@svgrid/enterprise 2.2.0 → 2.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.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +13 -1
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
package/package.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"type": "commercial",
|
|
5
5
|
"url": "https://svgrid.com/pricing"
|
|
6
6
|
},
|
|
7
|
-
"version": "2.
|
|
8
|
-
"description": "
|
|
7
|
+
"version": "2.3.0",
|
|
8
|
+
"description": "Enterprise feature pack for SvGrid: Excel (xlsx), PDF, CSV, TSV and HTML export, Excel/CSV/JSON import, pivot tables with a drag-and-drop designer, paginated print, Kanban board and scheduler views. Requires a paid license key.",
|
|
9
9
|
"license": "SEE LICENSE IN LICENSE",
|
|
10
10
|
"author": "jQWidgets <sales@jqwidgets.com>",
|
|
11
11
|
"homepage": "https://svgrid.com/pricing",
|
|
@@ -26,11 +26,6 @@
|
|
|
26
26
|
"svelte": "./src/print.ts",
|
|
27
27
|
"import": "./src/print.ts"
|
|
28
28
|
},
|
|
29
|
-
"./ai": {
|
|
30
|
-
"types": "./src/ai.ts",
|
|
31
|
-
"svelte": "./src/ai.ts",
|
|
32
|
-
"import": "./src/ai.ts"
|
|
33
|
-
},
|
|
34
29
|
"./import": {
|
|
35
30
|
"types": "./src/import.ts",
|
|
36
31
|
"svelte": "./src/import.ts",
|
|
@@ -69,7 +64,7 @@
|
|
|
69
64
|
"svelte": "^5.0.0",
|
|
70
65
|
"jszip": "^3.10.0",
|
|
71
66
|
"pdfmake": "^0.2.0",
|
|
72
|
-
"@svgrid/grid": "^2.2.
|
|
67
|
+
"@svgrid/grid": "^2.2.27"
|
|
73
68
|
},
|
|
74
69
|
"peerDependenciesMeta": {
|
|
75
70
|
"jszip": {
|
|
@@ -86,13 +81,39 @@
|
|
|
86
81
|
"svelte": "^5.55.5",
|
|
87
82
|
"typescript": "6.0.3",
|
|
88
83
|
"vite": "^8.0.10",
|
|
89
|
-
"@svgrid/grid": "^2.2.
|
|
84
|
+
"@svgrid/grid": "^2.2.27"
|
|
85
|
+
},
|
|
86
|
+
"repository": {
|
|
87
|
+
"type": "git",
|
|
88
|
+
"url": "git+https://github.com/sv-grid/sv-grid.git",
|
|
89
|
+
"directory": "packages/enterprise"
|
|
90
90
|
},
|
|
91
|
+
"keywords": [
|
|
92
|
+
"svelte",
|
|
93
|
+
"svelte5",
|
|
94
|
+
"sveltekit",
|
|
95
|
+
"data grid",
|
|
96
|
+
"datagrid",
|
|
97
|
+
"data table",
|
|
98
|
+
"excel export",
|
|
99
|
+
"xlsx",
|
|
100
|
+
"pdf export",
|
|
101
|
+
"csv",
|
|
102
|
+
"pivot table",
|
|
103
|
+
"pivot",
|
|
104
|
+
"import",
|
|
105
|
+
"print",
|
|
106
|
+
"kanban",
|
|
107
|
+
"scheduler",
|
|
108
|
+
"svgrid",
|
|
109
|
+
"sv-grid"
|
|
110
|
+
],
|
|
91
111
|
"scripts": {
|
|
92
112
|
"test": "vitest run",
|
|
93
113
|
"test:types": "tsc -p tsconfig.json --noEmit",
|
|
94
114
|
"build": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs",
|
|
95
115
|
"build:cdn": "node ./scripts/build-cdn.mjs",
|
|
96
|
-
"build:node": "node ./scripts/build-node.mjs"
|
|
116
|
+
"build:node": "node ./scripts/build-node.mjs",
|
|
117
|
+
"extract:ui-props": "node scripts/extract-ui-props.mjs"
|
|
97
118
|
}
|
|
98
119
|
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvAlertRuleEditor - a modal that authors a single alert rule: name /
|
|
4
|
+
* severity / scope, the trigger (data change, relative change, validation,
|
|
5
|
+
* scheduled), the predicate (embedded SvExpressionEditor), and the actions
|
|
6
|
+
* (toast / badge / highlight / flash / prevent-edit / log). Reuses the grid
|
|
7
|
+
* UI kit so it matches the rest of the app.
|
|
8
|
+
*/
|
|
9
|
+
import { SvModal, SvField, SvTextInput, SvDropDownList, SvColorInput, SvTagsInput } from '@svgrid/grid'
|
|
10
|
+
import SvExpressionEditor from './SvExpressionEditor.svelte'
|
|
11
|
+
import { CRON_PRESETS } from './scheduling'
|
|
12
|
+
import type { ExprColumn } from './expressions/expression-columns'
|
|
13
|
+
import type { PredicateExpr } from './expressions/expression-types'
|
|
14
|
+
import type {
|
|
15
|
+
AlertAction,
|
|
16
|
+
AlertActionKind,
|
|
17
|
+
AlertRule,
|
|
18
|
+
AlertScope,
|
|
19
|
+
AlertSeverity,
|
|
20
|
+
AlertTrigger,
|
|
21
|
+
} from './alerts/alert-types'
|
|
22
|
+
|
|
23
|
+
type Props = {
|
|
24
|
+
open?: boolean
|
|
25
|
+
columns: ReadonlyArray<ExprColumn>
|
|
26
|
+
rule?: AlertRule | null
|
|
27
|
+
rows?: ReadonlyArray<Record<string, unknown>>
|
|
28
|
+
onSave?: (rule: AlertRule) => void
|
|
29
|
+
onClose?: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
open = $bindable(false),
|
|
34
|
+
columns,
|
|
35
|
+
rule = null,
|
|
36
|
+
rows = [],
|
|
37
|
+
onSave,
|
|
38
|
+
onClose,
|
|
39
|
+
}: Props = $props()
|
|
40
|
+
|
|
41
|
+
const ACTION_KINDS: { kind: AlertActionKind; label: string }[] = [
|
|
42
|
+
{ kind: 'toast', label: 'Toast notification' },
|
|
43
|
+
{ kind: 'highlight', label: 'Highlight row/cell' },
|
|
44
|
+
{ kind: 'badge', label: 'Colour badge' },
|
|
45
|
+
{ kind: 'cellFlash', label: 'Flash the cell' },
|
|
46
|
+
{ kind: 'preventEdit', label: 'Prevent the edit' },
|
|
47
|
+
{ kind: 'log', label: 'Log only' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const TRIGGERS = [
|
|
51
|
+
{ value: 'dataChange', label: 'When data matches' },
|
|
52
|
+
{ value: 'relativeChange', label: 'When a value moves' },
|
|
53
|
+
{ value: 'validation', label: 'On edit (validation)' },
|
|
54
|
+
{ value: 'scheduled', label: 'On a schedule' },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const CHANGE_KINDS = [
|
|
58
|
+
{ value: 'changed', label: 'changed at all' },
|
|
59
|
+
{ value: 'delta', label: 'moved by (absolute)' },
|
|
60
|
+
{ value: 'percentChange', label: 'moved by (percent)' },
|
|
61
|
+
{ value: 'crossed', label: 'crossed a threshold' },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
// --- Draft state ---------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
let name = $state('')
|
|
67
|
+
let severity = $state<AlertSeverity>('warning')
|
|
68
|
+
let scope = $state<AlertScope>('row')
|
|
69
|
+
let triggerType = $state<AlertTrigger['type']>('dataChange')
|
|
70
|
+
let predicate = $state<PredicateExpr>({ kind: 'const', value: true })
|
|
71
|
+
let selected = $state<Set<AlertActionKind>>(new Set(['toast']))
|
|
72
|
+
let message = $state('')
|
|
73
|
+
let background = $state('#fee2e2')
|
|
74
|
+
let color = $state('#991b1b')
|
|
75
|
+
let targetColumns = $state<string[]>([])
|
|
76
|
+
|
|
77
|
+
// relativeChange draft
|
|
78
|
+
let changeKind = $state<'changed' | 'delta' | 'percentChange' | 'crossed'>('percentChange')
|
|
79
|
+
let changeColumn = $state('')
|
|
80
|
+
let changeOp = $state<'>' | '<' | '>=' | '<='>('>')
|
|
81
|
+
let changeValue = $state('5')
|
|
82
|
+
let changeAbs = $state(true)
|
|
83
|
+
let changeDirection = $state<'above' | 'below'>('above')
|
|
84
|
+
|
|
85
|
+
// scheduled draft
|
|
86
|
+
let cron = $state(CRON_PRESETS[3].cron)
|
|
87
|
+
|
|
88
|
+
let createdAt = $state(0)
|
|
89
|
+
let ruleId = $state('')
|
|
90
|
+
|
|
91
|
+
// Re-seed the draft each time the modal opens.
|
|
92
|
+
let wasOpen = $state(false)
|
|
93
|
+
$effect(() => {
|
|
94
|
+
if (open && !wasOpen) seed()
|
|
95
|
+
wasOpen = open
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
function seed() {
|
|
99
|
+
name = rule?.name ?? ''
|
|
100
|
+
severity = rule?.severity ?? 'warning'
|
|
101
|
+
scope = rule?.scope ?? 'row'
|
|
102
|
+
triggerType = rule?.trigger.type ?? 'dataChange'
|
|
103
|
+
predicate = rule?.predicate ?? { kind: 'const', value: true }
|
|
104
|
+
selected = new Set(rule?.actions.map((a) => a.kind) ?? ['toast'])
|
|
105
|
+
message = rule?.actions.find((a) => a.message)?.message ?? ''
|
|
106
|
+
const styled = rule?.actions.find((a) => a.style)
|
|
107
|
+
background = styled?.style?.background ?? '#fee2e2'
|
|
108
|
+
color = styled?.style?.color ?? '#991b1b'
|
|
109
|
+
targetColumns = rule?.columns ?? []
|
|
110
|
+
createdAt = rule?.createdAt ?? Date.now()
|
|
111
|
+
ruleId = rule?.id ?? `alert-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
112
|
+
changeColumn = columns[0]?.id ?? ''
|
|
113
|
+
if (rule?.trigger.type === 'relativeChange') {
|
|
114
|
+
const e = rule.trigger.expr
|
|
115
|
+
changeKind = e.kind
|
|
116
|
+
changeColumn = e.column
|
|
117
|
+
if (e.kind === 'delta' || e.kind === 'percentChange') {
|
|
118
|
+
changeOp = e.op as '>' | '<' | '>=' | '<='
|
|
119
|
+
changeValue = String(e.value)
|
|
120
|
+
changeAbs = e.abs ?? false
|
|
121
|
+
} else if (e.kind === 'crossed') {
|
|
122
|
+
changeValue = String(e.threshold)
|
|
123
|
+
changeDirection = e.direction
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (rule?.trigger.type === 'scheduled') cron = rule.trigger.schedule.cron ?? CRON_PRESETS[3].cron
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toggle(kind: AlertActionKind, on: boolean) {
|
|
130
|
+
const next = new Set(selected)
|
|
131
|
+
if (on) next.add(kind)
|
|
132
|
+
else next.delete(kind)
|
|
133
|
+
selected = next
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const showStyle = $derived(selected.has('highlight') || selected.has('badge'))
|
|
137
|
+
const showTargets = $derived(showStyle || selected.has('cellFlash') || scope === 'cell')
|
|
138
|
+
|
|
139
|
+
function buildTrigger(): AlertTrigger {
|
|
140
|
+
if (triggerType === 'relativeChange') {
|
|
141
|
+
const column = changeColumn
|
|
142
|
+
const value = Number(changeValue)
|
|
143
|
+
if (changeKind === 'changed') return { type: 'relativeChange', expr: { kind: 'changed', column } }
|
|
144
|
+
if (changeKind === 'crossed')
|
|
145
|
+
return { type: 'relativeChange', expr: { kind: 'crossed', column, threshold: value, direction: changeDirection } }
|
|
146
|
+
return { type: 'relativeChange', expr: { kind: changeKind, column, op: changeOp, value, abs: changeAbs } }
|
|
147
|
+
}
|
|
148
|
+
if (triggerType === 'scheduled') return { type: 'scheduled', schedule: { id: ruleId, cron } }
|
|
149
|
+
if (triggerType === 'validation') return { type: 'validation' }
|
|
150
|
+
return { type: 'dataChange' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildActions(): AlertAction[] {
|
|
154
|
+
const cols = targetColumns.length ? targetColumns : undefined
|
|
155
|
+
return [...selected].map((kind) => {
|
|
156
|
+
const action: AlertAction = { kind }
|
|
157
|
+
if (message && (kind === 'toast' || kind === 'log' || kind === 'preventEdit')) action.message = message
|
|
158
|
+
if (kind === 'highlight' || kind === 'badge') {
|
|
159
|
+
action.style = { background, color }
|
|
160
|
+
if (cols) action.columns = cols
|
|
161
|
+
}
|
|
162
|
+
if (kind === 'cellFlash' && cols) action.columns = cols
|
|
163
|
+
return action
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function save() {
|
|
168
|
+
if (!name.trim()) { name = 'Untitled alert' }
|
|
169
|
+
const built: AlertRule = {
|
|
170
|
+
id: ruleId,
|
|
171
|
+
name: name.trim(),
|
|
172
|
+
enabled: rule?.enabled ?? true,
|
|
173
|
+
severity,
|
|
174
|
+
scope,
|
|
175
|
+
predicate,
|
|
176
|
+
trigger: buildTrigger(),
|
|
177
|
+
actions: buildActions(),
|
|
178
|
+
...(targetColumns.length ? { columns: targetColumns } : {}),
|
|
179
|
+
createdAt,
|
|
180
|
+
}
|
|
181
|
+
onSave?.(built)
|
|
182
|
+
open = false
|
|
183
|
+
onClose?.()
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<SvModal bind:open size="lg" title={rule ? 'Edit alert rule' : 'New alert rule'} onClose={() => onClose?.()}>
|
|
188
|
+
<div class="ar-body">
|
|
189
|
+
<div class="ar-grid">
|
|
190
|
+
<SvField label="Name">
|
|
191
|
+
<SvTextInput value={name} onChange={(v) => (name = v)} placeholder="e.g. Price spike" />
|
|
192
|
+
</SvField>
|
|
193
|
+
<SvField label="Severity">
|
|
194
|
+
<SvDropDownList
|
|
195
|
+
options={[{ value: 'info', label: 'Info' }, { value: 'success', label: 'Success' }, { value: 'warning', label: 'Warning' }, { value: 'error', label: 'Error' }]}
|
|
196
|
+
value={severity}
|
|
197
|
+
onChange={(v) => (severity = v as AlertSeverity)} />
|
|
198
|
+
</SvField>
|
|
199
|
+
<SvField label="Scope">
|
|
200
|
+
<SvDropDownList
|
|
201
|
+
options={[{ value: 'row', label: 'Row' }, { value: 'cell', label: 'Cell' }, { value: 'aggregate', label: 'Aggregate' }]}
|
|
202
|
+
value={scope}
|
|
203
|
+
onChange={(v) => (scope = v as AlertScope)} />
|
|
204
|
+
</SvField>
|
|
205
|
+
<SvField label="Trigger">
|
|
206
|
+
<SvDropDownList options={TRIGGERS} value={triggerType} onChange={(v) => (triggerType = v as AlertTrigger['type'])} />
|
|
207
|
+
</SvField>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{#if triggerType === 'relativeChange'}
|
|
211
|
+
<fieldset class="ar-fieldset">
|
|
212
|
+
<legend>Value movement</legend>
|
|
213
|
+
<div class="ar-change">
|
|
214
|
+
<SvDropDownList size="sm" options={columns.map((c) => ({ value: c.id, label: c.name ?? c.id }))} value={changeColumn} onChange={(v) => (changeColumn = String(v))} ariaLabel="Column" />
|
|
215
|
+
<SvDropDownList size="sm" options={CHANGE_KINDS} value={changeKind} onChange={(v) => (changeKind = v as typeof changeKind)} ariaLabel="Change kind" />
|
|
216
|
+
{#if changeKind === 'delta' || changeKind === 'percentChange'}
|
|
217
|
+
<SvDropDownList size="sm" options={[{ value: '>', label: '>' }, { value: '<', label: '<' }, { value: '>=', label: '>=' }, { value: '<=', label: '<=' }]} value={changeOp} onChange={(v) => (changeOp = v as typeof changeOp)} ariaLabel="Operator" />
|
|
218
|
+
<SvTextInput size="sm" value={changeValue} onChange={(v) => (changeValue = v)} placeholder={changeKind === 'percentChange' ? '%' : 'amount'} ariaLabel="Amount" />
|
|
219
|
+
<label class="ar-check"><input type="checkbox" checked={changeAbs} onchange={(e) => (changeAbs = e.currentTarget.checked)} /> absolute</label>
|
|
220
|
+
{:else if changeKind === 'crossed'}
|
|
221
|
+
<SvDropDownList size="sm" options={[{ value: 'above', label: 'upward through' }, { value: 'below', label: 'downward through' }]} value={changeDirection} onChange={(v) => (changeDirection = v as typeof changeDirection)} ariaLabel="Direction" />
|
|
222
|
+
<SvTextInput size="sm" value={changeValue} onChange={(v) => (changeValue = v)} placeholder="threshold" ariaLabel="Threshold" />
|
|
223
|
+
{/if}
|
|
224
|
+
</div>
|
|
225
|
+
</fieldset>
|
|
226
|
+
{:else if triggerType === 'scheduled'}
|
|
227
|
+
<SvField label="Schedule">
|
|
228
|
+
<SvDropDownList options={CRON_PRESETS.map((p) => ({ value: p.cron, label: p.label }))} value={cron} onChange={(v) => (cron = String(v))} />
|
|
229
|
+
</SvField>
|
|
230
|
+
{/if}
|
|
231
|
+
|
|
232
|
+
<SvField label={triggerType === 'validation' ? 'Reject the edit when' : 'Condition'}>
|
|
233
|
+
<SvExpressionEditor {columns} bind:value={predicate} {rows} />
|
|
234
|
+
</SvField>
|
|
235
|
+
|
|
236
|
+
<fieldset class="ar-fieldset">
|
|
237
|
+
<legend>When it fires</legend>
|
|
238
|
+
<div class="ar-actions">
|
|
239
|
+
{#each ACTION_KINDS as a (a.kind)}
|
|
240
|
+
<label class="ar-check">
|
|
241
|
+
<input type="checkbox" checked={selected.has(a.kind)} onchange={(e) => toggle(a.kind, e.currentTarget.checked)} />
|
|
242
|
+
{a.label}
|
|
243
|
+
</label>
|
|
244
|
+
{/each}
|
|
245
|
+
</div>
|
|
246
|
+
<div class="ar-message">
|
|
247
|
+
<SvField label="Message (tokens: {'{value}'}, {'{column}'}, {'{field}'})">
|
|
248
|
+
<SvTextInput value={message} onChange={(v) => (message = v)} placeholder="e.g. {'{'}region{'}'} price hit {'{'}value{'}'}" />
|
|
249
|
+
</SvField>
|
|
250
|
+
</div>
|
|
251
|
+
{#if showStyle}
|
|
252
|
+
<div class="ar-style">
|
|
253
|
+
<SvField label="Background"><SvColorInput value={background} onChange={(v) => (background = v)} /></SvField>
|
|
254
|
+
<SvField label="Text"><SvColorInput value={color} onChange={(v) => (color = v)} /></SvField>
|
|
255
|
+
</div>
|
|
256
|
+
{/if}
|
|
257
|
+
{#if showTargets}
|
|
258
|
+
<SvField label="Target columns (blank = whole row)">
|
|
259
|
+
<SvTagsInput value={targetColumns} onChange={(v) => (targetColumns = v)} placeholder="column id…" />
|
|
260
|
+
</SvField>
|
|
261
|
+
{/if}
|
|
262
|
+
</fieldset>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{#snippet footer()}
|
|
266
|
+
<button type="button" class="ar-btn ar-ghost" onclick={() => { open = false; onClose?.() }}>Cancel</button>
|
|
267
|
+
<button type="button" class="ar-btn ar-primary" onclick={save}>{rule ? 'Save changes' : 'Create alert'}</button>
|
|
268
|
+
{/snippet}
|
|
269
|
+
</SvModal>
|
|
270
|
+
|
|
271
|
+
<style>
|
|
272
|
+
/* Theme-agnostic surfaces: currentColor + inherit (readable in any theme). */
|
|
273
|
+
.ar-body { display: flex; flex-direction: column; gap: 14px; max-height: 64vh; overflow: auto; padding: 2px; font-family: var(--sg-font, inherit); color: inherit; }
|
|
274
|
+
.ar-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
275
|
+
.ar-fieldset { border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 10px; padding: 12px 14px 14px; margin: 0; background: color-mix(in srgb, currentColor 3%, transparent); }
|
|
276
|
+
.ar-fieldset legend { font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; opacity: 0.6; padding: 0 6px; }
|
|
277
|
+
.ar-change { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
|
278
|
+
.ar-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
|
|
279
|
+
.ar-check {
|
|
280
|
+
display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: inherit;
|
|
281
|
+
padding: 7px 10px; border: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-radius: 8px;
|
|
282
|
+
background: color-mix(in srgb, currentColor 3%, transparent);
|
|
283
|
+
cursor: pointer; transition: border-color 0.13s ease, background 0.13s ease;
|
|
284
|
+
}
|
|
285
|
+
.ar-check:hover { border-color: color-mix(in srgb, var(--sg-accent, #4f46e5) 45%, currentColor); background: color-mix(in srgb, var(--sg-accent, #4f46e5) 7%, transparent); }
|
|
286
|
+
.ar-check input { accent-color: var(--sg-accent, #4f46e5); width: 15px; height: 15px; }
|
|
287
|
+
.ar-message { margin-top: 10px; }
|
|
288
|
+
.ar-style { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
|
|
289
|
+
.ar-btn { border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: background 0.14s ease, border-color 0.14s ease, filter 0.14s ease; }
|
|
290
|
+
.ar-ghost { background: color-mix(in srgb, currentColor 5%, transparent); border-color: color-mix(in srgb, currentColor 22%, transparent); color: inherit; }
|
|
291
|
+
.ar-ghost:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
292
|
+
.ar-primary { background: var(--sg-accent, #4f46e5); color: #fff; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.12); }
|
|
293
|
+
.ar-primary:hover { filter: brightness(1.08); }
|
|
294
|
+
</style>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvAlertsManager - the rule list. Enable/disable, edit, delete, create, and
|
|
4
|
+
* share (export/import JSON) alert rules. Editing opens SvAlertRuleEditor.
|
|
5
|
+
* Operates through an AlertRulesManager (persistence) and emits the updated
|
|
6
|
+
* list so the running engine can re-sync.
|
|
7
|
+
*/
|
|
8
|
+
import { SvModal, SvSwitchButton, toast } from '@svgrid/grid'
|
|
9
|
+
import SvAlertRuleEditor from './SvAlertRuleEditor.svelte'
|
|
10
|
+
import { stringifyPredicate } from './expressions/parse'
|
|
11
|
+
import type { ExprColumn } from './expressions/expression-columns'
|
|
12
|
+
import type { AlertRulesManager } from './alerts/alert-storage'
|
|
13
|
+
import type { AlertRule, AlertSeverity } from './alerts/alert-types'
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
open?: boolean
|
|
17
|
+
manager: AlertRulesManager
|
|
18
|
+
columns: ReadonlyArray<ExprColumn>
|
|
19
|
+
rows?: ReadonlyArray<Record<string, unknown>>
|
|
20
|
+
onChange?: (rules: AlertRule[]) => void
|
|
21
|
+
onClose?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let { open = $bindable(false), manager, columns, rows = [], onChange, onClose }: Props = $props()
|
|
25
|
+
|
|
26
|
+
let rules = $state<AlertRule[]>(manager.list())
|
|
27
|
+
let editing = $state<AlertRule | null>(null)
|
|
28
|
+
let editorOpen = $state(false)
|
|
29
|
+
let fileInput = $state<HTMLInputElement | null>(null)
|
|
30
|
+
|
|
31
|
+
// Re-read the persisted list whenever the manager modal opens.
|
|
32
|
+
let wasOpen = $state(false)
|
|
33
|
+
$effect(() => {
|
|
34
|
+
if (open && !wasOpen) rules = manager.list()
|
|
35
|
+
wasOpen = open
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const SEV_COLOR: Record<AlertSeverity, string> = {
|
|
39
|
+
info: '#3b82f6', success: '#16a34a', warning: '#d97706', error: '#dc2626',
|
|
40
|
+
}
|
|
41
|
+
const SEV_LABEL: Record<AlertSeverity, string> = {
|
|
42
|
+
info: 'Info', success: 'Success', warning: 'Warning', error: 'Critical',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sync() {
|
|
46
|
+
rules = manager.list()
|
|
47
|
+
onChange?.(rules)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function newRule() {
|
|
51
|
+
editing = null
|
|
52
|
+
editorOpen = true
|
|
53
|
+
}
|
|
54
|
+
function editRule(rule: AlertRule) {
|
|
55
|
+
editing = rule
|
|
56
|
+
editorOpen = true
|
|
57
|
+
}
|
|
58
|
+
function onSaved(rule: AlertRule) {
|
|
59
|
+
manager.save(rule)
|
|
60
|
+
sync()
|
|
61
|
+
}
|
|
62
|
+
function toggle(rule: AlertRule) {
|
|
63
|
+
manager.toggle(rule.id)
|
|
64
|
+
sync()
|
|
65
|
+
}
|
|
66
|
+
function remove(rule: AlertRule) {
|
|
67
|
+
manager.remove(rule.id)
|
|
68
|
+
sync()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function exportRules() {
|
|
72
|
+
const json = manager.export()
|
|
73
|
+
const blob = new Blob([json], { type: 'application/json' })
|
|
74
|
+
const url = URL.createObjectURL(blob)
|
|
75
|
+
const a = document.createElement('a')
|
|
76
|
+
a.href = url
|
|
77
|
+
a.download = 'alert-rules.json'
|
|
78
|
+
a.click()
|
|
79
|
+
URL.revokeObjectURL(url)
|
|
80
|
+
toast.success('Exported alert rules')
|
|
81
|
+
}
|
|
82
|
+
function importRules(e: Event) {
|
|
83
|
+
const file = (e.currentTarget as HTMLInputElement).files?.[0]
|
|
84
|
+
if (!file) return
|
|
85
|
+
const reader = new FileReader()
|
|
86
|
+
reader.onload = () => {
|
|
87
|
+
try {
|
|
88
|
+
manager.import(String(reader.result))
|
|
89
|
+
sync()
|
|
90
|
+
toast.success('Imported alert rules')
|
|
91
|
+
} catch {
|
|
92
|
+
toast.error('Could not import - invalid file')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
reader.readAsText(file)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function triggerText(rule: AlertRule): string {
|
|
99
|
+
switch (rule.trigger.type) {
|
|
100
|
+
case 'relativeChange': return 'on movement'
|
|
101
|
+
case 'validation': return 'on edit'
|
|
102
|
+
case 'scheduled': return 'scheduled'
|
|
103
|
+
default: return 'on match'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<SvModal bind:open size="lg" title="Alert rules" onClose={() => onClose?.()}>
|
|
109
|
+
<div class="am-body">
|
|
110
|
+
{#if rules.length === 0}
|
|
111
|
+
<div class="am-empty">
|
|
112
|
+
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
113
|
+
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
|
114
|
+
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
|
115
|
+
</svg>
|
|
116
|
+
<p class="am-empty-title">No alert rules yet</p>
|
|
117
|
+
<p class="am-empty-sub">Create one to get a toast, a highlight, or a flash the moment your data crosses a line.</p>
|
|
118
|
+
<button type="button" class="am-btn am-primary" onclick={newRule}>+ New alert</button>
|
|
119
|
+
</div>
|
|
120
|
+
{:else}
|
|
121
|
+
<ul class="am-list">
|
|
122
|
+
{#each rules as rule (rule.id)}
|
|
123
|
+
<li class="am-item" class:am-off={!rule.enabled} style={`--sev: ${SEV_COLOR[rule.severity]}`}>
|
|
124
|
+
<span class="am-strip" aria-hidden="true"></span>
|
|
125
|
+
<SvSwitchButton checked={rule.enabled} onChange={() => toggle(rule)} ariaLabel={`Enable ${rule.name}`} />
|
|
126
|
+
<button type="button" class="am-main" onclick={() => editRule(rule)}>
|
|
127
|
+
<div class="am-title">
|
|
128
|
+
<span class="am-name">{rule.name}</span>
|
|
129
|
+
<span class="am-sev">{SEV_LABEL[rule.severity]}</span>
|
|
130
|
+
<span class="am-trigger">{triggerText(rule)}</span>
|
|
131
|
+
</div>
|
|
132
|
+
<code class="am-expr">{stringifyPredicate(rule.predicate, columns)}</code>
|
|
133
|
+
</button>
|
|
134
|
+
<div class="am-tools">
|
|
135
|
+
<button type="button" class="am-icon" onclick={() => editRule(rule)} aria-label={`Edit ${rule.name}`} title="Edit">
|
|
136
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
|
137
|
+
</button>
|
|
138
|
+
<button type="button" class="am-icon am-danger" onclick={() => remove(rule)} aria-label={`Delete ${rule.name}`} title="Delete">
|
|
139
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /></svg>
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</li>
|
|
143
|
+
{/each}
|
|
144
|
+
</ul>
|
|
145
|
+
{/if}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{#snippet footer()}
|
|
149
|
+
<div class="am-footer">
|
|
150
|
+
<div class="am-share">
|
|
151
|
+
<button type="button" class="am-btn am-ghost" onclick={exportRules}>Export</button>
|
|
152
|
+
<button type="button" class="am-btn am-ghost" onclick={() => fileInput?.click()}>Import</button>
|
|
153
|
+
<input bind:this={fileInput} type="file" accept="application/json" hidden onchange={importRules} />
|
|
154
|
+
</div>
|
|
155
|
+
<button type="button" class="am-btn am-primary" onclick={newRule}>+ New alert</button>
|
|
156
|
+
</div>
|
|
157
|
+
{/snippet}
|
|
158
|
+
</SvModal>
|
|
159
|
+
|
|
160
|
+
<SvAlertRuleEditor bind:open={editorOpen} {columns} rule={editing} {rows} onSave={onSaved} />
|
|
161
|
+
|
|
162
|
+
<style>
|
|
163
|
+
/* Theme-agnostic surfaces: currentColor + inherit (readable in any theme). */
|
|
164
|
+
.am-body { min-width: 540px; max-width: 640px; max-height: 60vh; overflow: auto; font-family: var(--sg-font, inherit); color: inherit; }
|
|
165
|
+
|
|
166
|
+
.am-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 44px 24px; text-align: center; }
|
|
167
|
+
.am-empty svg { color: var(--sg-accent, #4f46e5); opacity: 0.75; margin-bottom: 4px; }
|
|
168
|
+
.am-empty-title { margin: 0; font-size: 15px; font-weight: 600; }
|
|
169
|
+
.am-empty-sub { margin: 0 0 8px; font-size: 13px; max-width: 34ch; line-height: 1.5; opacity: 0.65; }
|
|
170
|
+
|
|
171
|
+
.am-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
172
|
+
.am-item {
|
|
173
|
+
position: relative; display: grid; grid-template-columns: auto auto 1fr auto; gap: 12px; align-items: center;
|
|
174
|
+
padding: 11px 12px 11px 16px;
|
|
175
|
+
background: color-mix(in srgb, currentColor 3%, transparent);
|
|
176
|
+
border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 11px; overflow: hidden;
|
|
177
|
+
transition: border-color 0.14s ease, box-shadow 0.14s ease;
|
|
178
|
+
}
|
|
179
|
+
.am-item:hover { border-color: color-mix(in srgb, var(--sev) 55%, transparent); box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--sev) 60%, transparent); }
|
|
180
|
+
.am-item.am-off { opacity: 0.55; }
|
|
181
|
+
.am-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--sev); }
|
|
182
|
+
|
|
183
|
+
.am-main { flex: 1; min-width: 0; text-align: left; border: 0; background: transparent; padding: 0; cursor: pointer; font: inherit; color: inherit; }
|
|
184
|
+
.am-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
185
|
+
.am-name { font-weight: 600; font-size: 14px; }
|
|
186
|
+
.am-sev {
|
|
187
|
+
font-size: 10.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
|
|
188
|
+
color: var(--sev); background: color-mix(in srgb, var(--sev) 16%, transparent);
|
|
189
|
+
padding: 2px 7px; border-radius: 999px;
|
|
190
|
+
}
|
|
191
|
+
.am-trigger { font-size: 11px; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
192
|
+
.am-expr { display: block; font-size: 12px; opacity: 0.7; margin-top: 4px; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
193
|
+
|
|
194
|
+
.am-tools { display: flex; gap: 4px; }
|
|
195
|
+
.am-icon {
|
|
196
|
+
display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px;
|
|
197
|
+
border: 1px solid transparent; background: transparent; border-radius: 8px; cursor: pointer;
|
|
198
|
+
color: inherit; opacity: 0.6; transition: background 0.13s ease, opacity 0.13s ease, border-color 0.13s ease, color 0.13s ease;
|
|
199
|
+
}
|
|
200
|
+
.am-icon:hover { background: color-mix(in srgb, currentColor 10%, transparent); opacity: 1; border-color: color-mix(in srgb, currentColor 15%, transparent); }
|
|
201
|
+
.am-icon.am-danger:hover { color: var(--sg-danger, #dc2626); opacity: 1; background: color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent); border-color: color-mix(in srgb, var(--sg-danger, #dc2626) 35%, transparent); }
|
|
202
|
+
|
|
203
|
+
.am-footer { display: flex; justify-content: space-between; align-items: center; width: 100%; }
|
|
204
|
+
.am-share { display: flex; gap: 6px; }
|
|
205
|
+
.am-btn { border-radius: 8px; padding: 7px 15px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: background 0.14s ease, border-color 0.14s ease, filter 0.14s ease; }
|
|
206
|
+
.am-ghost { background: color-mix(in srgb, currentColor 5%, transparent); border-color: color-mix(in srgb, currentColor 22%, transparent); color: inherit; }
|
|
207
|
+
.am-ghost:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
208
|
+
.am-primary { background: var(--sg-accent, #4f46e5); color: #fff; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.12); }
|
|
209
|
+
.am-primary:hover { filter: brightness(1.08); }
|
|
210
|
+
</style>
|