create-ekka-desktop-app 0.4.9 → 0.4.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ekka-desktop-app",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Create an EKKA desktop app with built-in demo backend. No setup required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@ import { PathPermissionsPage } from './pages/PathPermissionsPage';
21
21
  import { VaultPage } from './pages/VaultPage';
22
22
  import { RunnerPage } from './pages/RunnerPage';
23
23
  import { ExecutionPlansPage } from './pages/ExecutionPlansPage';
24
+ import { ExecutionRunDetailPage } from './pages/ExecutionRunDetailPage';
24
25
  import { LoginPage } from './pages/LoginPage';
25
26
  import { HomeSetupPage } from './pages/HomeSetupPage';
26
27
  import { SetupWizard } from './components/SetupWizard';
@@ -37,6 +38,7 @@ interface DemoState {
37
38
 
38
39
  export function DemoApp(): ReactElement {
39
40
  const [selectedPage, setSelectedPage] = useState<Page>('path-permissions');
41
+ const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
40
42
  const [darkMode, setDarkMode] = useState<boolean>(() => {
41
43
  if (typeof window !== 'undefined') {
42
44
  return window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -252,7 +254,11 @@ export function DemoApp(): ReactElement {
252
254
  {selectedPage === 'path-permissions' && <PathPermissionsPage darkMode={darkMode} />}
253
255
  {selectedPage === 'vault' && <VaultPage darkMode={darkMode} />}
254
256
  {selectedPage === 'runner' && <RunnerPage darkMode={darkMode} />}
255
- {selectedPage === 'execution-plans' && <ExecutionPlansPage darkMode={darkMode} />}
257
+ {selectedPage === 'execution-plans' && (
258
+ selectedRunId
259
+ ? <ExecutionRunDetailPage runId={selectedRunId} onBack={() => setSelectedRunId(null)} darkMode={darkMode} />
260
+ : <ExecutionPlansPage darkMode={darkMode} onViewRun={(id) => setSelectedRunId(id)} />
261
+ )}
256
262
  {selectedPage === 'audit-log' && <AuditLogPage darkMode={darkMode} />}
257
263
  {selectedPage === 'system' && <SystemPage darkMode={darkMode} />}
258
264
  </Shell>
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * Execution Plans Page
3
3
  *
4
- * Generic execution plan runner demo:
5
- * - Select a plan from dropdown
6
- * - Edit input JSON
7
- * - Execute (start a run)
8
- * - View runs grid + run details/events
4
+ * Plan selection, input editing, execution, and runs grid.
5
+ * Clicking "View" on a run navigates to ExecutionRunDetailPage (via onViewRun).
9
6
  */
10
7
 
11
8
  import { useState, useEffect, useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
@@ -13,7 +10,7 @@ import { _internal, makeRequest } from '../../ekka/internal';
13
10
  import { InfoTooltip } from '../components';
14
11
 
15
12
  // =============================================================================
16
- // API HELPERS (use internal request wrapper, no direct fetch)
13
+ // API TYPES
17
14
  // =============================================================================
18
15
 
19
16
  interface Plan {
@@ -29,24 +26,25 @@ interface Plan {
29
26
  interface Run {
30
27
  id: string;
31
28
  plan_id: string;
29
+ plan_code?: string;
32
30
  status: string;
31
+ progress?: number;
32
+ correlation_id?: string;
33
33
  inputs?: Record<string, unknown>;
34
+ context?: Record<string, unknown>;
34
35
  outputs?: Record<string, unknown>;
36
+ result?: Record<string, unknown>;
35
37
  error?: string;
38
+ duration_ms?: number;
36
39
  created_at: string;
37
40
  updated_at?: string;
38
41
  started_at?: string;
39
42
  completed_at?: string;
40
43
  }
41
44
 
42
- interface RunEvent {
43
- id: string;
44
- run_id: string;
45
- event_type: string;
46
- step_key?: string;
47
- payload?: unknown;
48
- created_at: string;
49
- }
45
+ // =============================================================================
46
+ // API HELPERS (use internal request wrapper, no direct fetch)
47
+ // =============================================================================
50
48
 
51
49
  async function request<T>(op: string, payload: unknown = {}): Promise<T> {
52
50
  const req = makeRequest(op, payload);
@@ -63,25 +61,23 @@ async function listPlans(): Promise<Plan[]> {
63
61
  }
64
62
 
65
63
  async function getPlan(id: string): Promise<Plan> {
66
- return request<Plan>('execution.plans.get', { id });
67
- }
68
-
69
- async function listRuns(planId: string): Promise<Run[]> {
70
- const result = await request<{ data?: Run[]; runs?: Run[] }>('execution.plans.runs.list', { planId, limit: 25 });
71
- return result.data || result.runs || (Array.isArray(result) ? result as unknown as Run[] : []);
64
+ const result = await request<{ plan?: Plan }>('execution.plans.get', { id });
65
+ return result.plan || result as unknown as Plan;
72
66
  }
73
67
 
74
- async function startRun(plan_id: string, inputs: Record<string, unknown>): Promise<Run> {
75
- return request<Run>('execution.runs.start', { plan_id, inputs });
68
+ interface RunsPage {
69
+ runs: Run[];
70
+ total: number;
76
71
  }
77
72
 
78
- async function getRun(runId: string): Promise<Run> {
79
- return request<Run>('execution.runs.get', { runId });
73
+ async function listRuns(planId: string, limit: number, offset: number): Promise<RunsPage> {
74
+ const result = await request<{ data?: Run[]; runs?: Run[]; total?: number }>('execution.plans.runs.list', { planId, limit, offset });
75
+ const runs = result.data || result.runs || (Array.isArray(result) ? result as unknown as Run[] : []);
76
+ return { runs, total: result.total ?? runs.length };
80
77
  }
81
78
 
82
- async function getRunEvents(runId: string): Promise<RunEvent[]> {
83
- const result = await request<{ data?: RunEvent[]; events?: RunEvent[] }>('execution.runs.events', { runId });
84
- return result.data || result.events || (Array.isArray(result) ? result as unknown as RunEvent[] : []);
79
+ async function startRun(plan_id: string, inputs: Record<string, unknown>): Promise<Run> {
80
+ return request<Run>('execution.runs.start', { plan_id, inputs });
85
81
  }
86
82
 
87
83
  // =============================================================================
@@ -90,17 +86,19 @@ async function getRunEvents(runId: string): Promise<RunEvent[]> {
90
86
 
91
87
  interface ExecutionPlansPageProps {
92
88
  darkMode: boolean;
89
+ onViewRun?: (runId: string) => void;
93
90
  }
94
91
 
95
- export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): ReactElement {
92
+ export function ExecutionPlansPage({ darkMode, onViewRun }: ExecutionPlansPageProps): ReactElement {
96
93
  const [plans, setPlans] = useState<Plan[]>([]);
97
94
  const [selectedPlanId, setSelectedPlanId] = useState<string>('');
98
95
  const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
99
96
  const [inputJson, setInputJson] = useState<string>('{}');
100
97
  const [jsonError, setJsonError] = useState<string | null>(null);
101
98
  const [runs, setRuns] = useState<Run[]>([]);
102
- const [selectedRun, setSelectedRun] = useState<Run | null>(null);
103
- const [runEvents, setRunEvents] = useState<RunEvent[]>([]);
99
+ const [runsTotal, setRunsTotal] = useState(0);
100
+ const [runsOffset, setRunsOffset] = useState(0);
101
+ const runsLimit = 10;
104
102
  const [loading, setLoading] = useState(false);
105
103
  const [executing, setExecuting] = useState(false);
106
104
  const [error, setError] = useState<string | null>(null);
@@ -133,7 +131,6 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
133
131
  button: { padding: '8px 20px', fontSize: '13px', fontWeight: 500, borderRadius: '6px', border: 'none', cursor: 'pointer', transition: 'opacity 0.15s' },
134
132
  buttonPrimary: { background: colors.blue, color: '#ffffff' },
135
133
  buttonDisabled: { opacity: 0.5, cursor: 'not-allowed' },
136
- grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' },
137
134
  table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '12px' },
138
135
  th: { textAlign: 'left' as const, padding: '8px 10px', borderBottom: `1px solid ${colors.border}`, fontWeight: 600, color: colors.textMuted, fontSize: '11px', textTransform: 'uppercase' as const, letterSpacing: '0.04em' },
139
136
  td: { padding: '8px 10px', borderBottom: `1px solid ${colors.border}`, verticalAlign: 'top' as const },
@@ -141,9 +138,8 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
141
138
  badge: { display: 'inline-block', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500 },
142
139
  sectionTitle: { fontSize: '14px', fontWeight: 600, marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' },
143
140
  meta: { fontSize: '12px', color: colors.textMuted, marginTop: '8px' },
144
- pre: { background: darkMode ? '#1c1c1e' : '#f5f5f7', border: `1px solid ${colors.border}`, borderRadius: '6px', padding: '12px', fontSize: '11px', fontFamily: 'SF Mono, Menlo, monospace', overflow: 'auto', maxHeight: '300px', whiteSpace: 'pre-wrap' as const, wordBreak: 'break-all' as const },
141
+ pre: { background: darkMode ? '#1c1c1e' : '#f5f5f7', border: `1px solid ${colors.border}`, borderRadius: '6px', padding: '12px', fontSize: '11px', fontFamily: 'SF Mono, Menlo, monospace', overflow: 'auto', maxHeight: '300px', whiteSpace: 'pre-wrap' as const, wordBreak: 'break-all' as const, margin: 0 },
145
142
  link: { color: colors.blue, cursor: 'pointer', background: 'none', border: 'none', fontSize: '12px', fontFamily: 'SF Mono, Menlo, monospace', padding: 0 },
146
- row: { display: 'flex', gap: '12px', alignItems: 'flex-end', marginBottom: '12px' },
147
143
  };
148
144
 
149
145
  // Load plans on mount
@@ -154,7 +150,6 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
154
150
  listPlans()
155
151
  .then((p) => {
156
152
  setPlans(p);
157
- // Restore last selected plan from localStorage
158
153
  const lastPlan = localStorage.getItem('ekka_exec_plan_id');
159
154
  if (lastPlan && p.some((x) => x.id === lastPlan)) {
160
155
  setSelectedPlanId(lastPlan);
@@ -173,26 +168,28 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
173
168
  }
174
169
  localStorage.setItem('ekka_exec_plan_id', selectedPlanId);
175
170
  setSelectedPlan(null);
176
- setSelectedRun(null);
177
- setRunEvents([]);
171
+ setRunsOffset(0);
178
172
 
179
173
  getPlan(selectedPlanId)
180
174
  .then((p) => {
181
175
  setSelectedPlan(p);
182
- // Prefill input JSON from schema
183
176
  const defaults = buildDefaultInputs(p);
184
177
  const lastInputs = localStorage.getItem(`ekka_exec_inputs_${selectedPlanId}`);
185
178
  setInputJson(lastInputs || JSON.stringify(defaults, null, 2));
186
179
  })
187
180
  .catch((e) => setError(e.message));
188
181
 
189
- loadRuns(selectedPlanId);
182
+ loadRuns(selectedPlanId, 0);
190
183
  }, [selectedPlanId]);
191
184
 
192
- const loadRuns = useCallback((planId: string) => {
193
- listRuns(planId)
194
- .then(setRuns)
195
- .catch(() => setRuns([]));
185
+ const loadRuns = useCallback((planId: string, offset: number) => {
186
+ listRuns(planId, runsLimit, offset)
187
+ .then((page) => {
188
+ setRuns(page.runs);
189
+ setRunsTotal(page.total);
190
+ setRunsOffset(offset);
191
+ })
192
+ .catch(() => { setRuns([]); setRunsTotal(0); });
196
193
  }, []);
197
194
 
198
195
  // Validate JSON on change
@@ -205,7 +202,6 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
205
202
  }
206
203
  }, [inputJson]);
207
204
 
208
- // Execute
209
205
  async function handleExecute() {
210
206
  if (!selectedPlanId || jsonError) return;
211
207
  setExecuting(true);
@@ -214,7 +210,7 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
214
210
  const inputs = JSON.parse(inputJson);
215
211
  localStorage.setItem(`ekka_exec_inputs_${selectedPlanId}`, inputJson);
216
212
  await startRun(selectedPlanId, inputs);
217
- loadRuns(selectedPlanId);
213
+ loadRuns(selectedPlanId, 0);
218
214
  } catch (e) {
219
215
  setError((e as Error).message);
220
216
  } finally {
@@ -222,14 +218,9 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
222
218
  }
223
219
  }
224
220
 
225
- // View run details
226
- async function handleViewRun(runId: string) {
227
- try {
228
- const [run, events] = await Promise.all([getRun(runId), getRunEvents(runId)]);
229
- setSelectedRun(run);
230
- setRunEvents(events);
231
- } catch (e) {
232
- setError((e as Error).message);
221
+ function handleViewRun(runId: string) {
222
+ if (onViewRun) {
223
+ onViewRun(runId);
233
224
  }
234
225
  }
235
226
 
@@ -242,10 +233,11 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
242
233
  }
243
234
 
244
235
  function shortId(id: string): string {
245
- return id.length > 12 ? id.slice(0, 8) + '...' : id;
236
+ return id && id.length > 12 ? id.slice(0, 8) + '...' : (id || '—');
246
237
  }
247
238
 
248
239
  function timeAgo(ts: string): string {
240
+ if (!ts) return '—';
249
241
  const diff = Date.now() - new Date(ts).getTime();
250
242
  const secs = Math.floor(diff / 1000);
251
243
  if (secs < 60) return `${secs}s ago`;
@@ -255,6 +247,13 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
255
247
  return `${hrs}h ago`;
256
248
  }
257
249
 
250
+ function formatDuration(ms?: number): string {
251
+ if (ms == null) return '—';
252
+ if (ms < 1000) return `${ms}ms`;
253
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
254
+ return `${(ms / 60000).toFixed(1)}min`;
255
+ }
256
+
258
257
  return (
259
258
  <div style={styles.container}>
260
259
  <header style={styles.header}>
@@ -310,7 +309,7 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
310
309
  </button>
311
310
  <button
312
311
  style={{ ...styles.button, border: `1px solid ${colors.border}`, background: 'transparent', color: colors.text }}
313
- onClick={() => loadRuns(selectedPlanId)}
312
+ onClick={() => loadRuns(selectedPlanId, runsOffset)}
314
313
  >
315
314
  Refresh Runs
316
315
  </button>
@@ -321,33 +320,61 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
321
320
  {/* Runs Grid */}
322
321
  {selectedPlanId && runs.length > 0 && (
323
322
  <div style={styles.card}>
324
- <div style={styles.sectionTitle}>Runs ({runs.length})</div>
323
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
324
+ <div style={styles.sectionTitle}>Runs</div>
325
+ <span style={{ fontSize: '11px', color: colors.textMuted }}>
326
+ {runsTotal > 0
327
+ ? `${runsOffset + 1}\u2013${runsOffset + runs.length} of ${runsTotal}`
328
+ : `Showing ${runs.length}`}
329
+ </span>
330
+ </div>
325
331
  <table style={styles.table}>
326
332
  <thead>
327
333
  <tr>
328
334
  <th style={styles.th}>Run ID</th>
329
335
  <th style={styles.th}>Status</th>
336
+ <th style={styles.th}>Duration</th>
330
337
  <th style={styles.th}>Created</th>
331
- <th style={styles.th}>Actions</th>
338
+ <th style={styles.th}></th>
332
339
  </tr>
333
340
  </thead>
334
341
  <tbody>
335
342
  {runs.map((run) => (
336
- <tr key={run.id}>
343
+ <tr key={run.id} style={{ cursor: 'pointer' }} onClick={() => handleViewRun(run.id)}>
337
344
  <td style={{ ...styles.td, ...styles.mono }}>{shortId(run.id)}</td>
338
345
  <td style={styles.td}>
339
346
  <span style={{ ...styles.badge, background: `${statusColor(run.status)}20`, color: statusColor(run.status) }}>
340
347
  {run.status}
341
348
  </span>
342
349
  </td>
350
+ <td style={{ ...styles.td, ...styles.mono, color: colors.textMuted }}>{formatDuration(run.duration_ms)}</td>
343
351
  <td style={{ ...styles.td, color: colors.textMuted }}>{timeAgo(run.created_at)}</td>
344
352
  <td style={styles.td}>
345
- <button style={styles.link} onClick={() => handleViewRun(run.id)}>View</button>
353
+ <button style={styles.link} onClick={(e) => { e.stopPropagation(); handleViewRun(run.id); }}>View</button>
346
354
  </td>
347
355
  </tr>
348
356
  ))}
349
357
  </tbody>
350
358
  </table>
359
+ {/* Pagination controls */}
360
+ {(runsOffset > 0 || runsOffset + runs.length < runsTotal) && (
361
+ <div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: '8px', marginTop: '12px', paddingTop: '8px', borderTop: `1px solid ${colors.border}` }}>
362
+ <button
363
+ style={{ ...styles.button, padding: '5px 14px', fontSize: '12px', border: `1px solid ${colors.border}`, background: 'transparent', color: colors.text, ...(runsOffset === 0 ? styles.buttonDisabled : {}) }}
364
+ disabled={runsOffset === 0}
365
+ onClick={() => loadRuns(selectedPlanId, Math.max(0, runsOffset - runsLimit))}
366
+ >
367
+ {'\u2190'} Prev
368
+ </button>
369
+ <button
370
+ style={{ ...styles.button, padding: '5px 14px', fontSize: '12px', border: `1px solid ${colors.border}`, background: 'transparent', color: colors.text, ...(runsOffset + runs.length >= runsTotal ? styles.buttonDisabled : {}) }}
371
+ disabled={runsOffset + runs.length >= runsTotal}
372
+ onClick={() => loadRuns(selectedPlanId, runsOffset + runsLimit)}
373
+ >
374
+ Next {'\u2192'}
375
+ </button>
376
+ </div>
377
+ )}
351
378
  </div>
352
379
  )}
353
380
 
@@ -357,80 +384,6 @@ export function ExecutionPlansPage({ darkMode }: ExecutionPlansPageProps): React
357
384
  </div>
358
385
  )}
359
386
 
360
- {/* Run Detail Panel */}
361
- {selectedRun && (
362
- <div style={styles.card}>
363
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
364
- <div style={styles.sectionTitle}>
365
- Run Detail
366
- <span style={{ ...styles.badge, background: `${statusColor(selectedRun.status)}20`, color: statusColor(selectedRun.status) }}>
367
- {selectedRun.status}
368
- </span>
369
- </div>
370
- <button style={styles.link} onClick={() => { setSelectedRun(null); setRunEvents([]); }}>Close</button>
371
- </div>
372
-
373
- <div style={{ ...styles.grid, marginBottom: '16px' }}>
374
- <div>
375
- <span style={styles.label}>Run ID</span>
376
- <div style={styles.mono}>{selectedRun.id}</div>
377
- </div>
378
- <div>
379
- <span style={styles.label}>Created</span>
380
- <div style={{ fontSize: '12px' }}>{new Date(selectedRun.created_at).toLocaleString()}</div>
381
- </div>
382
- </div>
383
-
384
- {selectedRun.error && (
385
- <div style={{ ...styles.error, marginBottom: '12px' }}>
386
- <strong>Error:</strong> {selectedRun.error}
387
- </div>
388
- )}
389
-
390
- {selectedRun.outputs && Object.keys(selectedRun.outputs).length > 0 && (
391
- <>
392
- <span style={styles.label}>Outputs</span>
393
- <pre style={styles.pre}>{JSON.stringify(selectedRun.outputs, null, 2)}</pre>
394
- </>
395
- )}
396
-
397
- {/* Events Timeline */}
398
- <div style={{ marginTop: '16px' }}>
399
- <div style={styles.sectionTitle}>
400
- Events ({runEvents.length})
401
- <button style={styles.link} onClick={() => handleViewRun(selectedRun.id)}>Refresh</button>
402
- </div>
403
- {runEvents.length === 0 ? (
404
- <div style={{ color: colors.textMuted, fontSize: '12px' }}>No events yet.</div>
405
- ) : (
406
- <table style={styles.table}>
407
- <thead>
408
- <tr>
409
- <th style={styles.th}>Time</th>
410
- <th style={styles.th}>Type</th>
411
- <th style={styles.th}>Step</th>
412
- <th style={styles.th}>Detail</th>
413
- </tr>
414
- </thead>
415
- <tbody>
416
- {runEvents.map((ev) => (
417
- <tr key={ev.id}>
418
- <td style={{ ...styles.td, ...styles.mono, whiteSpace: 'nowrap' }}>
419
- {new Date(ev.created_at).toLocaleTimeString()}
420
- </td>
421
- <td style={{ ...styles.td, ...styles.mono }}>{ev.event_type}</td>
422
- <td style={{ ...styles.td, ...styles.mono, color: colors.textMuted }}>{ev.step_key || '—'}</td>
423
- <td style={{ ...styles.td, fontSize: '11px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
424
- {ev.payload ? (typeof ev.payload === 'string' ? ev.payload : JSON.stringify(ev.payload).slice(0, 120)) : '—'}
425
- </td>
426
- </tr>
427
- ))}
428
- </tbody>
429
- </table>
430
- )}
431
- </div>
432
- </div>
433
- )}
434
387
  </div>
435
388
  );
436
389
  }