decision_agent 0.2.0 → 0.3.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -1
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/dmn/adapter.rb +135 -0
  5. data/lib/decision_agent/dmn/cache.rb +306 -0
  6. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  7. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  8. data/lib/decision_agent/dmn/errors.rb +30 -0
  9. data/lib/decision_agent/dmn/exporter.rb +217 -0
  10. data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
  11. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  12. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  13. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  14. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  15. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  16. data/lib/decision_agent/dmn/importer.rb +77 -0
  17. data/lib/decision_agent/dmn/model.rb +197 -0
  18. data/lib/decision_agent/dmn/parser.rb +191 -0
  19. data/lib/decision_agent/dmn/testing.rb +333 -0
  20. data/lib/decision_agent/dmn/validator.rb +315 -0
  21. data/lib/decision_agent/dmn/versioning.rb +229 -0
  22. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  23. data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
  24. data/lib/decision_agent/dsl/schema_validator.rb +2 -1
  25. data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
  26. data/lib/decision_agent/version.rb +1 -1
  27. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  28. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  29. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  30. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  31. data/lib/decision_agent/web/public/index.html +3 -0
  32. data/lib/decision_agent/web/public/styles.css +21 -0
  33. data/lib/decision_agent/web/server.rb +465 -0
  34. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  35. data/spec/auth/rbac_adapter_spec.rb +228 -0
  36. data/spec/dmn/decision_graph_spec.rb +282 -0
  37. data/spec/dmn/decision_tree_spec.rb +203 -0
  38. data/spec/dmn/feel/errors_spec.rb +18 -0
  39. data/spec/dmn/feel/functions_spec.rb +400 -0
  40. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  41. data/spec/dmn/feel/types_spec.rb +176 -0
  42. data/spec/dmn/feel_parser_spec.rb +489 -0
  43. data/spec/dmn/hit_policy_spec.rb +202 -0
  44. data/spec/dmn/integration_spec.rb +226 -0
  45. data/spec/examples.txt +1846 -1570
  46. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  47. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  48. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  49. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  50. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  51. data/spec/performance_optimizations_spec.rb +10 -3
  52. data/spec/thread_safety_spec.rb +10 -2
  53. data/spec/web_ui_rack_spec.rb +294 -0
  54. metadata +65 -1
@@ -0,0 +1,553 @@
1
+ // DMN Editor JavaScript
2
+
3
+ // State
4
+ const state = {
5
+ currentModel: null,
6
+ currentDecision: null,
7
+ models: [],
8
+ baseUrl: window.location.origin
9
+ };
10
+
11
+ // Initialize
12
+ document.addEventListener('DOMContentLoaded', () => {
13
+ initializeEventListeners();
14
+ loadModels();
15
+ });
16
+
17
+ // Event Listeners
18
+ function initializeEventListeners() {
19
+ // Header actions
20
+ document.getElementById('new-model-btn').addEventListener('click', () => {
21
+ openModal('new-model-modal');
22
+ });
23
+
24
+ document.getElementById('import-btn').addEventListener('click', () => {
25
+ openModal('import-modal');
26
+ });
27
+
28
+ document.getElementById('export-btn').addEventListener('click', exportModel);
29
+ document.getElementById('validate-btn').addEventListener('click', validateModel);
30
+
31
+ // Forms
32
+ document.getElementById('new-model-form').addEventListener('submit', createNewModel);
33
+ document.getElementById('add-decision-form').addEventListener('submit', addDecision);
34
+ document.getElementById('add-column-form').addEventListener('submit', addColumn);
35
+ document.getElementById('import-form').addEventListener('submit', importModel);
36
+
37
+ // Decision actions
38
+ document.getElementById('add-decision-btn').addEventListener('click', () => {
39
+ openModal('add-decision-modal');
40
+ });
41
+
42
+ document.getElementById('decision-select').addEventListener('change', (e) => {
43
+ loadDecision(e.target.value);
44
+ });
45
+
46
+ // Table controls
47
+ document.getElementById('add-input-btn').addEventListener('click', () => {
48
+ document.getElementById('column-type').value = 'input';
49
+ document.getElementById('column-modal-title').textContent = 'Add Input Column';
50
+ document.getElementById('expression-group').style.display = 'block';
51
+ openModal('add-column-modal');
52
+ });
53
+
54
+ document.getElementById('add-output-btn').addEventListener('click', () => {
55
+ document.getElementById('column-type').value = 'output';
56
+ document.getElementById('column-modal-title').textContent = 'Add Output Column';
57
+ document.getElementById('expression-group').style.display = 'none';
58
+ openModal('add-column-modal');
59
+ });
60
+
61
+ document.getElementById('add-rule-btn').addEventListener('click', addRule);
62
+ document.getElementById('hit-policy-select').addEventListener('change', updateHitPolicy);
63
+
64
+ // Tabs
65
+ document.querySelectorAll('.tab-btn').forEach(btn => {
66
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
67
+ });
68
+
69
+ // Visualization
70
+ document.getElementById('visualize-graph-btn')?.addEventListener('click', visualizeGraph);
71
+ document.getElementById('visualize-tree-btn')?.addEventListener('click', visualizeTree);
72
+
73
+ // XML actions
74
+ document.getElementById('copy-xml-btn')?.addEventListener('click', copyXml);
75
+ document.getElementById('download-xml-btn')?.addEventListener('click', downloadXml);
76
+ }
77
+
78
+ // API Functions
79
+ async function apiCall(endpoint, method = 'GET', data = null) {
80
+ const options = {
81
+ method,
82
+ headers: {
83
+ 'Content-Type': 'application/json'
84
+ }
85
+ };
86
+
87
+ if (data) {
88
+ options.body = JSON.stringify(data);
89
+ }
90
+
91
+ const response = await fetch(`${state.baseUrl}/api/dmn${endpoint}`, options);
92
+
93
+ if (!response.ok) {
94
+ const error = await response.json();
95
+ throw new Error(error.error || 'API request failed');
96
+ }
97
+
98
+ return response.json();
99
+ }
100
+
101
+ // Model Management
102
+ async function loadModels() {
103
+ try {
104
+ const models = await apiCall('/models');
105
+ state.models = models;
106
+ renderModelList();
107
+ } catch (error) {
108
+ showNotification('Failed to load models: ' + error.message, 'error');
109
+ }
110
+ }
111
+
112
+ function renderModelList() {
113
+ const list = document.getElementById('model-list');
114
+
115
+ if (state.models.length === 0) {
116
+ list.innerHTML = '<p class="empty-state">No models yet. Create one to get started.</p>';
117
+ return;
118
+ }
119
+
120
+ list.innerHTML = state.models.map(model => `
121
+ <div class="model-item ${model.id === state.currentModel?.id ? 'active' : ''}"
122
+ onclick="loadModel('${model.id}')">
123
+ <h4>${escapeHtml(model.name)}</h4>
124
+ <p>${model.decision_count} decision(s)</p>
125
+ </div>
126
+ `).join('');
127
+ }
128
+
129
+ async function loadModel(modelId) {
130
+ try {
131
+ const model = await apiCall(`/models/${modelId}`);
132
+ state.currentModel = model;
133
+ renderModelEditor();
134
+ renderModelList();
135
+
136
+ // Hide welcome screen, show editor
137
+ document.getElementById('welcome-screen').style.display = 'none';
138
+ document.getElementById('editor-container').style.display = 'block';
139
+
140
+ // Load first decision if available
141
+ if (model.decisions && model.decisions.length > 0) {
142
+ loadDecision(model.decisions[0].id);
143
+ }
144
+ } catch (error) {
145
+ showNotification('Failed to load model: ' + error.message, 'error');
146
+ }
147
+ }
148
+
149
+ async function createNewModel(e) {
150
+ e.preventDefault();
151
+
152
+ const name = document.getElementById('model-name-input').value;
153
+ const namespace = document.getElementById('model-namespace-input').value;
154
+
155
+ try {
156
+ const model = await apiCall('/models', 'POST', { name, namespace });
157
+ state.models.push(model);
158
+ await loadModel(model.id);
159
+ closeModal('new-model-modal');
160
+ document.getElementById('new-model-form').reset();
161
+ showNotification('Model created successfully', 'success');
162
+ } catch (error) {
163
+ showNotification('Failed to create model: ' + error.message, 'error');
164
+ }
165
+ }
166
+
167
+ function renderModelEditor() {
168
+ if (!state.currentModel) return;
169
+
170
+ document.getElementById('model-name').textContent = state.currentModel.name;
171
+ document.getElementById('model-id').textContent = `ID: ${state.currentModel.id}`;
172
+
173
+ // Populate decision selector
174
+ const select = document.getElementById('decision-select');
175
+ if (state.currentModel.decisions && state.currentModel.decisions.length > 0) {
176
+ select.innerHTML = state.currentModel.decisions.map(d =>
177
+ `<option value="${d.id}">${escapeHtml(d.name)}</option>`
178
+ ).join('');
179
+ } else {
180
+ select.innerHTML = '<option value="">No decisions yet</option>';
181
+ }
182
+ }
183
+
184
+ // Decision Management
185
+ async function addDecision(e) {
186
+ e.preventDefault();
187
+
188
+ const decisionId = document.getElementById('decision-id-input').value;
189
+ const name = document.getElementById('decision-name-input').value;
190
+ const type = document.getElementById('decision-type-select').value;
191
+
192
+ try {
193
+ const decision = await apiCall(`/models/${state.currentModel.id}/decisions`, 'POST', {
194
+ decision_id: decisionId,
195
+ name,
196
+ type
197
+ });
198
+
199
+ state.currentModel.decisions.push(decision);
200
+ renderModelEditor();
201
+ loadDecision(decision.id);
202
+ closeModal('add-decision-modal');
203
+ document.getElementById('add-decision-form').reset();
204
+ showNotification('Decision added successfully', 'success');
205
+ } catch (error) {
206
+ showNotification('Failed to add decision: ' + error.message, 'error');
207
+ }
208
+ }
209
+
210
+ async function loadDecision(decisionId) {
211
+ const decision = state.currentModel.decisions.find(d => d.id === decisionId);
212
+ if (!decision) return;
213
+
214
+ state.currentDecision = decision;
215
+ document.getElementById('decision-select').value = decisionId;
216
+
217
+ if (decision.decision_table) {
218
+ renderDecisionTable(decision.decision_table);
219
+ }
220
+ }
221
+
222
+ // Decision Table
223
+ function renderDecisionTable(table) {
224
+ const headers = document.getElementById('table-headers');
225
+ const types = document.getElementById('table-types');
226
+ const body = document.getElementById('table-body');
227
+
228
+ // Set hit policy
229
+ document.getElementById('hit-policy-select').value = table.hit_policy;
230
+
231
+ // Render headers
232
+ let headerHtml = '<th class="rule-number">#</th>';
233
+ let typeHtml = '<th></th>';
234
+
235
+ table.inputs.forEach(input => {
236
+ headerHtml += `<th class="input-col">${escapeHtml(input.label)} <span class="col-type">(Input)</span></th>`;
237
+ typeHtml += `<th class="type-annotation">${input.type_ref || 'string'}</th>`;
238
+ });
239
+
240
+ table.outputs.forEach(output => {
241
+ headerHtml += `<th class="output-col">${escapeHtml(output.label)} <span class="col-type">(Output)</span></th>`;
242
+ typeHtml += `<th class="type-annotation">${output.type_ref || 'string'}</th>`;
243
+ });
244
+
245
+ headerHtml += '<th class="actions-col">Actions</th>';
246
+ typeHtml += '<th></th>';
247
+
248
+ headers.innerHTML = headerHtml;
249
+ types.innerHTML = typeHtml;
250
+
251
+ // Render rules
252
+ if (table.rules.length === 0) {
253
+ body.innerHTML = `<tr class="empty-row"><td colspan="${table.inputs.length + table.outputs.length + 2}">
254
+ No rules defined. Click "Add Rule" to create one.</td></tr>`;
255
+ return;
256
+ }
257
+
258
+ body.innerHTML = table.rules.map((rule, index) => {
259
+ let rowHtml = `<tr data-rule-id="${rule.id}">`;
260
+ rowHtml += `<td class="rule-number">${index + 1}</td>`;
261
+
262
+ rule.input_entries.forEach((entry, i) => {
263
+ rowHtml += `<td><input type="text" value="${escapeHtml(entry)}"
264
+ onchange="updateRuleEntry('${rule.id}', 'input', ${i}, this.value)"></td>`;
265
+ });
266
+
267
+ rule.output_entries.forEach((entry, i) => {
268
+ rowHtml += `<td><input type="text" value="${escapeHtml(entry)}"
269
+ onchange="updateRuleEntry('${rule.id}', 'output', ${i}, this.value)"></td>`;
270
+ });
271
+
272
+ rowHtml += `<td class="actions-col">
273
+ <button class="btn btn-sm btn-danger" onclick="deleteRule('${rule.id}')">Delete</button>
274
+ </td>`;
275
+ rowHtml += '</tr>';
276
+
277
+ return rowHtml;
278
+ }).join('');
279
+ }
280
+
281
+ async function addColumn(e) {
282
+ e.preventDefault();
283
+
284
+ const columnType = document.getElementById('column-type').value;
285
+ const id = document.getElementById('column-id-input').value;
286
+ const label = document.getElementById('column-label-input').value;
287
+ const typeRef = document.getElementById('column-type-input').value;
288
+ const expression = document.getElementById('column-expression-input').value;
289
+
290
+ try {
291
+ const endpoint = `/models/${state.currentModel.id}/decisions/${state.currentDecision.id}/${columnType}s`;
292
+
293
+ const data = {
294
+ [`${columnType}_id`]: id,
295
+ label,
296
+ type_ref: typeRef
297
+ };
298
+
299
+ if (columnType === 'input' && expression) {
300
+ data.expression = expression;
301
+ } else if (columnType === 'output') {
302
+ data.name = id;
303
+ }
304
+
305
+ await apiCall(endpoint, 'POST', data);
306
+
307
+ // Reload model to get updated state
308
+ await loadModel(state.currentModel.id);
309
+ loadDecision(state.currentDecision.id);
310
+
311
+ closeModal('add-column-modal');
312
+ document.getElementById('add-column-form').reset();
313
+ showNotification(`${columnType === 'input' ? 'Input' : 'Output'} column added successfully`, 'success');
314
+ } catch (error) {
315
+ showNotification('Failed to add column: ' + error.message, 'error');
316
+ }
317
+ }
318
+
319
+ async function addRule() {
320
+ if (!state.currentDecision || !state.currentDecision.decision_table) return;
321
+
322
+ const table = state.currentDecision.decision_table;
323
+ const ruleId = `rule_${Date.now()}`;
324
+ const inputEntries = table.inputs.map(() => '-');
325
+ const outputEntries = table.outputs.map(() => '');
326
+
327
+ try {
328
+ await apiCall(`/models/${state.currentModel.id}/decisions/${state.currentDecision.id}/rules`, 'POST', {
329
+ rule_id: ruleId,
330
+ input_entries: inputEntries,
331
+ output_entries: outputEntries
332
+ });
333
+
334
+ // Reload model
335
+ await loadModel(state.currentModel.id);
336
+ loadDecision(state.currentDecision.id);
337
+ showNotification('Rule added successfully', 'success');
338
+ } catch (error) {
339
+ showNotification('Failed to add rule: ' + error.message, 'error');
340
+ }
341
+ }
342
+
343
+ async function updateRuleEntry(ruleId, type, index, value) {
344
+ try {
345
+ const rule = state.currentDecision.decision_table.rules.find(r => r.id === ruleId);
346
+ if (!rule) return;
347
+
348
+ if (type === 'input') {
349
+ rule.input_entries[index] = value;
350
+ } else {
351
+ rule.output_entries[index] = value;
352
+ }
353
+
354
+ await apiCall(`/models/${state.currentModel.id}/decisions/${state.currentDecision.id}/rules/${ruleId}`, 'PUT', {
355
+ input_entries: rule.input_entries,
356
+ output_entries: rule.output_entries
357
+ });
358
+ } catch (error) {
359
+ showNotification('Failed to update rule: ' + error.message, 'error');
360
+ }
361
+ }
362
+
363
+ async function deleteRule(ruleId) {
364
+ if (!confirm('Are you sure you want to delete this rule?')) return;
365
+
366
+ try {
367
+ await apiCall(`/models/${state.currentModel.id}/decisions/${state.currentDecision.id}/rules/${ruleId}`, 'DELETE');
368
+
369
+ // Reload model
370
+ await loadModel(state.currentModel.id);
371
+ loadDecision(state.currentDecision.id);
372
+ showNotification('Rule deleted successfully', 'success');
373
+ } catch (error) {
374
+ showNotification('Failed to delete rule: ' + error.message, 'error');
375
+ }
376
+ }
377
+
378
+ async function updateHitPolicy(e) {
379
+ if (!state.currentDecision || !state.currentDecision.decision_table) return;
380
+
381
+ try {
382
+ await apiCall(`/models/${state.currentModel.id}/decisions/${state.currentDecision.id}`, 'PUT', {
383
+ logic: { hit_policy: e.target.value }
384
+ });
385
+
386
+ showNotification('Hit policy updated successfully', 'success');
387
+ } catch (error) {
388
+ showNotification('Failed to update hit policy: ' + error.message, 'error');
389
+ }
390
+ }
391
+
392
+ // Import/Export
393
+ async function exportModel() {
394
+ if (!state.currentModel) {
395
+ showNotification('No model selected', 'warning');
396
+ return;
397
+ }
398
+
399
+ try {
400
+ const xml = await apiCall(`/models/${state.currentModel.id}/export`);
401
+
402
+ // Update XML tab
403
+ document.getElementById('xml-content').innerHTML = `<code>${escapeHtml(xml)}</code>`;
404
+
405
+ showNotification('Model exported successfully', 'success');
406
+ } catch (error) {
407
+ showNotification('Failed to export model: ' + error.message, 'error');
408
+ }
409
+ }
410
+
411
+ async function importModel(e) {
412
+ e.preventDefault();
413
+
414
+ const fileInput = document.getElementById('import-file-input');
415
+ const file = fileInput.files[0];
416
+
417
+ if (!file) {
418
+ showNotification('Please select a file', 'warning');
419
+ return;
420
+ }
421
+
422
+ try {
423
+ const xmlContent = await file.text();
424
+ const model = await apiCall('/models/import', 'POST', { xml: xmlContent });
425
+
426
+ state.models.push(model);
427
+ await loadModel(model.id);
428
+ closeModal('import-modal');
429
+ document.getElementById('import-form').reset();
430
+ showNotification('Model imported successfully', 'success');
431
+ } catch (error) {
432
+ showNotification('Failed to import model: ' + error.message, 'error');
433
+ }
434
+ }
435
+
436
+ async function validateModel() {
437
+ if (!state.currentModel) {
438
+ showNotification('No model selected', 'warning');
439
+ return;
440
+ }
441
+
442
+ try {
443
+ const result = await apiCall(`/models/${state.currentModel.id}/validate`);
444
+
445
+ if (result.valid) {
446
+ showNotification('Model is valid!', 'success');
447
+ } else {
448
+ showNotification(`Validation failed: ${result.errors.join(', ')}`, 'error');
449
+ }
450
+ } catch (error) {
451
+ showNotification('Failed to validate model: ' + error.message, 'error');
452
+ }
453
+ }
454
+
455
+ // Visualization
456
+ async function visualizeGraph() {
457
+ if (!state.currentModel) return;
458
+
459
+ const format = document.getElementById('graph-format-select').value;
460
+
461
+ try {
462
+ const visualization = await apiCall(`/models/${state.currentModel.id}/visualize/graph?format=${format}`);
463
+ const container = document.getElementById('graph-visualization');
464
+
465
+ if (format === 'svg') {
466
+ container.innerHTML = visualization;
467
+ } else {
468
+ container.innerHTML = `<pre><code>${escapeHtml(visualization)}</code></pre>`;
469
+ }
470
+ } catch (error) {
471
+ showNotification('Failed to generate visualization: ' + error.message, 'error');
472
+ }
473
+ }
474
+
475
+ async function visualizeTree() {
476
+ if (!state.currentDecision) return;
477
+
478
+ try {
479
+ const svg = await apiCall(`/models/${state.currentModel.id}/decisions/${state.currentDecision.id}/visualize/tree`);
480
+ document.getElementById('tree-visualization').innerHTML = svg;
481
+ } catch (error) {
482
+ showNotification('Failed to generate tree visualization: ' + error.message, 'error');
483
+ }
484
+ }
485
+
486
+ function copyXml() {
487
+ const xmlContent = document.getElementById('xml-content').textContent;
488
+ navigator.clipboard.writeText(xmlContent);
489
+ showNotification('XML copied to clipboard', 'success');
490
+ }
491
+
492
+ function downloadXml() {
493
+ const xmlContent = document.getElementById('xml-content').textContent;
494
+ const blob = new Blob([xmlContent], { type: 'text/xml' });
495
+ const url = URL.createObjectURL(blob);
496
+ const a = document.createElement('a');
497
+ a.href = url;
498
+ a.download = `${state.currentModel.name}.dmn`;
499
+ a.click();
500
+ URL.revokeObjectURL(url);
501
+ showNotification('DMN file downloaded', 'success');
502
+ }
503
+
504
+ // UI Helpers
505
+ function switchTab(tabName) {
506
+ // Update tab buttons
507
+ document.querySelectorAll('.tab-btn').forEach(btn => {
508
+ btn.classList.toggle('active', btn.dataset.tab === tabName);
509
+ });
510
+
511
+ // Update tab panes
512
+ document.querySelectorAll('.tab-pane').forEach(pane => {
513
+ pane.classList.toggle('active', pane.id === `${tabName}-tab`);
514
+ });
515
+
516
+ // Load content based on tab
517
+ if (tabName === 'xml') {
518
+ exportModel();
519
+ } else if (tabName === 'graph') {
520
+ visualizeGraph();
521
+ }
522
+ }
523
+
524
+ function openModal(modalId) {
525
+ document.getElementById(modalId).classList.add('active');
526
+ }
527
+
528
+ function closeModal(modalId) {
529
+ document.getElementById(modalId).classList.remove('active');
530
+ }
531
+
532
+ function showNotification(message, type = 'info') {
533
+ const notification = document.getElementById('notification');
534
+ notification.textContent = message;
535
+ notification.className = `notification ${type} show`;
536
+
537
+ setTimeout(() => {
538
+ notification.classList.remove('show');
539
+ }, 3000);
540
+ }
541
+
542
+ function escapeHtml(text) {
543
+ const div = document.createElement('div');
544
+ div.textContent = text;
545
+ return div.innerHTML;
546
+ }
547
+
548
+ // Close modals when clicking outside
549
+ window.addEventListener('click', (e) => {
550
+ if (e.target.classList.contains('modal')) {
551
+ e.target.classList.remove('active');
552
+ }
553
+ });
@@ -11,6 +11,9 @@
11
11
  <header>
12
12
  <h1>🎯 DecisionAgent Rule Builder</h1>
13
13
  <p class="subtitle">Visual rule editor for non-technical users</p>
14
+ <div class="header-links">
15
+ <a href="/dmn/editor" class="btn btn-link">📊 DMN Editor</a>
16
+ </div>
14
17
  </header>
15
18
 
16
19
  <div class="main-layout">
@@ -42,6 +42,7 @@ header {
42
42
  background: linear-gradient(135deg, var(--primary-color), #6366f1);
43
43
  color: white;
44
44
  border-radius: 10px;
45
+ position: relative;
45
46
  }
46
47
 
47
48
  header h1 {
@@ -49,6 +50,26 @@ header h1 {
49
50
  margin-bottom: 10px;
50
51
  }
51
52
 
53
+ header .header-links {
54
+ position: absolute;
55
+ top: 20px;
56
+ right: 20px;
57
+ }
58
+
59
+ header .header-links .btn-link {
60
+ color: white;
61
+ text-decoration: none;
62
+ padding: 8px 16px;
63
+ border: 1px solid rgba(255, 255, 255, 0.3);
64
+ border-radius: 4px;
65
+ background: rgba(255, 255, 255, 0.1);
66
+ transition: background 0.2s;
67
+ }
68
+
69
+ header .header-links .btn-link:hover {
70
+ background: rgba(255, 255, 255, 0.2);
71
+ }
72
+
52
73
  .subtitle {
53
74
  font-size: 1.1rem;
54
75
  opacity: 0.9;