infaira-canvas 0.3.0 → 0.5.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,85 @@ 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 directly:
170
+ //
171
+ // useService(icanContext, 'Assets', 'Asset:List', { limit: 20 })
172
+ //
173
+ // The result is the raw microservice response — no extra wrapper to unwrap.
174
+ // For orchestrated flows that call multiple services, use executeAction with
175
+ // a Mythos model instead (see the commented-out examples below).
176
+
177
+ interface IServiceState<T> {
178
+ data: T | null;
179
+ loading: boolean;
180
+ error: string | null;
181
+ }
182
+
183
+ function useService<T = unknown>(
184
+ ctx: IContextProvider | undefined,
185
+ app: string,
186
+ service: string,
187
+ params: Record<string, unknown> = {},
188
+ deps: unknown[] = [],
189
+ ): IServiceState<T> & { refetch: () => void } {
190
+ const [state, setState] = React.useState<IServiceState<T>>({ data: null, loading: true, error: null });
191
+ const [tick, setTick] = React.useState(0);
192
+ const mounted = React.useRef(true);
193
+ React.useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
194
+
195
+ React.useEffect(() => {
196
+ if (!ctx) { setState({ data: null, loading: false, error: null }); return; }
197
+ setState(prev => ({ ...prev, loading: true, error: null }));
198
+ ctx.executeService(app, service, params)
199
+ .then(result => { if (mounted.current) setState({ data: result as T, loading: false, error: null }); })
200
+ .catch((err: unknown) => { if (mounted.current) setState({ data: null, loading: false, error: String(err) }); });
201
+ // eslint-disable-next-line react-hooks/exhaustive-deps
202
+ }, [ctx, app, service, tick, ...deps]);
203
+
204
+ return { ...state, refetch: () => setTick(t => t + 1) };
205
+ }
206
+
207
+ // ─── Types ────────────────────────────────────────────────────────────────────
208
+ // Replace with real types from your microservice.
209
+ interface IItem {
210
+ id: number;
211
+ name: string;
212
+ [key: string]: unknown;
213
+ }
214
+
215
+ interface IListResponse {
216
+ total: number;
217
+ data: IItem[];
218
+ }
219
+
168
220
  export interface IWidgetProps {
169
221
  icanContext?: IContextProvider;
170
222
  instanceId?: string;
171
223
  [key: string]: unknown;
172
224
  }
173
225
 
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);
226
+ // ─── Widget ───────────────────────────────────────────────────────────────────
227
+ const ${componentName}: React.FC<IWidgetProps> = ({ icanContext }) => {
228
+ // ── Fetch data via executeService ────────────────────────────────────────
229
+ // Replace 'Assets' / 'Asset:List' with your app + service path.
230
+ // All InfAIra apps: Assets, Locations, UserManagement, FMS, PPM, IBMS
231
+ const { data, loading, error, refetch } = useService<IListResponse>(
232
+ icanContext,
233
+ 'Assets', // app key (matches manifest app_key)
234
+ 'Asset:List', // Model:Action
235
+ { limit: 20, skip: 0 },
236
+ );
178
237
 
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]);
238
+ // ── For Mythos orchestrated flows, use executeAction instead: ─────────────
239
+ // React.useEffect(() => {
240
+ // icanContext?.executeAction('mythos:AssetManager', 'ListAssets', { limit: 20 })
241
+ // .then(result => {
242
+ // // result = { run_id, status, output: { assets: { total, data } } }
243
+ // const list = (result as { output?: { assets?: IListResponse } })?.output?.assets;
244
+ // console.log(list);
245
+ // });
246
+ // }, [icanContext]);
191
247
 
192
248
  if (loading) {
193
249
  return (
@@ -201,13 +257,29 @@ const ${componentName}: React.FC<IWidgetProps> = ({ icanContext, instanceId: _in
201
257
  return (
202
258
  <div className="widget-error">
203
259
  <p className="widget-error-message">{error}</p>
260
+ <button className="widget-retry" onClick={refetch}>Retry</button>
204
261
  </div>
205
262
  );
206
263
  }
207
264
 
265
+ const items = data?.data ?? [];
266
+ const total = data?.total ?? 0;
267
+
208
268
  return (
209
269
  <div className="widget-root">
210
- <p>{String(data)}</p>
270
+ <div className="widget-header">
271
+ <span className="widget-title">${widgetName}</span>
272
+ <span className="widget-count">{total} items</span>
273
+ </div>
274
+ {items.length === 0 ? (
275
+ <p className="widget-empty">No data found.</p>
276
+ ) : (
277
+ <ul className="widget-list">
278
+ {items.map(item => (
279
+ <li key={item.id} className="widget-list-item">{item.name}</li>
280
+ ))}
281
+ </ul>
282
+ )}
211
283
  </div>
212
284
  );
213
285
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infaira-canvas",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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",
@@ -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",
@@ -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
@@ -725,6 +725,17 @@ declare module "ican/context" {
725
725
  params?: Record<string, unknown>,
726
726
  options?: { cancelPrevious?: boolean; key?: string }
727
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 }
738
+ ): Promise<unknown>;
728
739
  /**
729
740
  * List every Mythos action published and callable from this widget.
730
741
  * Optional — present only when the ICan backend has MYTHOS_BASE_URL set.
@@ -732,11 +743,23 @@ declare module "ican/context" {
732
743
  * once at mount and cache the result themselves.
733
744
  */
734
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;
735
759
  /**
736
760
  * Fire an external event into the Mythos engine. The event_type matches
737
761
  * what Mythos event-handlers subscribe to (configured in the model's
738
- * Events tab). Mirrors UXP's fireEvent, with an optional payload.
739
- * Optional — present only when MYTHOS_BASE_URL is configured.
762
+ * Events tab). Optional present only when MYTHOS_BASE_URL is configured.
740
763
  */
741
764
  fireMythosEvent?: (
742
765
  eventType: string,
@@ -744,7 +767,7 @@ declare module "ican/context" {
744
767
  ) => Promise<void>;
745
768
  /**
746
769
  * Build an IDataFunction over a Mythos collection — drop-in data source
747
- * for DataList / DataTable. Mirrors UXP's fromLucyDataCollection.
770
+ * for DataList / DataTable.
748
771
  * const fn = icanContext.fromMythosCollection('Inventory', 'items')
749
772
  * const { items, pageToken } = await fn(50, '', { search: 'foo' })
750
773
  * Optional — present only when MYTHOS_BASE_URL is configured.
@@ -790,7 +813,6 @@ interface Window {
790
813
  React: unknown;
791
814
  ReactDOM: unknown;
792
815
  ICanComponents: Record<string, unknown>;
793
- UXPComponents: Record<string, unknown>;
794
816
  WidgetDesignerComponents: Record<string, unknown>;
795
817
  /** Widget registry — qualifiedId → registration entry. */
796
818
  __ican_registry: Map<string, unknown>;
@@ -798,7 +820,6 @@ interface Window {
798
820
  registerWidget(registration: {
799
821
  id: string;
800
822
  component?: unknown;
801
- /** UXP-legacy alias for `component`. */
802
823
  widget?: unknown;
803
824
  configs?: unknown;
804
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,62 +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
  },
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).
1446
1627
  fireMythosEvent: function (eventType, payload) {
1447
1628
  console.log('[ICan Dev] fireMythosEvent:', eventType, payload);
1448
- return Promise.resolve();
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
  },
1638
+
1639
+ // fromMythosCollection → real collection read through the batch.
1450
1640
  fromMythosCollection: function (model, collection) {
1451
1641
  return function (max, lastPageToken, args) {
1452
1642
  console.log('[ICan Dev] fromMythosCollection:', model, collection, { max: max, lastPageToken: lastPageToken, args: args });
1453
- // Return one page of synthetic docs so DataList renders something.
1454
- return Promise.resolve({
1455
- items: Array.from({ length: Math.min(max, 5) }, function (_, i) {
1456
- return { id: 'dev-' + i, name: model + ' row ' + i, value: i * 10 };
1457
- }),
1458
- pageToken: '',
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) };
1459
1648
  });
1460
1649
  };
1461
1650
  },
1462
- fireEvent: function (id) {
1463
- console.log('[ICan Dev] fireEvent:', id);
1464
- return Promise.resolve();
1465
- },
1466
- hasAppRole: function () { return true; },
1651
+
1652
+ fireEvent: function (id) { console.log('[ICan Dev] fireEvent:', id); return Promise.resolve(); },
1653
+ hasAppRole: function () { return true; }, // dev harness: full access
1467
1654
  $L: function (code) { return code; },
1468
1655
  themeName: info.label,
1469
1656
  themeType: info.type,
@@ -1834,46 +2021,108 @@
1834
2021
 
1835
2022
  /* ─── ConnectionPanel ─────────────────────────────────── */
1836
2023
  function ConnectionPanel(props) {
1837
- var orchUrl = props.orchUrl;
1838
- var apiKey = props.apiKey;
1839
- var onSave = props.onSave;
1840
- 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';
1841
2054
 
1842
2055
  return h('div', { className: 'ican-conn-panel' },
1843
2056
  h('div', { className: 'ican-conn-field' },
1844
- h('label', { className: 'ican-conn-label', htmlFor: 'conn-orch' }, 'Orch URL'),
2057
+ h('label', { className: 'ican-conn-label', htmlFor: 'conn-orch' }, 'Mythos URL'),
1845
2058
  h('input', {
1846
2059
  id: 'conn-orch',
1847
2060
  className: 'ican-conn-input',
1848
2061
  type: 'text',
1849
- defaultValue: orchUrl,
1850
- placeholder: 'https://your-orch.example.com',
1851
- 'aria-label': 'Orchestration URL',
1852
- onChange: function (e) { ref.orchUrl = e.target.value; },
1853
- })
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
1854
2071
  ),
1855
2072
  h('div', { className: 'ican-conn-field' },
1856
2073
  h('label', { className: 'ican-conn-label', htmlFor: 'conn-key' }, 'API Key'),
1857
- h('input', {
1858
- id: 'conn-key',
1859
- className: 'ican-conn-input',
1860
- type: 'password',
1861
- defaultValue: apiKey,
1862
- placeholder: '••••••••••••••••',
1863
- 'aria-label': 'API Key',
1864
- onChange: function (e) { ref.apiKey = e.target.value; },
1865
- })
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
1866
2106
  ),
1867
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'),
1868
2116
  h('button', {
1869
2117
  className: 'ican-btn ican-btn-primary',
1870
2118
  type: 'button',
2119
+ disabled: !!urlValidationErr,
1871
2120
  style: { height: 30, fontSize: 12 },
1872
- onClick: function () { onSave(ref.orchUrl, ref.apiKey); },
2121
+ onClick: function () { onSave(localUrl, localKey); },
1873
2122
  }, 'Save'),
1874
2123
  h('div', { className: 'ican-conn-status' },
1875
- h('div', { className: 'ican-conn-dot' + (orchUrl ? ' connected' : '') }),
1876
- orchUrl ? 'Connected' : 'Not configured'
2124
+ h('div', { className: dotClass }),
2125
+ statusText
1877
2126
  )
1878
2127
  )
1879
2128
  );
@@ -1899,6 +2148,8 @@
1899
2148
  editMode: false,
1900
2149
  layouts: JSON.parse(localStorage.getItem('ican_layouts') || '{}'),
1901
2150
  gridWidth: window.innerWidth - 40,
2151
+ connStatus: 'idle', // idle | testing | connected | error
2152
+ connError: '',
1902
2153
  };
1903
2154
 
1904
2155
  this._onRegistryUpdate = this._onRegistryUpdate.bind(this);
@@ -1910,6 +2161,7 @@
1910
2161
  this._addWidget = this._addWidget.bind(this);
1911
2162
  this._removeWidget = this._removeWidget.bind(this);
1912
2163
  this._saveConnection = this._saveConnection.bind(this);
2164
+ this._testConnection = this._testConnection.bind(this);
1913
2165
  this._toggleEditMode = this._toggleEditMode.bind(this);
1914
2166
  this._onLayoutChange = this._onLayoutChange.bind(this);
1915
2167
  }
@@ -1965,12 +2217,50 @@
1965
2217
  };
1966
2218
 
1967
2219
  App.prototype._saveConnection = function (orchUrl, apiKey) {
1968
- localStorage.setItem(LS_ORCH_URL, orchUrl);
1969
- localStorage.setItem(LS_API_KEY, apiKey);
1970
- 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: '' });
1971
2229
  showToast('Connection settings saved', 'success');
1972
2230
  };
1973
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
+
1974
2264
  App.prototype._toggleEditMode = function () {
1975
2265
  this.setState(function (s) { return { editMode: !s.editMode }; });
1976
2266
  };
@@ -2026,6 +2316,23 @@
2026
2316
  title: 'Development environment',
2027
2317
  'aria-label': 'Development environment',
2028
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
+ })(),
2029
2336
  h('div', { className: 'ican-header-spacer' }),
2030
2337
 
2031
2338
  /* Theme switcher */
@@ -2054,9 +2361,12 @@
2054
2361
  s.showConn
2055
2362
  ? h('div', { style: { position: 'fixed', top: 56, left: 0, right: 0, zIndex: 98 } },
2056
2363
  h(ConnectionPanel, {
2057
- orchUrl: s.orchUrl,
2058
- apiKey: s.apiKey,
2059
- 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,
2060
2370
  })
2061
2371
  )
2062
2372
  : null,
@@ -1727,7 +1727,6 @@
1727
1727
  if (!window.registerUI) window.registerUI = function () {};
1728
1728
  if (!window.registerMenuItem) window.registerMenuItem = function () {};
1729
1729
  if (!window.registerLocalization) window.registerLocalization = function () {};
1730
- if (!window.UXPComponents) window.UXPComponents = {};
1731
1730
  if (!window.WidgetDesignerComponents) window.WidgetDesignerComponents = {};
1732
1731
 
1733
1732
  console.log('[ICan] Components loaded — InfAIra Canvas v2.0 (canonical, matches widget-bridge.ts)');