infaira-canvas 0.2.0 → 0.4.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/dist/commands/init.js +107 -18
- package/package.json +3 -3
- package/templates/ICan-Widget-Development-Guide.md +58 -1
- package/templates/designer.d.ts +2 -2
- package/templates/ican.d.ts +55 -4
- package/templates/index.html +375 -49
- package/templates/ui.html +15 -0
package/dist/commands/init.js
CHANGED
|
@@ -165,29 +165,86 @@ import { registerWidget } from './ican';
|
|
|
165
165
|
import type { IContextProvider } from './ican';
|
|
166
166
|
import './styles.scss';
|
|
167
167
|
|
|
168
|
+
// ─── useService hook ──────────────────────────────────────────────────────────
|
|
169
|
+
// Call any InfAIra microservice — same pattern as Lucy UXP's executeService.
|
|
170
|
+
//
|
|
171
|
+
// Lucy UXP (old): uxpContext.executeService('Assets', 'Asset:List', { limit: 20 })
|
|
172
|
+
// ICan (now): useService(icanContext, 'Assets', 'Asset:List', { limit: 20 })
|
|
173
|
+
//
|
|
174
|
+
// The result is the raw microservice response — no extra wrapper to unwrap.
|
|
175
|
+
// For orchestrated flows that call multiple services, use executeAction with
|
|
176
|
+
// a Mythos model instead (see the commented-out examples below).
|
|
177
|
+
|
|
178
|
+
interface IServiceState<T> {
|
|
179
|
+
data: T | null;
|
|
180
|
+
loading: boolean;
|
|
181
|
+
error: string | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function useService<T = unknown>(
|
|
185
|
+
ctx: IContextProvider | undefined,
|
|
186
|
+
app: string,
|
|
187
|
+
service: string,
|
|
188
|
+
params: Record<string, unknown> = {},
|
|
189
|
+
deps: unknown[] = [],
|
|
190
|
+
): IServiceState<T> & { refetch: () => void } {
|
|
191
|
+
const [state, setState] = React.useState<IServiceState<T>>({ data: null, loading: true, error: null });
|
|
192
|
+
const [tick, setTick] = React.useState(0);
|
|
193
|
+
const mounted = React.useRef(true);
|
|
194
|
+
React.useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
195
|
+
|
|
196
|
+
React.useEffect(() => {
|
|
197
|
+
if (!ctx) { setState({ data: null, loading: false, error: null }); return; }
|
|
198
|
+
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
199
|
+
ctx.executeService(app, service, params)
|
|
200
|
+
.then(result => { if (mounted.current) setState({ data: result as T, loading: false, error: null }); })
|
|
201
|
+
.catch((err: unknown) => { if (mounted.current) setState({ data: null, loading: false, error: String(err) }); });
|
|
202
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
|
+
}, [ctx, app, service, tick, ...deps]);
|
|
204
|
+
|
|
205
|
+
return { ...state, refetch: () => setTick(t => t + 1) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
209
|
+
// Replace with real types from your microservice.
|
|
210
|
+
interface IItem {
|
|
211
|
+
id: number;
|
|
212
|
+
name: string;
|
|
213
|
+
[key: string]: unknown;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface IListResponse {
|
|
217
|
+
total: number;
|
|
218
|
+
data: IItem[];
|
|
219
|
+
}
|
|
220
|
+
|
|
168
221
|
export interface IWidgetProps {
|
|
169
222
|
icanContext?: IContextProvider;
|
|
170
223
|
instanceId?: string;
|
|
171
224
|
[key: string]: unknown;
|
|
172
225
|
}
|
|
173
226
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
// ─── Widget ───────────────────────────────────────────────────────────────────
|
|
228
|
+
const ${componentName}: React.FC<IWidgetProps> = ({ icanContext }) => {
|
|
229
|
+
// ── Fetch data via executeService (Lucy-UXP pattern) ──────────────────────
|
|
230
|
+
// Replace 'Assets' / 'Asset:List' with your app + service path.
|
|
231
|
+
// All InfAIra apps: Assets, Locations, UserManagement, FMS, PPM, IBMS
|
|
232
|
+
const { data, loading, error, refetch } = useService<IListResponse>(
|
|
233
|
+
icanContext,
|
|
234
|
+
'Assets', // app key (matches manifest app_key)
|
|
235
|
+
'Asset:List', // Model:Action
|
|
236
|
+
{ limit: 20, skip: 0 },
|
|
237
|
+
);
|
|
178
238
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
setData('Hello from ${widgetName}!');
|
|
189
|
-
setLoading(false);
|
|
190
|
-
}, [icanContext]);
|
|
239
|
+
// ── For Mythos orchestrated flows, use executeAction instead: ─────────────
|
|
240
|
+
// React.useEffect(() => {
|
|
241
|
+
// icanContext?.executeAction('mythos:AssetManager', 'ListAssets', { limit: 20 })
|
|
242
|
+
// .then(result => {
|
|
243
|
+
// // result = { run_id, status, output: { assets: { total, data } } }
|
|
244
|
+
// const list = (result as { output?: { assets?: IListResponse } })?.output?.assets;
|
|
245
|
+
// console.log(list);
|
|
246
|
+
// });
|
|
247
|
+
// }, [icanContext]);
|
|
191
248
|
|
|
192
249
|
if (loading) {
|
|
193
250
|
return (
|
|
@@ -201,13 +258,29 @@ const ${componentName}: React.FC<IWidgetProps> = ({ icanContext, instanceId: _in
|
|
|
201
258
|
return (
|
|
202
259
|
<div className="widget-error">
|
|
203
260
|
<p className="widget-error-message">{error}</p>
|
|
261
|
+
<button className="widget-retry" onClick={refetch}>Retry</button>
|
|
204
262
|
</div>
|
|
205
263
|
);
|
|
206
264
|
}
|
|
207
265
|
|
|
266
|
+
const items = data?.data ?? [];
|
|
267
|
+
const total = data?.total ?? 0;
|
|
268
|
+
|
|
208
269
|
return (
|
|
209
270
|
<div className="widget-root">
|
|
210
|
-
<
|
|
271
|
+
<div className="widget-header">
|
|
272
|
+
<span className="widget-title">${widgetName}</span>
|
|
273
|
+
<span className="widget-count">{total} items</span>
|
|
274
|
+
</div>
|
|
275
|
+
{items.length === 0 ? (
|
|
276
|
+
<p className="widget-empty">No data found.</p>
|
|
277
|
+
) : (
|
|
278
|
+
<ul className="widget-list">
|
|
279
|
+
{items.map(item => (
|
|
280
|
+
<li key={item.id} className="widget-list-item">{item.name}</li>
|
|
281
|
+
))}
|
|
282
|
+
</ul>
|
|
283
|
+
)}
|
|
211
284
|
</div>
|
|
212
285
|
);
|
|
213
286
|
};
|
|
@@ -288,9 +361,15 @@ import LocalizationMessages from '../localization.json';
|
|
|
288
361
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
289
362
|
|
|
290
363
|
export interface IActionOptions {
|
|
364
|
+
/** Parse response body as JSON before resolving. */
|
|
291
365
|
json?: boolean;
|
|
366
|
+
/** Stable identifier for cancellation. Required when cancelPrevious is set. */
|
|
292
367
|
key?: string;
|
|
368
|
+
/** When true and "key" is also set, any prior in-flight call sharing the
|
|
369
|
+
* same key is cancelled (its Promise resolves to null). Use for live-filter
|
|
370
|
+
* widgets where only the latest call result should land in state. */
|
|
293
371
|
cancelPrevious?: boolean;
|
|
372
|
+
/** Skip the 100ms batch window and fire immediately. */
|
|
294
373
|
executeImmediately?: boolean;
|
|
295
374
|
}
|
|
296
375
|
|
|
@@ -316,13 +395,23 @@ export interface IContextProvider {
|
|
|
316
395
|
* - executeAction('Buildings', 'List', { siteId }) → ICan native
|
|
317
396
|
* - executeAction('mythos:Inventory', 'UpdateStock', p) → Mythos engine
|
|
318
397
|
* The 'mythos:' prefix is the only difference at the call site; the
|
|
319
|
-
* ICan backend routes the call appropriately.
|
|
398
|
+
* ICan backend routes the call appropriately.
|
|
399
|
+
* options.cancelPrevious + options.key: when set, an in-flight call with
|
|
400
|
+
* the same key is cancelled (resolves null) and the new call wins. */
|
|
320
401
|
executeAction(model: string, action: string, parameters: unknown, options?: IActionOptions): Promise<unknown>;
|
|
321
402
|
executeService(app: string, service: string, parameters: unknown, options?: IActionOptions): Promise<unknown>;
|
|
322
403
|
/** List Mythos actions callable via executeAction('mythos:...', ...).
|
|
323
404
|
* Optional — present only when MYTHOS_BASE_URL is configured on the ICan
|
|
324
405
|
* backend. Treat absence as "Mythos integration disabled". */
|
|
325
406
|
listMythosActions?(): Promise<IMythosAction[]>;
|
|
407
|
+
/** Fire a Mythos external event with an optional payload. */
|
|
408
|
+
fireMythosEvent?(eventType: string, payload?: Record<string, unknown>): Promise<void>;
|
|
409
|
+
/** Build an IDataFunction over a Mythos collection for DataList/DataTable. */
|
|
410
|
+
fromMythosCollection?(model: string, collection: string): (
|
|
411
|
+
max: number,
|
|
412
|
+
lastPageToken: string,
|
|
413
|
+
args?: { search?: string } & Record<string, unknown>,
|
|
414
|
+
) => Promise<{ items: Array<Record<string, unknown>>; pageToken: string }>;
|
|
326
415
|
fireEvent(eventId: string): Promise<void>;
|
|
327
416
|
hasAppRole(app: string, role: string): boolean;
|
|
328
417
|
themeName?: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infaira-canvas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "InfAIra Canvas CLI — scaffold widgets that talk to ICan and Mythos.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "InfAIra Canvas CLI — scaffold widgets that talk to ICan and Mythos (actions, events, public portals, paginated collections).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"infaira",
|
|
7
7
|
"ican",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"type": "module",
|
|
17
17
|
"bin": {
|
|
18
|
-
"infaira-canvas": "
|
|
18
|
+
"infaira-canvas": "dist/index.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
@@ -38,8 +38,10 @@ In the portal, `icanContext` is always defined — the widget is never mounted u
|
|
|
38
38
|
| `language` | `string` | Active language code, e.g. `'en'` |
|
|
39
39
|
| `themeName` | `string \| undefined` | e.g. `'Dark'`, `'Glass Light'` |
|
|
40
40
|
| `themeType` | `'Dark' \| 'Light' \| 'Glass-Dark' \| 'Glass-Light' \| undefined` | Structured theme type for chart colours |
|
|
41
|
-
| `executeAction` | `(model, action, params?, options?) => Promise<unknown>` | Call an action. ICan native by default; prefix `model` with `mythos:` to route to the Mythos engine instead. |
|
|
41
|
+
| `executeAction` | `(model, action, params?, options?) => Promise<unknown>` | Call an action. ICan native by default; prefix `model` with `mythos:` to route to the Mythos engine instead. `options.cancelPrevious` + `options.key` to deduplicate live-filter calls. |
|
|
42
42
|
| `listMythosActions` | `() => Promise<IMythosAction[]> \| undefined` | List Mythos actions callable from this widget. Present only when the ICan backend has `MYTHOS_BASE_URL` configured. |
|
|
43
|
+
| `fireMythosEvent` | `(event_type, payload?) => Promise<void> \| undefined` | Fire a named external event into the Mythos bus. Triggers any matching Mythos event handlers. |
|
|
44
|
+
| `fromMythosCollection` | `(model, collection) => IDataFunction \| undefined` | Build a paginated data source over a Mythos collection — drops into DataList / DataTable. |
|
|
43
45
|
| `fireEvent` | `(eventId) => Promise<void>` | Trigger a portal event |
|
|
44
46
|
| `hasAppRole` | `(app, role) => boolean` | Check if the user has a role |
|
|
45
47
|
| `$L` | `(code, params?) => string` | Translate a localisation key |
|
|
@@ -125,6 +127,61 @@ const handleRun = async () => {
|
|
|
125
127
|
|
|
126
128
|
A working reference widget lives at `Widgets/ICan widgets/mythosdemo/` — pick an action, fill params, run, render the response.
|
|
127
129
|
|
|
130
|
+
### Cancelling stale calls (`options.cancelPrevious`)
|
|
131
|
+
|
|
132
|
+
Widgets that fire on every keystroke — search bars, live filters — end up racing requests against themselves. Use `cancelPrevious` + `key` to ensure only the latest call's result lands:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
const onSearch = (text: string) => {
|
|
136
|
+
icanContext.executeAction(
|
|
137
|
+
'mythos:Inventory',
|
|
138
|
+
'Search',
|
|
139
|
+
{ q: text },
|
|
140
|
+
{ cancelPrevious: true, key: 'inventory-search' },
|
|
141
|
+
).then((res) => {
|
|
142
|
+
if (res !== null) setResults(res); // null means this call was cancelled
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Calls with the same `key` are tracked; when a newer one is issued, older ones resolve to `null` instead of delivering stale data into your state.
|
|
148
|
+
|
|
149
|
+
### Firing Mythos events
|
|
150
|
+
|
|
151
|
+
`icanContext.fireMythosEvent('order_placed', { id: 4821 })` publishes the event into Mythos's bus. Any Mythos workflow with a matching External Event trigger fires. Useful for widget → workflow dispatch where you don't need a return value.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
await icanContext.fireMythosEvent?.('user_logged_in', { userId });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Optional — `fireMythosEvent` is `undefined` when Mythos integration is disabled. Guard with `?.()`.
|
|
158
|
+
|
|
159
|
+
### Reading from a Mythos collection
|
|
160
|
+
|
|
161
|
+
`icanContext.fromMythosCollection(model, collection)` returns a function with the same shape ICan's DataList / DataTable already accept:
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
const fetchPage = icanContext.fromMythosCollection!('Inventory', 'items');
|
|
165
|
+
|
|
166
|
+
<DataList
|
|
167
|
+
dataFunction={fetchPage}
|
|
168
|
+
pageSize={50}
|
|
169
|
+
renderItem={(item) => <div>{item.sku} — {item.qty}</div>}
|
|
170
|
+
/>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The function takes `(max, lastPageToken, args?)` and returns `{ items, pageToken }`. Empty `pageToken` means "no more pages". Supports `args.search` for text filtering against the collection's searchable fields.
|
|
174
|
+
|
|
175
|
+
### Public portals (anonymous Mythos access)
|
|
176
|
+
|
|
177
|
+
When a widget runs on a `is_public: true` portal, the host calls `/api/public/execute-batch` instead of the authenticated endpoint. For `mythos:` calls in that batch:
|
|
178
|
+
|
|
179
|
+
- The Mythos action **must** have `no_auth: true` set in its API Route config
|
|
180
|
+
- The call is forwarded to Mythos's public route `/api/<scope>/models/{name}/actions/{name}` with no JWT
|
|
181
|
+
- Mythos enforces `no_auth` at its middleware layer — actions without that flag return `401` to the widget
|
|
182
|
+
|
|
183
|
+
No code change in the widget itself — the same `executeAction('mythos:X', 'Y', params)` call works in both authenticated and public portals.
|
|
184
|
+
|
|
128
185
|
---
|
|
129
186
|
|
|
130
187
|
## 3. Widget settings panel — `IWidgetPropConfig`
|
package/templates/designer.d.ts
CHANGED
|
@@ -240,7 +240,7 @@ declare module "widget-designer/components" {
|
|
|
240
240
|
params?: { [key: string]: any }
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
export interface
|
|
243
|
+
export interface ICanActionConfig {
|
|
244
244
|
modelName: string;
|
|
245
245
|
action: string;
|
|
246
246
|
disableParamters?: boolean,
|
|
@@ -271,7 +271,7 @@ declare module "widget-designer/components" {
|
|
|
271
271
|
type: IActionType,
|
|
272
272
|
redirectUrl?: IRedirectUrl
|
|
273
273
|
redirectWidget?: IRedirectWidget;
|
|
274
|
-
|
|
274
|
+
canAction?: ICanActionConfig
|
|
275
275
|
}
|
|
276
276
|
export interface IActionButtonProps {
|
|
277
277
|
designer?: any
|
package/templates/ican.d.ts
CHANGED
|
@@ -658,7 +658,7 @@ declare module "ican/hooks" {
|
|
|
658
658
|
// ─── useFields ────────────────────────────────────────────────────────────
|
|
659
659
|
/**
|
|
660
660
|
* Lightweight controlled-form helper. Returns a getter/setter for each named
|
|
661
|
-
* field.
|
|
661
|
+
* field.
|
|
662
662
|
*/
|
|
663
663
|
export function useFields<T extends Record<string, unknown>>(
|
|
664
664
|
initial: T
|
|
@@ -714,11 +714,27 @@ declare module "ican/context" {
|
|
|
714
714
|
*
|
|
715
715
|
* Both call shapes return a Promise that resolves to the action's output
|
|
716
716
|
* envelope. For Mythos, the envelope is `{ run_id, status, output? }`.
|
|
717
|
+
*
|
|
718
|
+
* `options.cancelPrevious` + `options.key`: when an in-flight call shares
|
|
719
|
+
* the same key, its Promise resolves to `null` and the newer call wins.
|
|
720
|
+
* Designed for live-filter widgets where only the latest result matters.
|
|
717
721
|
*/
|
|
718
722
|
executeAction(
|
|
719
723
|
model: string,
|
|
720
724
|
action: string,
|
|
721
|
-
params?: Record<string, unknown
|
|
725
|
+
params?: Record<string, unknown>,
|
|
726
|
+
options?: { cancelPrevious?: boolean; key?: string }
|
|
727
|
+
): Promise<unknown>;
|
|
728
|
+
/**
|
|
729
|
+
* Call an application service directly. The ICan/Mythos service registry
|
|
730
|
+
* resolves `<app>.<service>` (e.g. 'Assets' + 'Asset:List') to the
|
|
731
|
+
* microservice and returns its raw response — no model/DAG needed.
|
|
732
|
+
*/
|
|
733
|
+
executeService(
|
|
734
|
+
app: string,
|
|
735
|
+
service: string,
|
|
736
|
+
params?: Record<string, unknown>,
|
|
737
|
+
options?: { cancelPrevious?: boolean; key?: string }
|
|
722
738
|
): Promise<unknown>;
|
|
723
739
|
/**
|
|
724
740
|
* List every Mythos action published and callable from this widget.
|
|
@@ -727,6 +743,43 @@ declare module "ican/context" {
|
|
|
727
743
|
* once at mount and cache the result themselves.
|
|
728
744
|
*/
|
|
729
745
|
listMythosActions?: () => Promise<IMythosAction[]>;
|
|
746
|
+
/**
|
|
747
|
+
* List models for a config-panel model picker (`{ id, name }[]`). Optional —
|
|
748
|
+
* present only when MYTHOS_BASE_URL is set.
|
|
749
|
+
*/
|
|
750
|
+
listMythosModels?: () => Promise<Array<{ id: string; name: string }>>;
|
|
751
|
+
/**
|
|
752
|
+
* Returns true when the current user holds any of the given app roles.
|
|
753
|
+
* In the dev harness this is always true (full access); in production it
|
|
754
|
+
* checks the JWT principal's roles.
|
|
755
|
+
*/
|
|
756
|
+
hasAppRole?: (roles: string | string[]) => boolean | Promise<boolean>;
|
|
757
|
+
/** Localization helper — `$L(code, params?)` resolves a locale key to a translated string. */
|
|
758
|
+
$L?: (code: string, params?: Record<string, unknown>) => string;
|
|
759
|
+
/**
|
|
760
|
+
* Fire an external event into the Mythos engine. The event_type matches
|
|
761
|
+
* what Mythos event-handlers subscribe to (configured in the model's
|
|
762
|
+
* Events tab). Optional — present only when MYTHOS_BASE_URL is configured.
|
|
763
|
+
*/
|
|
764
|
+
fireMythosEvent?: (
|
|
765
|
+
eventType: string,
|
|
766
|
+
payload?: Record<string, unknown>
|
|
767
|
+
) => Promise<void>;
|
|
768
|
+
/**
|
|
769
|
+
* Build an IDataFunction over a Mythos collection — drop-in data source
|
|
770
|
+
* for DataList / DataTable.
|
|
771
|
+
* const fn = icanContext.fromMythosCollection('Inventory', 'items')
|
|
772
|
+
* const { items, pageToken } = await fn(50, '', { search: 'foo' })
|
|
773
|
+
* Optional — present only when MYTHOS_BASE_URL is configured.
|
|
774
|
+
*/
|
|
775
|
+
fromMythosCollection?: (
|
|
776
|
+
model: string,
|
|
777
|
+
collection: string
|
|
778
|
+
) => (
|
|
779
|
+
max: number,
|
|
780
|
+
lastPageToken: string,
|
|
781
|
+
args?: { search?: string } & Record<string, unknown>
|
|
782
|
+
) => Promise<{ items: Array<Record<string, unknown>>; pageToken: string }>;
|
|
730
783
|
/** Resolve a relative ICan backend path against the portal base URL. */
|
|
731
784
|
resolveUrl(path: string): string;
|
|
732
785
|
/** Logging helper namespaced to this widget instance. */
|
|
@@ -760,7 +813,6 @@ interface Window {
|
|
|
760
813
|
React: unknown;
|
|
761
814
|
ReactDOM: unknown;
|
|
762
815
|
ICanComponents: Record<string, unknown>;
|
|
763
|
-
UXPComponents: Record<string, unknown>;
|
|
764
816
|
WidgetDesignerComponents: Record<string, unknown>;
|
|
765
817
|
/** Widget registry — qualifiedId → registration entry. */
|
|
766
818
|
__ican_registry: Map<string, unknown>;
|
|
@@ -768,7 +820,6 @@ interface Window {
|
|
|
768
820
|
registerWidget(registration: {
|
|
769
821
|
id: string;
|
|
770
822
|
component?: unknown;
|
|
771
|
-
/** UXP-legacy alias for `component`. */
|
|
772
823
|
widget?: unknown;
|
|
773
824
|
configs?: unknown;
|
|
774
825
|
defaultProps?: Record<string, unknown>;
|
package/templates/index.html
CHANGED
|
@@ -775,6 +775,92 @@
|
|
|
775
775
|
box-shadow: 0 0 6px rgba(74, 222, 128, 0.45);
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
.ican-conn-dot.error {
|
|
779
|
+
background: var(--ican-error, #f87171);
|
|
780
|
+
box-shadow: 0 0 6px rgba(248, 113, 113, 0.45);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.ican-conn-dot.testing {
|
|
784
|
+
background: #facc15;
|
|
785
|
+
animation: conn-pulse 0.9s ease-in-out infinite;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
@keyframes conn-pulse {
|
|
789
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
790
|
+
50% { opacity: 0.35; transform: scale(0.7); }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
.ican-conn-error {
|
|
794
|
+
font-size: 10px;
|
|
795
|
+
color: var(--ican-error, #f87171);
|
|
796
|
+
font-family: var(--ican-font-mono);
|
|
797
|
+
margin-top: 2px;
|
|
798
|
+
white-space: nowrap;
|
|
799
|
+
overflow: hidden;
|
|
800
|
+
text-overflow: ellipsis;
|
|
801
|
+
max-width: 100%;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.ican-conn-input-wrap {
|
|
805
|
+
position: relative;
|
|
806
|
+
display: flex;
|
|
807
|
+
align-items: center;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.ican-conn-input-wrap .ican-conn-input { padding-right: 34px; }
|
|
811
|
+
|
|
812
|
+
.ican-conn-show-btn {
|
|
813
|
+
position: absolute;
|
|
814
|
+
right: 8px;
|
|
815
|
+
background: none;
|
|
816
|
+
border: none;
|
|
817
|
+
cursor: pointer;
|
|
818
|
+
padding: 0;
|
|
819
|
+
color: var(--ican-secondary-text);
|
|
820
|
+
line-height: 1;
|
|
821
|
+
display: flex;
|
|
822
|
+
align-items: center;
|
|
823
|
+
flex-shrink: 0;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.ican-conn-show-btn:hover { color: var(--ican-primary-text); }
|
|
827
|
+
|
|
828
|
+
.ican-live-badge {
|
|
829
|
+
display: inline-flex;
|
|
830
|
+
align-items: center;
|
|
831
|
+
gap: 4px;
|
|
832
|
+
font-size: 9px;
|
|
833
|
+
font-weight: 700;
|
|
834
|
+
text-transform: uppercase;
|
|
835
|
+
letter-spacing: 0.8px;
|
|
836
|
+
padding: 2px 7px;
|
|
837
|
+
border-radius: 3px;
|
|
838
|
+
font-family: var(--ican-font-mono);
|
|
839
|
+
background: rgba(255,255,255,0.05);
|
|
840
|
+
color: var(--ican-tertiary-text);
|
|
841
|
+
border: 1px solid var(--ican-border);
|
|
842
|
+
user-select: none;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.ican-live-badge.live {
|
|
846
|
+
background: rgba(74, 222, 128, 0.10);
|
|
847
|
+
color: #4ade80;
|
|
848
|
+
border-color: rgba(74, 222, 128, 0.22);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.ican-live-badge.live-error {
|
|
852
|
+
background: rgba(248, 113, 113, 0.10);
|
|
853
|
+
color: #f87171;
|
|
854
|
+
border-color: rgba(248, 113, 113, 0.22);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
.ican-live-badge.live-testing {
|
|
858
|
+
background: rgba(250, 204, 21, 0.10);
|
|
859
|
+
color: #facc15;
|
|
860
|
+
border-color: rgba(250, 204, 21, 0.22);
|
|
861
|
+
animation: conn-pulse 0.9s ease-in-out infinite;
|
|
862
|
+
}
|
|
863
|
+
|
|
778
864
|
/* ═══════════════════════════════════════════════════════
|
|
779
865
|
Main Content
|
|
780
866
|
═══════════════════════════════════════════════════════ */
|
|
@@ -1408,46 +1494,163 @@
|
|
|
1408
1494
|
return THEMES[0];
|
|
1409
1495
|
}
|
|
1410
1496
|
|
|
1497
|
+
/* ─── Dev-harness security helpers ────────────────────────
|
|
1498
|
+
isValidBackendUrl: permits https:// (any host) and
|
|
1499
|
+
http://localhost / http://127.0.0.1 only. Plain http:// to
|
|
1500
|
+
external hosts is blocked (SSRF / mixed-content risk).
|
|
1501
|
+
─────────────────────────────────────────────────────────── */
|
|
1502
|
+
function isValidBackendUrl(raw) {
|
|
1503
|
+
if (!raw || typeof raw !== 'string') return false;
|
|
1504
|
+
var url;
|
|
1505
|
+
try { url = new URL(raw); } catch (_) { return false; }
|
|
1506
|
+
if (url.protocol === 'https:') return true;
|
|
1507
|
+
if (url.protocol === 'http:') {
|
|
1508
|
+
var h = url.hostname;
|
|
1509
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
|
|
1510
|
+
}
|
|
1511
|
+
return false;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function normalizeUrl(raw) {
|
|
1515
|
+
return (raw || '').trim().replace(/\/+$/, '');
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// fetchWithTimeout wraps fetch with an AbortController so hung requests
|
|
1519
|
+
// don't freeze the harness indefinitely. Default timeout: 15 s.
|
|
1520
|
+
function fetchWithTimeout(url, opts, ms) {
|
|
1521
|
+
ms = ms || 15000;
|
|
1522
|
+
var ctrl = new AbortController();
|
|
1523
|
+
var timer = setTimeout(function () { ctrl.abort(); }, ms);
|
|
1524
|
+
return fetch(url, Object.assign({}, opts, { signal: ctrl.signal })).then(
|
|
1525
|
+
function (r) { clearTimeout(timer); return r; },
|
|
1526
|
+
function (e) {
|
|
1527
|
+
clearTimeout(timer);
|
|
1528
|
+
if (e && e.name === 'AbortError') {
|
|
1529
|
+
throw new Error('Request timed out — is the Mythos engine reachable?');
|
|
1530
|
+
}
|
|
1531
|
+
throw new Error('Network error — check the Mythos URL and that CORS is enabled on the server');
|
|
1532
|
+
}
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1411
1536
|
/* ─── Mock icanContext factory ─────────────────────────── */
|
|
1412
1537
|
function makeICanContext(themeKey) {
|
|
1413
1538
|
var info = getThemeInfo(themeKey);
|
|
1539
|
+
var orchUrl = (localStorage.getItem(LS_ORCH_URL) || '').replace(/\/+$/, '');
|
|
1540
|
+
var apiKey = localStorage.getItem(LS_API_KEY) || '';
|
|
1541
|
+
// "configured" = the developer entered a Mythos/Orch URL + API Key in the
|
|
1542
|
+
// Developer Tools (Connection) panel. When set, the harness hits a REAL
|
|
1543
|
+
// backend (the same data the widget will see in production). When unset,
|
|
1544
|
+
// it falls back to a clear "not configured" message so widgets don't crash.
|
|
1545
|
+
var configured = !!(orchUrl && apiKey);
|
|
1546
|
+
|
|
1547
|
+
// batch() POSTs the widget-batch endpoint Mythos exposes. One API-key gate
|
|
1548
|
+
// behind which executeAction, executeService and fromMythosCollection all
|
|
1549
|
+
// resolve. Security: URL is validated before every call so a stale
|
|
1550
|
+
// localStorage value can never route requests to an arbitrary host.
|
|
1551
|
+
function batch(items) {
|
|
1552
|
+
if (!configured) {
|
|
1553
|
+
return Promise.resolve(items.map(function (it) {
|
|
1554
|
+
console.warn('[ICan Dev] Not configured — set Mythos URL + API Key in Developer Tools.');
|
|
1555
|
+
return { id: it.id, ok: false, error: 'not configured: open Developer Tools and set the Mythos URL + API Key' };
|
|
1556
|
+
}));
|
|
1557
|
+
}
|
|
1558
|
+
if (!isValidBackendUrl(orchUrl)) {
|
|
1559
|
+
return Promise.resolve(items.map(function (it) {
|
|
1560
|
+
console.error('[ICan Dev] Blocked request to invalid URL:', orchUrl);
|
|
1561
|
+
return { id: it.id, ok: false, error: 'invalid URL — only https:// or http://localhost is allowed' };
|
|
1562
|
+
}));
|
|
1563
|
+
}
|
|
1564
|
+
return fetchWithTimeout(orchUrl + '/api/widget-batch', {
|
|
1565
|
+
method: 'POST',
|
|
1566
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
1567
|
+
body: JSON.stringify({ items: items }),
|
|
1568
|
+
}).then(function (r) {
|
|
1569
|
+
if (r.status === 401) throw new Error('Unauthorized — check the API Key in Developer Tools');
|
|
1570
|
+
if (r.status === 403) throw new Error('Forbidden — API Key may lack scope for this action');
|
|
1571
|
+
if (!r.ok) throw new Error('Mythos returned ' + r.status + ' — check server logs');
|
|
1572
|
+
return r.json();
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1414
1576
|
return {
|
|
1415
1577
|
environment: 'dev',
|
|
1416
1578
|
userKey: 'dev-user-001',
|
|
1417
1579
|
language: 'en',
|
|
1418
1580
|
root: 'root',
|
|
1419
|
-
orchUrl:
|
|
1420
|
-
apiKey:
|
|
1581
|
+
orchUrl: orchUrl,
|
|
1582
|
+
apiKey: apiKey,
|
|
1583
|
+
|
|
1584
|
+
// executeAction → real Mythos action. Returns the {run_id,status,output}
|
|
1585
|
+
// envelope per ican.d.ts (the batch returns the bare output as `data`).
|
|
1421
1586
|
executeAction: function (model, action, params) {
|
|
1422
|
-
// Mythos-routed calls use the `mythos:<ModelName>` prefix in production.
|
|
1423
|
-
// In the dev harness we just log them distinctively so authors can see
|
|
1424
|
-
// whether their widget is targeting ICan native or Mythos.
|
|
1425
1587
|
var isMythos = typeof model === 'string' && model.toLowerCase().indexOf('mythos:') === 0;
|
|
1426
1588
|
console.log('[ICan Dev]' + (isMythos ? ' [Mythos]' : '') + ' executeAction:', model, action, params);
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1589
|
+
return batch([{ id: 'a', model: model, action: action, parameters: params || {} }]).then(function (res) {
|
|
1590
|
+
var it = (res && res[0]) || {};
|
|
1591
|
+
if (!it.ok) { console.warn('[ICan Dev] executeAction error:', it.error); return { run_id: null, status: 'error', output: null, error: it.error }; }
|
|
1592
|
+
return { run_id: 'dev', status: 'ok', output: it.data };
|
|
1593
|
+
});
|
|
1432
1594
|
},
|
|
1595
|
+
|
|
1596
|
+
// executeService → real Mythos /api/services/dispatch (App.Service path).
|
|
1433
1597
|
executeService: function (app, service, params) {
|
|
1434
1598
|
console.log('[ICan Dev] executeService:', app, service, params);
|
|
1435
|
-
return
|
|
1599
|
+
return batch([{ id: 's', app: app, service: service, parameters: params || {} }]).then(function (res) {
|
|
1600
|
+
var it = (res && res[0]) || {};
|
|
1601
|
+
if (!it.ok) { console.warn('[ICan Dev] executeService error:', it.error); return null; }
|
|
1602
|
+
return it.data;
|
|
1603
|
+
});
|
|
1436
1604
|
},
|
|
1605
|
+
|
|
1606
|
+
// listMythosActions → real published-action index via the batch "discover"
|
|
1607
|
+
// item (API-key only — no JWT needed). Powers config-panel action pickers.
|
|
1437
1608
|
listMythosActions: function () {
|
|
1438
|
-
// Dev stub — returns a couple of synthetic actions so widgets that
|
|
1439
|
-
// populate dropdowns from discovery don't render empty.
|
|
1440
1609
|
console.log('[ICan Dev] listMythosActions');
|
|
1441
|
-
return Promise.resolve([
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1610
|
+
if (!configured) return Promise.resolve([]);
|
|
1611
|
+
return batch([{ id: 'd', discover: 'actions' }]).then(function (res) {
|
|
1612
|
+
var it = (res && res[0]) || {};
|
|
1613
|
+
return (it.ok && Array.isArray(it.data)) ? it.data : [];
|
|
1614
|
+
}).catch(function () { return []; });
|
|
1445
1615
|
},
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1616
|
+
|
|
1617
|
+
// listMythosModels → model picker source (config panels).
|
|
1618
|
+
listMythosModels: function () {
|
|
1619
|
+
if (!configured) return Promise.resolve([]);
|
|
1620
|
+
return batch([{ id: 'd', discover: 'models' }]).then(function (res) {
|
|
1621
|
+
var it = (res && res[0]) || {};
|
|
1622
|
+
return (it.ok && Array.isArray(it.data)) ? it.data : [];
|
|
1623
|
+
}).catch(function () { return []; });
|
|
1624
|
+
},
|
|
1625
|
+
|
|
1626
|
+
// fireMythosEvent → real event publish (best-effort, fire-and-forget).
|
|
1627
|
+
fireMythosEvent: function (eventType, payload) {
|
|
1628
|
+
console.log('[ICan Dev] fireMythosEvent:', eventType, payload);
|
|
1629
|
+
if (!configured || !isValidBackendUrl(orchUrl)) return Promise.resolve();
|
|
1630
|
+
return fetchWithTimeout(orchUrl + '/api/events/publish', {
|
|
1631
|
+
method: 'POST',
|
|
1632
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
1633
|
+
body: JSON.stringify({ event_type: eventType, payload: payload || {} }),
|
|
1634
|
+
}, 8000).then(function () {}).catch(function (e) {
|
|
1635
|
+
console.warn('[ICan Dev] fireMythosEvent failed:', e && e.message);
|
|
1636
|
+
});
|
|
1449
1637
|
},
|
|
1450
|
-
|
|
1638
|
+
|
|
1639
|
+
// fromMythosCollection → real collection read through the batch.
|
|
1640
|
+
fromMythosCollection: function (model, collection) {
|
|
1641
|
+
return function (max, lastPageToken, args) {
|
|
1642
|
+
console.log('[ICan Dev] fromMythosCollection:', model, collection, { max: max, lastPageToken: lastPageToken, args: args });
|
|
1643
|
+
return batch([{ id: 'c', model: model, collection: collection, parameters: { max: max, last: lastPageToken || '0', filter: args || {} } }]).then(function (res) {
|
|
1644
|
+
var it = (res && res[0]) || {};
|
|
1645
|
+
var items = (it.ok && Array.isArray(it.data)) ? it.data : [];
|
|
1646
|
+
var last = parseInt(lastPageToken || '0', 10) || 0;
|
|
1647
|
+
return { items: items, pageToken: String(last + items.length) };
|
|
1648
|
+
});
|
|
1649
|
+
};
|
|
1650
|
+
},
|
|
1651
|
+
|
|
1652
|
+
fireEvent: function (id) { console.log('[ICan Dev] fireEvent:', id); return Promise.resolve(); },
|
|
1653
|
+
hasAppRole: function () { return true; }, // dev harness: full access
|
|
1451
1654
|
$L: function (code) { return code; },
|
|
1452
1655
|
themeName: info.label,
|
|
1453
1656
|
themeType: info.type,
|
|
@@ -1818,46 +2021,108 @@
|
|
|
1818
2021
|
|
|
1819
2022
|
/* ─── ConnectionPanel ─────────────────────────────────── */
|
|
1820
2023
|
function ConnectionPanel(props) {
|
|
1821
|
-
var
|
|
1822
|
-
var
|
|
1823
|
-
var
|
|
1824
|
-
var
|
|
2024
|
+
var onSave = props.onSave;
|
|
2025
|
+
var onTest = props.onTest;
|
|
2026
|
+
var connStatus = props.connStatus || 'idle';
|
|
2027
|
+
var connError = props.connError || '';
|
|
2028
|
+
|
|
2029
|
+
// Local controlled state for the two fields + key visibility
|
|
2030
|
+
var urlState = React.useState(props.orchUrl || '');
|
|
2031
|
+
var localUrl = urlState[0]; var setLocalUrl = urlState[1];
|
|
2032
|
+
var keyState = React.useState(props.apiKey || '');
|
|
2033
|
+
var localKey = keyState[0]; var setLocalKey = keyState[1];
|
|
2034
|
+
var showKeyState = React.useState(false);
|
|
2035
|
+
var showKey = showKeyState[0]; var setShowKey = showKeyState[1];
|
|
2036
|
+
|
|
2037
|
+
var urlValidationErr = localUrl && !isValidBackendUrl(localUrl)
|
|
2038
|
+
? 'Only https:// or http://localhost allowed'
|
|
2039
|
+
: '';
|
|
2040
|
+
|
|
2041
|
+
var dotClass = 'ican-conn-dot';
|
|
2042
|
+
var statusText = 'Not configured';
|
|
2043
|
+
if (connStatus === 'testing') {
|
|
2044
|
+
dotClass += ' testing'; statusText = 'Testing…';
|
|
2045
|
+
} else if (connStatus === 'connected') {
|
|
2046
|
+
dotClass += ' connected'; statusText = 'Connected';
|
|
2047
|
+
} else if (connStatus === 'error') {
|
|
2048
|
+
dotClass += ' error'; statusText = 'Error';
|
|
2049
|
+
} else if (localUrl && localKey) {
|
|
2050
|
+
dotClass += ' connected'; statusText = 'Configured';
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
var isTesting = connStatus === 'testing';
|
|
1825
2054
|
|
|
1826
2055
|
return h('div', { className: 'ican-conn-panel' },
|
|
1827
2056
|
h('div', { className: 'ican-conn-field' },
|
|
1828
|
-
h('label', { className: 'ican-conn-label', htmlFor: 'conn-orch' }, '
|
|
2057
|
+
h('label', { className: 'ican-conn-label', htmlFor: 'conn-orch' }, 'Mythos URL'),
|
|
1829
2058
|
h('input', {
|
|
1830
2059
|
id: 'conn-orch',
|
|
1831
2060
|
className: 'ican-conn-input',
|
|
1832
2061
|
type: 'text',
|
|
1833
|
-
|
|
1834
|
-
placeholder: 'https://your-
|
|
1835
|
-
'aria-label': '
|
|
1836
|
-
|
|
1837
|
-
|
|
2062
|
+
value: localUrl,
|
|
2063
|
+
placeholder: 'https://your-mythos-engine:8090',
|
|
2064
|
+
'aria-label': 'Mythos engine URL',
|
|
2065
|
+
'aria-describedby': urlValidationErr ? 'conn-url-err' : undefined,
|
|
2066
|
+
onChange: function (e) { setLocalUrl(e.target.value); },
|
|
2067
|
+
}),
|
|
2068
|
+
urlValidationErr
|
|
2069
|
+
? h('div', { id: 'conn-url-err', className: 'ican-conn-error', role: 'alert' }, urlValidationErr)
|
|
2070
|
+
: null
|
|
1838
2071
|
),
|
|
1839
2072
|
h('div', { className: 'ican-conn-field' },
|
|
1840
2073
|
h('label', { className: 'ican-conn-label', htmlFor: 'conn-key' }, 'API Key'),
|
|
1841
|
-
h('
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2074
|
+
h('div', { className: 'ican-conn-input-wrap' },
|
|
2075
|
+
h('input', {
|
|
2076
|
+
id: 'conn-key',
|
|
2077
|
+
className: 'ican-conn-input',
|
|
2078
|
+
type: showKey ? 'text' : 'password',
|
|
2079
|
+
value: localKey,
|
|
2080
|
+
placeholder: showKey ? 'mk_live_…' : '••••••••••••••••',
|
|
2081
|
+
'aria-label': 'API Key',
|
|
2082
|
+
autoComplete: 'off',
|
|
2083
|
+
onChange: function (e) { setLocalKey(e.target.value); },
|
|
2084
|
+
}),
|
|
2085
|
+
h('button', {
|
|
2086
|
+
className: 'ican-conn-show-btn',
|
|
2087
|
+
type: 'button',
|
|
2088
|
+
title: showKey ? 'Hide key' : 'Show key',
|
|
2089
|
+
'aria-label': showKey ? 'Hide API Key' : 'Show API Key',
|
|
2090
|
+
onClick: function () { setShowKey(!showKey); },
|
|
2091
|
+
}, showKey
|
|
2092
|
+
? h('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
2093
|
+
h('path', { d: 'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94' }),
|
|
2094
|
+
h('path', { d: 'M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19' }),
|
|
2095
|
+
h('line', { x1: 1, y1: 1, x2: 23, y2: 23 })
|
|
2096
|
+
)
|
|
2097
|
+
: h('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
2098
|
+
h('path', { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z' }),
|
|
2099
|
+
h('circle', { cx: 12, cy: 12, r: 3 })
|
|
2100
|
+
)
|
|
2101
|
+
)
|
|
2102
|
+
),
|
|
2103
|
+
connStatus === 'error' && connError
|
|
2104
|
+
? h('div', { className: 'ican-conn-error', role: 'alert' }, connError)
|
|
2105
|
+
: null
|
|
1850
2106
|
),
|
|
1851
2107
|
h('div', { className: 'ican-conn-actions' },
|
|
2108
|
+
h('button', {
|
|
2109
|
+
className: 'ican-btn ican-btn-secondary',
|
|
2110
|
+
type: 'button',
|
|
2111
|
+
disabled: isTesting || !!urlValidationErr,
|
|
2112
|
+
style: { height: 30, fontSize: 12 },
|
|
2113
|
+
title: 'Test the connection without saving',
|
|
2114
|
+
onClick: function () { onTest(localUrl, localKey); },
|
|
2115
|
+
}, isTesting ? 'Testing…' : 'Test'),
|
|
1852
2116
|
h('button', {
|
|
1853
2117
|
className: 'ican-btn ican-btn-primary',
|
|
1854
2118
|
type: 'button',
|
|
2119
|
+
disabled: !!urlValidationErr,
|
|
1855
2120
|
style: { height: 30, fontSize: 12 },
|
|
1856
|
-
onClick: function () { onSave(
|
|
2121
|
+
onClick: function () { onSave(localUrl, localKey); },
|
|
1857
2122
|
}, 'Save'),
|
|
1858
2123
|
h('div', { className: 'ican-conn-status' },
|
|
1859
|
-
h('div', { className:
|
|
1860
|
-
|
|
2124
|
+
h('div', { className: dotClass }),
|
|
2125
|
+
statusText
|
|
1861
2126
|
)
|
|
1862
2127
|
)
|
|
1863
2128
|
);
|
|
@@ -1883,6 +2148,8 @@
|
|
|
1883
2148
|
editMode: false,
|
|
1884
2149
|
layouts: JSON.parse(localStorage.getItem('ican_layouts') || '{}'),
|
|
1885
2150
|
gridWidth: window.innerWidth - 40,
|
|
2151
|
+
connStatus: 'idle', // idle | testing | connected | error
|
|
2152
|
+
connError: '',
|
|
1886
2153
|
};
|
|
1887
2154
|
|
|
1888
2155
|
this._onRegistryUpdate = this._onRegistryUpdate.bind(this);
|
|
@@ -1894,6 +2161,7 @@
|
|
|
1894
2161
|
this._addWidget = this._addWidget.bind(this);
|
|
1895
2162
|
this._removeWidget = this._removeWidget.bind(this);
|
|
1896
2163
|
this._saveConnection = this._saveConnection.bind(this);
|
|
2164
|
+
this._testConnection = this._testConnection.bind(this);
|
|
1897
2165
|
this._toggleEditMode = this._toggleEditMode.bind(this);
|
|
1898
2166
|
this._onLayoutChange = this._onLayoutChange.bind(this);
|
|
1899
2167
|
}
|
|
@@ -1949,12 +2217,50 @@
|
|
|
1949
2217
|
};
|
|
1950
2218
|
|
|
1951
2219
|
App.prototype._saveConnection = function (orchUrl, apiKey) {
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2220
|
+
var url = normalizeUrl(orchUrl);
|
|
2221
|
+
var key = (apiKey || '').trim();
|
|
2222
|
+
if (url && !isValidBackendUrl(url)) {
|
|
2223
|
+
showToast('Invalid URL — only https:// or http://localhost allowed', 'error');
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
localStorage.setItem(LS_ORCH_URL, url);
|
|
2227
|
+
localStorage.setItem(LS_API_KEY, key);
|
|
2228
|
+
this.setState({ orchUrl: url, apiKey: key, connStatus: 'idle', connError: '' });
|
|
1955
2229
|
showToast('Connection settings saved', 'success');
|
|
1956
2230
|
};
|
|
1957
2231
|
|
|
2232
|
+
App.prototype._testConnection = function (orchUrl, apiKey) {
|
|
2233
|
+
var self = this;
|
|
2234
|
+
var url = normalizeUrl(orchUrl);
|
|
2235
|
+
var key = (apiKey || '').trim();
|
|
2236
|
+
if (!url || !key) {
|
|
2237
|
+
showToast('Enter the Mythos URL and API Key first', 'info');
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
if (!isValidBackendUrl(url)) {
|
|
2241
|
+
self.setState({ connStatus: 'error', connError: 'Only https:// or http://localhost allowed' });
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
self.setState({ connStatus: 'testing', connError: '' });
|
|
2245
|
+
fetchWithTimeout(url + '/api/widget-batch', {
|
|
2246
|
+
method: 'POST',
|
|
2247
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
|
|
2248
|
+
body: JSON.stringify({ items: [{ id: 'ping', discover: 'actions' }] }),
|
|
2249
|
+
}, 10000).then(function (r) {
|
|
2250
|
+
if (r.status === 401) throw new Error('Unauthorized — check your API Key');
|
|
2251
|
+
if (r.status === 403) throw new Error('Forbidden — API Key lacks required scope');
|
|
2252
|
+
if (!r.ok) throw new Error('Server returned ' + r.status);
|
|
2253
|
+
return r.json();
|
|
2254
|
+
}).then(function () {
|
|
2255
|
+
self.setState({ connStatus: 'connected', connError: '' });
|
|
2256
|
+
showToast('Connection successful', 'success');
|
|
2257
|
+
}).catch(function (e) {
|
|
2258
|
+
var msg = (e && e.message) || 'Connection failed';
|
|
2259
|
+
self.setState({ connStatus: 'error', connError: msg });
|
|
2260
|
+
showToast('Connection failed: ' + msg, 'error');
|
|
2261
|
+
});
|
|
2262
|
+
};
|
|
2263
|
+
|
|
1958
2264
|
App.prototype._toggleEditMode = function () {
|
|
1959
2265
|
this.setState(function (s) { return { editMode: !s.editMode }; });
|
|
1960
2266
|
};
|
|
@@ -2010,6 +2316,23 @@
|
|
|
2010
2316
|
title: 'Development environment',
|
|
2011
2317
|
'aria-label': 'Development environment',
|
|
2012
2318
|
}, 'DEV'),
|
|
2319
|
+
(function () {
|
|
2320
|
+
var cs = s.connStatus;
|
|
2321
|
+
var cfg = !!(s.orchUrl && s.apiKey);
|
|
2322
|
+
var cls = 'ican-live-badge';
|
|
2323
|
+
var label = 'MOCK';
|
|
2324
|
+
if (cs === 'testing') { cls += ' live-testing'; label = '…'; }
|
|
2325
|
+
else if (cs === 'connected') { cls += ' live'; label = 'LIVE'; }
|
|
2326
|
+
else if (cs === 'error') { cls += ' live-error'; label = 'ERROR'; }
|
|
2327
|
+
else if (cfg) { cls += ' live'; label = 'LIVE'; }
|
|
2328
|
+
return h('span', {
|
|
2329
|
+
className: cls,
|
|
2330
|
+
title: cfg
|
|
2331
|
+
? 'Connected to Mythos: ' + s.orchUrl
|
|
2332
|
+
: 'Mock mode — set Mythos URL in Developer Tools',
|
|
2333
|
+
'aria-label': cfg ? 'Live Mythos connection' : 'Mock mode',
|
|
2334
|
+
}, label);
|
|
2335
|
+
})(),
|
|
2013
2336
|
h('div', { className: 'ican-header-spacer' }),
|
|
2014
2337
|
|
|
2015
2338
|
/* Theme switcher */
|
|
@@ -2038,9 +2361,12 @@
|
|
|
2038
2361
|
s.showConn
|
|
2039
2362
|
? h('div', { style: { position: 'fixed', top: 56, left: 0, right: 0, zIndex: 98 } },
|
|
2040
2363
|
h(ConnectionPanel, {
|
|
2041
|
-
orchUrl:
|
|
2042
|
-
apiKey:
|
|
2043
|
-
onSave:
|
|
2364
|
+
orchUrl: s.orchUrl,
|
|
2365
|
+
apiKey: s.apiKey,
|
|
2366
|
+
onSave: self._saveConnection,
|
|
2367
|
+
onTest: self._testConnection,
|
|
2368
|
+
connStatus: s.connStatus,
|
|
2369
|
+
connError: s.connError,
|
|
2044
2370
|
})
|
|
2045
2371
|
)
|
|
2046
2372
|
: null,
|
package/templates/ui.html
CHANGED
|
@@ -1142,6 +1142,21 @@
|
|
|
1142
1142
|
{ model_id: 'dev-model-2', model_name: 'Buildings', action_id: 'dev-action-2', action_name: 'List', description: 'Dev stub' },
|
|
1143
1143
|
]);
|
|
1144
1144
|
},
|
|
1145
|
+
fireMythosEvent: function (eventType, payload) {
|
|
1146
|
+
console.log('[ICan Dev] fireMythosEvent:', eventType, payload);
|
|
1147
|
+
return Promise.resolve();
|
|
1148
|
+
},
|
|
1149
|
+
fromMythosCollection: function (model, collection) {
|
|
1150
|
+
return function (max, lastPageToken, args) {
|
|
1151
|
+
console.log('[ICan Dev] fromMythosCollection:', model, collection, { max: max, lastPageToken: lastPageToken, args: args });
|
|
1152
|
+
return Promise.resolve({
|
|
1153
|
+
items: Array.from({ length: Math.min(max, 5) }, function (_, i) {
|
|
1154
|
+
return { id: 'dev-' + i, name: model + ' row ' + i, value: i * 10 };
|
|
1155
|
+
}),
|
|
1156
|
+
pageToken: '',
|
|
1157
|
+
});
|
|
1158
|
+
};
|
|
1159
|
+
},
|
|
1145
1160
|
fireEvent: function (id) {
|
|
1146
1161
|
console.log('[ICan Dev] fireEvent:', id);
|
|
1147
1162
|
return Promise.resolve();
|