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.
@@ -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
- const ${componentName}: React.FC<IWidgetProps> = ({ icanContext, instanceId: _instanceId }) => {
175
- const [data, setData] = React.useState<unknown>(null);
176
- const [loading, setLoading] = React.useState(true);
177
- const [error, _setError] = React.useState<string | null>(null);
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
- React.useEffect(() => {
180
- if (!icanContext) {
181
- setLoading(false);
182
- return;
183
- }
184
- // Example: fetch data from Orch
185
- // icanContext.executeAction('MyModel', 'GetAll', {}, { json: true })
186
- // .then((result) => { setData(result); setLoading(false); })
187
- // .catch((err: unknown) => { _setError(String(err)); setLoading(false); });
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
- <p>{String(data)}</p>
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.2.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": "./dist/index.js"
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`
@@ -240,7 +240,7 @@ declare module "widget-designer/components" {
240
240
  params?: { [key: string]: any }
241
241
  }
242
242
 
243
- export interface ILucyAction {
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
- lucyAction?: ILucyAction
274
+ canAction?: ICanActionConfig
275
275
  }
276
276
  export interface IActionButtonProps {
277
277
  designer?: any
@@ -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. Mirrors the UXP useFields hook for migrating widgets.
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>;
@@ -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: localStorage.getItem(LS_ORCH_URL) || '',
1420
- apiKey: localStorage.getItem(LS_API_KEY) || '',
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
- if (isMythos) {
1428
- // Mock the Mythos response envelope: { run_id, status, output }.
1429
- return Promise.resolve({ run_id: 'dev-' + Date.now(), status: 'ok', output: {} });
1430
- }
1431
- return Promise.resolve([]);
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 Promise.resolve(null);
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
- { model_id: 'dev-model-1', model_name: 'Inventory', action_id: 'dev-action-1', action_name: 'UpdateStock', description: 'Dev stub' },
1443
- { model_id: 'dev-model-2', model_name: 'Buildings', action_id: 'dev-action-2', action_name: 'List', description: 'Dev stub' },
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
- fireEvent: function (id) {
1447
- console.log('[ICan Dev] fireEvent:', id);
1448
- return Promise.resolve();
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
- hasAppRole: function () { return true; },
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 orchUrl = props.orchUrl;
1822
- var apiKey = props.apiKey;
1823
- var onSave = props.onSave;
1824
- var ref = { orchUrl: orchUrl, apiKey: apiKey };
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' }, 'Orch URL'),
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
- defaultValue: orchUrl,
1834
- placeholder: 'https://your-orch.example.com',
1835
- 'aria-label': 'Orchestration URL',
1836
- onChange: function (e) { ref.orchUrl = e.target.value; },
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('input', {
1842
- id: 'conn-key',
1843
- className: 'ican-conn-input',
1844
- type: 'password',
1845
- defaultValue: apiKey,
1846
- placeholder: '••••••••••••••••',
1847
- 'aria-label': 'API Key',
1848
- onChange: function (e) { ref.apiKey = e.target.value; },
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(ref.orchUrl, ref.apiKey); },
2121
+ onClick: function () { onSave(localUrl, localKey); },
1857
2122
  }, 'Save'),
1858
2123
  h('div', { className: 'ican-conn-status' },
1859
- h('div', { className: 'ican-conn-dot' + (orchUrl ? ' connected' : '') }),
1860
- orchUrl ? 'Connected' : 'Not configured'
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
- localStorage.setItem(LS_ORCH_URL, orchUrl);
1953
- localStorage.setItem(LS_API_KEY, apiKey);
1954
- this.setState({ orchUrl: orchUrl, apiKey: apiKey });
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: s.orchUrl,
2042
- apiKey: s.apiKey,
2043
- onSave: self._saveConnection,
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();