@morscherlab/mint-sdk 1.0.0-beta.7 → 1.0.0-rc.2

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 (163) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
  3. package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
  4. package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
  5. package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
  7. package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
  8. package/dist/__tests__/stores/settings.test.d.ts +1 -0
  9. package/dist/__tests__/utils/instrument.test.d.ts +1 -0
  10. package/dist/__tests__/utils/lcms.test.d.ts +1 -0
  11. package/dist/__tests__/utils/permissions.test.d.ts +1 -0
  12. package/dist/__tests__/utils/rack.test.d.ts +1 -0
  13. package/dist/{auth-QQj2kkze.js → auth-B7g4J4ZF.js} +148 -24
  14. package/dist/auth-B7g4J4ZF.js.map +1 -0
  15. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  16. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  17. package/dist/components/BaseToggle.vue.d.ts +2 -2
  18. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  19. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
  20. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
  21. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
  22. package/dist/components/FormulaInput.vue.d.ts +1 -1
  23. package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
  24. package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
  25. package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
  26. package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
  27. package/dist/components/ProgressBar.vue.d.ts +1 -0
  28. package/dist/components/RackEditor.vue.d.ts +41 -3
  29. package/dist/components/ReagentList.vue.d.ts +1 -1
  30. package/dist/components/SampleSelector.vue.d.ts +5 -2
  31. package/dist/components/SegmentedControl.vue.d.ts +2 -0
  32. package/dist/components/SequenceInput.vue.d.ts +1 -1
  33. package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
  34. package/dist/components/SettingsModal.vue.d.ts +8 -1
  35. package/dist/components/TagsInput.vue.d.ts +1 -1
  36. package/dist/components/WellPlate.vue.d.ts +42 -3
  37. package/dist/components/index.d.ts +5 -0
  38. package/dist/components/index.js +3 -3
  39. package/dist/{components-DihbSJjU.js → components-BhK-dW99.js} +2135 -1075
  40. package/dist/components-BhK-dW99.js.map +1 -0
  41. package/dist/composables/experimentDesignData.d.ts +17 -0
  42. package/dist/composables/index.d.ts +2 -0
  43. package/dist/composables/index.js +4 -4
  44. package/dist/composables/useControlSchema.d.ts +11 -0
  45. package/dist/composables/useExperimentData.d.ts +11 -3
  46. package/dist/composables/useExperimentSamples.d.ts +42 -0
  47. package/dist/composables/usePlatformContext.d.ts +54 -0
  48. package/dist/{composables-BcgZ6diz.js → composables-Bg7CFuNz.js} +5 -3
  49. package/dist/composables-Bg7CFuNz.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +168 -6
  52. package/dist/index.js.map +1 -0
  53. package/dist/install.js +2 -2
  54. package/dist/instrument.d.ts +7 -0
  55. package/dist/lcms.d.ts +27 -0
  56. package/dist/permissions.d.ts +46 -0
  57. package/dist/stores/auth.d.ts +74 -2
  58. package/dist/stores/index.js +1 -1
  59. package/dist/styles.css +3186 -1070
  60. package/dist/templates/builders.d.ts +7 -3
  61. package/dist/templates/index.d.ts +2 -2
  62. package/dist/templates/index.js +2 -2
  63. package/dist/templates/presets.d.ts +12 -0
  64. package/dist/templates/types.d.ts +16 -1
  65. package/dist/{templates-Cyt0Suwf.js → templates-BorLR_7p.js} +324 -10
  66. package/dist/templates-BorLR_7p.js.map +1 -0
  67. package/dist/types/auth.d.ts +2 -0
  68. package/dist/types/components.d.ts +32 -3
  69. package/dist/types/form-builder.d.ts +2 -1
  70. package/dist/types/index.d.ts +4 -1
  71. package/dist/types/instrument.d.ts +56 -0
  72. package/dist/types/platform.d.ts +3 -0
  73. package/dist/{useExperimentData-CM6Y0u5L.js → useProtocolTemplates-n6AJqSqv.js} +627 -380
  74. package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
  75. package/dist/utils/rack.d.ts +47 -0
  76. package/package.json +1 -1
  77. package/src/__tests__/components/AppTopBar.test.ts +15 -0
  78. package/src/__tests__/components/BaseTabs.test.ts +15 -0
  79. package/src/__tests__/components/GroupAssigner.test.ts +18 -0
  80. package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
  81. package/src/__tests__/components/ProgressBar.test.ts +18 -0
  82. package/src/__tests__/components/RackEditor.test.ts +125 -0
  83. package/src/__tests__/components/SampleSelector.test.ts +25 -0
  84. package/src/__tests__/components/SegmentedControl.test.ts +45 -0
  85. package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
  86. package/src/__tests__/components/SettingsModal.test.ts +83 -2
  87. package/src/__tests__/composables/useApi.test.ts +45 -0
  88. package/src/__tests__/composables/useAuth.test.ts +20 -0
  89. package/src/__tests__/composables/useControlSchema.test.ts +4 -0
  90. package/src/__tests__/composables/useExperimentData.test.ts +23 -0
  91. package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
  92. package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
  93. package/src/__tests__/stores/settings.test.ts +78 -0
  94. package/src/__tests__/templates/templates.test.ts +86 -0
  95. package/src/__tests__/utils/instrument.test.ts +47 -0
  96. package/src/__tests__/utils/lcms.test.ts +73 -0
  97. package/src/__tests__/utils/permissions.test.ts +50 -0
  98. package/src/__tests__/utils/rack.test.ts +120 -0
  99. package/src/components/AppAvatarMenu.vue +6 -3
  100. package/src/components/AppTopBar.vue +16 -10
  101. package/src/components/AuditTrail.vue +1 -1
  102. package/src/components/BaseTabs.vue +22 -1
  103. package/src/components/Calendar.vue +6 -2
  104. package/src/components/ConcentrationInput.vue +3 -2
  105. package/src/components/GroupAssigner.vue +8 -3
  106. package/src/components/InstrumentAlertLog.vue +191 -0
  107. package/src/components/InstrumentStateBadge.vue +50 -0
  108. package/src/components/InstrumentStatusCard.vue +188 -0
  109. package/src/components/LcmsSequenceTable.vue +191 -0
  110. package/src/components/NumberInput.vue +5 -3
  111. package/src/components/ProgressBar.vue +3 -0
  112. package/src/components/RackEditor.vue +73 -2
  113. package/src/components/SampleHierarchyTree.vue +3 -2
  114. package/src/components/SampleSelector.vue +28 -9
  115. package/src/components/SegmentedControl.story.vue +17 -0
  116. package/src/components/SegmentedControl.vue +14 -3
  117. package/src/components/SequenceProgressBar.vue +71 -0
  118. package/src/components/SettingsModal.vue +49 -2
  119. package/src/components/UnitInput.vue +6 -2
  120. package/src/components/WellPlate.vue +145 -24
  121. package/src/components/index.ts +5 -0
  122. package/src/components/internal/WellEditPopupInternal.vue +1 -0
  123. package/src/composables/experimentDesignData.ts +182 -0
  124. package/src/composables/index.ts +14 -0
  125. package/src/composables/useApi.ts +113 -16
  126. package/src/composables/useAuth.ts +4 -0
  127. package/src/composables/useAutoGroup.ts +18 -9
  128. package/src/composables/useControlSchema.ts +21 -0
  129. package/src/composables/useExperimentData.ts +57 -16
  130. package/src/composables/useExperimentSamples.ts +142 -0
  131. package/src/composables/useProtocolTemplates.ts +13 -1
  132. package/src/composables/useRackEditor.ts +3 -2
  133. package/src/index.ts +27 -0
  134. package/src/instrument.ts +90 -0
  135. package/src/lcms.ts +108 -0
  136. package/src/permissions.ts +143 -0
  137. package/src/stores/auth.ts +79 -26
  138. package/src/stores/settings.ts +10 -0
  139. package/src/styles/components/instrument-monitor.css +478 -0
  140. package/src/styles/components/lcms-sequence-table.css +189 -0
  141. package/src/styles/components/sequence-progress-bar.css +63 -0
  142. package/src/styles/components/settings-modal.css +9 -0
  143. package/src/styles/components/tabs.css +9 -0
  144. package/src/styles/components/well-edit-popup.css +7 -1
  145. package/src/styles/components/well-plate.css +5 -0
  146. package/src/styles/index.css +3 -0
  147. package/src/templates/builders.ts +201 -0
  148. package/src/templates/controlSchemas.ts +68 -0
  149. package/src/templates/index.ts +2 -0
  150. package/src/templates/presets.ts +23 -0
  151. package/src/templates/types.ts +17 -0
  152. package/src/types/auth.ts +3 -0
  153. package/src/types/components.ts +45 -3
  154. package/src/types/form-builder.ts +2 -1
  155. package/src/types/index.ts +35 -0
  156. package/src/types/instrument.ts +61 -0
  157. package/src/types/platform.ts +4 -0
  158. package/src/utils/rack.ts +209 -0
  159. package/dist/auth-QQj2kkze.js.map +0 -1
  160. package/dist/components-DihbSJjU.js.map +0 -1
  161. package/dist/composables-BcgZ6diz.js.map +0 -1
  162. package/dist/templates-Cyt0Suwf.js.map +0 -1
  163. package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ createLcmsControlWellEditData,
4
+ getLcmsDefaultControlWellId,
5
+ lcmsPlateCellsToRacks,
6
+ rackToLcmsPlateCells,
7
+ } from '../../utils/rack'
8
+ import type { Rack } from '../../types'
9
+
10
+ describe('rack LCMS adapters', () => {
11
+ it('converts LCMS plate cells into rack wells grouped by slot', () => {
12
+ const racks = lcmsPlateCellsToRacks([
13
+ {
14
+ row: 'A',
15
+ column: 1,
16
+ sample_name: 'S1',
17
+ sample_type: 'sample',
18
+ injection_volume: 7,
19
+ slot: 'G',
20
+ },
21
+ {
22
+ row: 'H',
23
+ column: 10,
24
+ sample_name: 'IQC',
25
+ sample_type: 'iqc',
26
+ injection_count: 2,
27
+ slot: 'R',
28
+ },
29
+ ])
30
+
31
+ expect(racks.map(rack => rack.slot)).toEqual(['R', 'G'])
32
+ expect(racks[0]).toMatchObject({
33
+ id: 'rack-R',
34
+ format: 96,
35
+ wells: {
36
+ H10: {
37
+ sampleType: 'iqc',
38
+ metadata: {
39
+ label: 'IQC',
40
+ injectionVolume: 5,
41
+ injectionCount: 2,
42
+ },
43
+ },
44
+ },
45
+ })
46
+ expect(racks[1].wells.A1?.metadata).toMatchObject({
47
+ label: 'S1',
48
+ injectionVolume: 7,
49
+ })
50
+ })
51
+
52
+ it('flattens rack wells back to LCMS plate cells with slot metadata', () => {
53
+ const rack: Rack = {
54
+ id: 'rack-R',
55
+ name: 'Rack R',
56
+ format: 54,
57
+ slot: 'R',
58
+ injectionVolume: 5,
59
+ wells: {
60
+ A1: {
61
+ id: 'A1',
62
+ state: 'filled',
63
+ sampleType: 'sample',
64
+ metadata: {
65
+ label: 'S1',
66
+ injectionVolume: 7,
67
+ injectionCount: 3,
68
+ customMethod: 'C:\\Methods\\custom.meth',
69
+ },
70
+ },
71
+ F7: {
72
+ id: 'F7',
73
+ state: 'filled',
74
+ sampleType: 'iqc',
75
+ metadata: {
76
+ label: 'IQC',
77
+ },
78
+ },
79
+ },
80
+ }
81
+
82
+ expect(rackToLcmsPlateCells(rack)).toEqual([
83
+ {
84
+ row: 'A',
85
+ column: 1,
86
+ sample_name: 'S1',
87
+ sample_type: 'sample',
88
+ injection_volume: 7,
89
+ injection_count: 3,
90
+ custom_method: 'C:\\Methods\\custom.meth',
91
+ slot: 'R',
92
+ },
93
+ {
94
+ row: 'F',
95
+ column: 7,
96
+ sample_name: 'IQC',
97
+ sample_type: 'iqc',
98
+ injection_volume: 5,
99
+ injection_count: 1,
100
+ custom_method: null,
101
+ slot: 'R',
102
+ },
103
+ ])
104
+ })
105
+
106
+ it('exposes LCMS default control well edit data', () => {
107
+ expect(getLcmsDefaultControlWellId('54vial', 'iqc')).toBe('F7')
108
+ expect(createLcmsControlWellEditData('qc', {
109
+ plateType: '96well',
110
+ injectionVolume: 8,
111
+ })).toEqual({
112
+ wellId: 'H11',
113
+ label: 'QC',
114
+ sampleType: 'qc',
115
+ injectionVolume: 8,
116
+ injectionCount: 1,
117
+ customMethod: '',
118
+ })
119
+ })
120
+ })
@@ -35,9 +35,12 @@ const { isOpen, rootRef, close, toggle } = useDropdownState({
35
35
  const initials = computed(() => {
36
36
  if (props.userInitial) return props.userInitial.slice(0, 2).toUpperCase()
37
37
  if (props.userName) {
38
- const parts = props.userName.trim().split(/\s+/)
39
- if (parts.length >= 2) return (parts[0]!.charAt(0) + parts[1]!.charAt(0)).toUpperCase()
40
- return parts[0]!.slice(0, 2).toUpperCase()
38
+ const parts = props.userName.trim().split(/\s+/).filter(Boolean)
39
+ const first = parts[0]
40
+ if (!first) return 'U'
41
+ const second = parts[1]
42
+ if (second) return (first.charAt(0) + second.charAt(0)).toUpperCase()
43
+ return first.slice(0, 2).toUpperCase()
41
44
  }
42
45
  return 'U'
43
46
  })
@@ -117,7 +117,12 @@ const { isIntegrated, plugin } = usePlatformContext()
117
117
  const isStandalone = computed(() => !isIntegrated.value)
118
118
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
119
119
 
120
- const hasPluginIcon = computed(() => !!plugin.value?.icon)
120
+ const pluginIcon = computed(() => {
121
+ const currentPlugin = plugin.value
122
+ return currentPlugin?.icon
123
+ ? { icon: currentPlugin.icon, color: currentPlugin.color }
124
+ : null
125
+ })
121
126
 
122
127
  const profileInitial = computed(() => {
123
128
  if (props.userInitial) return props.userInitial
@@ -225,10 +230,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
225
230
  >
226
231
  <slot name="icon">
227
232
  <PluginIcon
228
- v-if="hasPluginIcon"
233
+ v-if="pluginIcon"
229
234
  class="mint-topbar__plugin-icon"
230
- :icon="plugin!.icon"
231
- :tone="plugin!.color"
235
+ :icon="pluginIcon.icon"
236
+ :tone="pluginIcon.color"
232
237
  size="md"
233
238
  variant="solid"
234
239
  />
@@ -248,10 +253,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
248
253
  >
249
254
  <slot name="icon">
250
255
  <PluginIcon
251
- v-if="hasPluginIcon"
256
+ v-if="pluginIcon"
252
257
  class="mint-topbar__plugin-icon"
253
- :icon="plugin!.icon"
254
- :tone="plugin!.color"
258
+ :icon="pluginIcon.icon"
259
+ :tone="pluginIcon.color"
255
260
  size="md"
256
261
  variant="solid"
257
262
  />
@@ -267,10 +272,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
267
272
  <template v-else>
268
273
  <slot name="icon">
269
274
  <PluginIcon
270
- v-if="hasPluginIcon"
275
+ v-if="pluginIcon"
271
276
  class="mint-topbar__plugin-icon"
272
- :icon="plugin!.icon"
273
- :tone="plugin!.color"
277
+ :icon="pluginIcon.icon"
278
+ :tone="pluginIcon.color"
274
279
  size="md"
275
280
  variant="solid"
276
281
  />
@@ -446,6 +451,7 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
446
451
  :control-options="settingsConfig?.controlOptions"
447
452
  :values="settingsConfig?.values"
448
453
  :enhancements="settingsConfig?.enhancements"
454
+ :user-type="settingsConfig?.userType"
449
455
  @update:values="emit('settings-values-change', $event)"
450
456
  >
451
457
  <template v-for="tab in normalizedSettingsTabs" :key="tab.id" #[`tab-${tab.id}`]>
@@ -32,7 +32,7 @@ const uniqueTypes = computed(() => {
32
32
  })
33
33
 
34
34
  const uniqueUsers = computed(() => {
35
- const users = new Set(props.entries.filter(e => e.user).map(e => e.user!))
35
+ const users = new Set(props.entries.flatMap(e => e.user ? [e.user] : []))
36
36
  return Array.from(users)
37
37
  })
38
38
 
@@ -21,6 +21,11 @@ const emit = defineEmits<{
21
21
  const activeTab = computed(() => props.modelValue)
22
22
  const normalizedTabs = computed<TabItem[]>(() => props.tabs.map(normalizeItemInput))
23
23
 
24
+ function isSvgIcon(icon: TabItem['icon']): icon is string | string[] {
25
+ if (!icon) return false
26
+ return Array.isArray(icon) || icon.trim().startsWith('M') || icon.trim().startsWith('m')
27
+ }
28
+
24
29
  function selectTab(tabId: string) {
25
30
  const tab = normalizedTabs.value.find(t => t.id === tabId)
26
31
  if (tab && !tab.disabled) {
@@ -46,7 +51,23 @@ function selectTab(tabId: string) {
46
51
  @click="selectTab(tab.id)"
47
52
  >
48
53
  <span class="mint-tab__content">
49
- <span v-if="tab.icon" class="mint-tab__icon">{{ tab.icon }}</span>
54
+ <svg
55
+ v-if="isSvgIcon(tab.icon)"
56
+ class="mint-tab__icon mint-tab__icon--svg"
57
+ viewBox="0 0 24 24"
58
+ fill="none"
59
+ stroke="currentColor"
60
+ stroke-width="2"
61
+ stroke-linecap="round"
62
+ stroke-linejoin="round"
63
+ aria-hidden="true"
64
+ >
65
+ <template v-if="Array.isArray(tab.icon)">
66
+ <path v-for="(d, index) in tab.icon" :key="index" :d="d" />
67
+ </template>
68
+ <path v-else :d="tab.icon" />
69
+ </svg>
70
+ <span v-else-if="tab.icon" class="mint-tab__icon">{{ tab.icon }}</span>
50
71
  {{ tab.label }}
51
72
  <span v-if="tab.badge !== undefined" class="mint-tab__badge">
52
73
  {{ tab.badge }}
@@ -134,8 +134,12 @@ const markerMap = computed(() => {
134
134
  for (const marker of props.markers) {
135
135
  const date = parseDate(marker.date)
136
136
  const key = toDateKey(date)
137
- if (!map.has(key)) map.set(key, [])
138
- map.get(key)!.push(marker)
137
+ const markers = map.get(key)
138
+ if (markers) {
139
+ markers.push(marker)
140
+ } else {
141
+ map.set(key, [marker])
142
+ }
139
143
  }
140
144
  return map
141
145
  })
@@ -37,13 +37,14 @@ const { unitCategories, getConversionHint } = useConcentrationUnits()
37
37
 
38
38
  // Filter categories based on allowedUnits
39
39
  const filteredCategories = computed(() => {
40
- if (!props.allowedUnits || props.allowedUnits.length === 0) {
40
+ const allowedUnits = props.allowedUnits
41
+ if (!allowedUnits || allowedUnits.length === 0) {
41
42
  return unitCategories.value
42
43
  }
43
44
  return unitCategories.value
44
45
  .map(cat => ({
45
46
  label: cat.label,
46
- units: cat.units.filter(u => props.allowedUnits!.includes(u)),
47
+ units: cat.units.filter(u => allowedUnits.includes(u)),
47
48
  }))
48
49
  .filter(cat => cat.units.length > 0)
49
50
  })
@@ -42,9 +42,12 @@ const assignment = useGroupAssignment({
42
42
  const { unassignedGroups, zone1Groups, zone2Groups, zone1Count, zone2Count, validationMessage } = assignment
43
43
 
44
44
  function handleDragStart(event: DragEvent, groupName: string) {
45
+ const transfer = event.dataTransfer
46
+ if (!transfer) return
47
+
45
48
  draggingGroup.value = groupName
46
- event.dataTransfer?.setData('groupName', groupName)
47
- event.dataTransfer!.effectAllowed = 'move'
49
+ transfer.setData('groupName', groupName)
50
+ transfer.effectAllowed = 'move'
48
51
  }
49
52
 
50
53
  function handleDragEnd() {
@@ -54,7 +57,9 @@ function handleDragEnd() {
54
57
 
55
58
  function handleDragOver(event: DragEvent, zone: GroupAssignmentZone) {
56
59
  event.preventDefault()
57
- event.dataTransfer!.dropEffect = 'move'
60
+ if (event.dataTransfer) {
61
+ event.dataTransfer.dropEffect = 'move'
62
+ }
58
63
  dragOverZone.value = zone
59
64
  }
60
65
 
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ /** Filterable instrument alert/event list with optional acknowledge actions. */
3
+ import { computed, ref } from 'vue'
4
+ import type { InstrumentAlert, InstrumentAlertLevel } from '../types'
5
+
6
+ interface Props {
7
+ alerts?: InstrumentAlert[]
8
+ title?: string
9
+ acknowledgeable?: boolean
10
+ emptyMessage?: string
11
+ filteredEmptyMessage?: string
12
+ locale?: string
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ alerts: () => [],
17
+ title: 'Event Log',
18
+ acknowledgeable: true,
19
+ emptyMessage: 'No alerts received yet',
20
+ filteredEmptyMessage: 'No alerts match current filters',
21
+ locale: undefined,
22
+ })
23
+
24
+ const emit = defineEmits<{
25
+ acknowledge: [alert: InstrumentAlert]
26
+ }>()
27
+
28
+ const levelConfig: Record<InstrumentAlertLevel, { label: string; cssClass: string; color: string }> = {
29
+ critical: { label: 'Critical', cssClass: 'mint-instrument-alert--critical', color: '#ef4444' },
30
+ warning: { label: 'Warning', cssClass: 'mint-instrument-alert--warning', color: '#eab308' },
31
+ info: { label: 'Info', cssClass: 'mint-instrument-alert--info', color: '#3b82f6' },
32
+ }
33
+
34
+ const levels: InstrumentAlertLevel[] = ['critical', 'warning', 'info']
35
+ const activeLevels = ref<Set<InstrumentAlertLevel>>(new Set(levels))
36
+ const activeInstrument = ref<string | null>(null)
37
+
38
+ const instruments = computed(() => {
39
+ const counts = new Map<string, number>()
40
+ for (const alert of props.alerts) {
41
+ const name = instrumentName(alert)
42
+ counts.set(name, (counts.get(name) ?? 0) + 1)
43
+ }
44
+ return [...counts.entries()].map(([name, count]) => ({ name, count }))
45
+ })
46
+
47
+ const alertCounts = computed(() => {
48
+ const counts: Record<InstrumentAlertLevel, number> = { critical: 0, warning: 0, info: 0 }
49
+ for (const alert of props.alerts) {
50
+ if (alert.level in counts) counts[alert.level]++
51
+ }
52
+ return counts
53
+ })
54
+
55
+ const filteredAlerts = computed(() =>
56
+ [...props.alerts]
57
+ .filter(alert => activeLevels.value.has(alert.level))
58
+ .filter(alert => !activeInstrument.value || instrumentName(alert) === activeInstrument.value)
59
+ .sort((a, b) => alertTime(b) - alertTime(a))
60
+ )
61
+
62
+ function toggleLevel(level: InstrumentAlertLevel) {
63
+ const next = new Set(activeLevels.value)
64
+ if (next.has(level)) {
65
+ if (next.size > 1) next.delete(level)
66
+ } else {
67
+ next.add(level)
68
+ }
69
+ activeLevels.value = next
70
+ }
71
+
72
+ function toggleInstrument(name: string) {
73
+ activeInstrument.value = activeInstrument.value === name ? null : name
74
+ }
75
+
76
+ function instrumentName(alert: InstrumentAlert): string {
77
+ return alert.instrument_name || alert.instrument_id
78
+ }
79
+
80
+ function alertTime(alert: InstrumentAlert): number {
81
+ if (!alert.timestamp) return 0
82
+ const date = alert.timestamp instanceof Date ? alert.timestamp : new Date(alert.timestamp)
83
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime()
84
+ }
85
+
86
+ function timeAgo(value: string | Date | undefined): string {
87
+ if (!value) return ''
88
+ const date = value instanceof Date ? value : new Date(value)
89
+ if (Number.isNaN(date.getTime())) return ''
90
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
91
+ if (seconds < 60) return 'just now'
92
+ const minutes = Math.floor(seconds / 60)
93
+ if (minutes < 60) return `${minutes}m ago`
94
+ const hours = Math.floor(minutes / 60)
95
+ if (hours < 24) return `${hours}h ago`
96
+ const days = Math.floor(hours / 24)
97
+ if (days < 7) return `${days}d ago`
98
+ return date.toLocaleDateString(props.locale, { month: 'short', day: 'numeric' })
99
+ }
100
+
101
+ function alertDetail(alert: InstrumentAlert): string {
102
+ return alert.body?.detail ?? alert.message ?? alert.rule ?? ''
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div class="mint-instrument-alert-log">
108
+ <div class="mint-instrument-alert-log__header">
109
+ <span class="mint-instrument-alert-log__title">{{ title }}</span>
110
+ </div>
111
+
112
+ <div class="mint-instrument-alert-log__filters">
113
+ <div class="mint-instrument-alert-log__filter-row">
114
+ <button
115
+ v-for="level in levels"
116
+ :key="level"
117
+ type="button"
118
+ class="mint-instrument-alert-log__filter"
119
+ :class="{ 'mint-instrument-alert-log__filter--active': activeLevels.has(level) }"
120
+ :style="activeLevels.has(level) ? { '--filter-color': levelConfig[level].color } : undefined"
121
+ @click="toggleLevel(level)"
122
+ >
123
+ <span
124
+ class="mint-instrument-alert-log__filter-dot"
125
+ :style="{ backgroundColor: levelConfig[level].color }"
126
+ />
127
+ {{ levelConfig[level].label }}
128
+ <span class="mint-instrument-alert-log__filter-count">{{ alertCounts[level] }}</span>
129
+ </button>
130
+ </div>
131
+ <div v-if="instruments.length > 1" class="mint-instrument-alert-log__filter-row">
132
+ <button
133
+ v-for="instrument in instruments"
134
+ :key="instrument.name"
135
+ type="button"
136
+ class="mint-instrument-alert-log__filter"
137
+ :class="{ 'mint-instrument-alert-log__filter--active': activeInstrument === instrument.name }"
138
+ @click="toggleInstrument(instrument.name)"
139
+ >
140
+ {{ instrument.name }}
141
+ <span class="mint-instrument-alert-log__filter-count">{{ instrument.count }}</span>
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ <TransitionGroup name="mint-instrument-alert" tag="div" class="mint-instrument-alert-log__list">
147
+ <div
148
+ v-for="alert in filteredAlerts"
149
+ :key="alert.id ?? `${alert.instrument_id}-${alert.rule ?? alert.message}-${alert.timestamp ?? ''}`"
150
+ class="mint-instrument-alert"
151
+ :class="[
152
+ levelConfig[alert.level]?.cssClass,
153
+ { 'mint-instrument-alert--acknowledged': alert.acknowledged },
154
+ ]"
155
+ >
156
+ <div class="mint-instrument-alert__header">
157
+ <span class="mint-instrument-alert__instrument">{{ instrumentName(alert) }}</span>
158
+ <span v-if="alert.body?.source" class="mint-instrument-alert__source">
159
+ {{ alert.body.source }}
160
+ </span>
161
+ <span class="mint-instrument-alert__time">{{ timeAgo(alert.timestamp) }}</span>
162
+ </div>
163
+ <div class="mint-instrument-alert__detail">{{ alertDetail(alert) }}</div>
164
+ <div
165
+ v-if="alert.body?.code != null || (acknowledgeable && !alert.acknowledged && alert.level !== 'info')"
166
+ class="mint-instrument-alert__footer"
167
+ >
168
+ <span v-if="alert.body?.code != null" class="mint-instrument-alert__code">
169
+ Code {{ alert.body.code }}
170
+ </span>
171
+ <button
172
+ v-if="acknowledgeable && !alert.acknowledged && alert.level !== 'info'"
173
+ type="button"
174
+ class="mint-instrument-alert__ack"
175
+ @click="emit('acknowledge', alert)"
176
+ >
177
+ Acknowledge
178
+ </button>
179
+ </div>
180
+ </div>
181
+
182
+ <div v-if="filteredAlerts.length === 0" key="empty" class="mint-instrument-alert-log__empty">
183
+ {{ alerts.length === 0 ? emptyMessage : filteredEmptyMessage }}
184
+ </div>
185
+ </TransitionGroup>
186
+ </div>
187
+ </template>
188
+
189
+ <style>
190
+ @import '../styles/components/instrument-monitor.css';
191
+ </style>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ /** Status badge for shared instrument monitor states. */
3
+ import { computed } from 'vue'
4
+ import type { InstrumentState } from '../types'
5
+
6
+ interface Props {
7
+ state: InstrumentState | string
8
+ label?: string
9
+ pulseWhenRunning?: boolean
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ label: undefined,
14
+ pulseWhenRunning: true,
15
+ })
16
+
17
+ const stateConfig: Record<InstrumentState, { label: string; tone: string }> = {
18
+ running: { label: 'RUNNING', tone: 'success' },
19
+ standby: { label: 'STANDBY', tone: 'warning' },
20
+ idle: { label: 'IDLE', tone: 'muted' },
21
+ error: { label: 'ERROR', tone: 'error' },
22
+ connected: { label: 'CONNECTED', tone: 'info' },
23
+ disconnected: { label: 'OFFLINE', tone: 'muted' },
24
+ }
25
+
26
+ const normalizedState = computed(() => props.state.toLowerCase())
27
+ const config = computed(() => {
28
+ const match = stateConfig[normalizedState.value as InstrumentState]
29
+ return match ?? { label: props.state.toUpperCase(), tone: 'muted' }
30
+ })
31
+ const shouldPulse = computed(() => props.pulseWhenRunning && normalizedState.value === 'running')
32
+ </script>
33
+
34
+ <template>
35
+ <span
36
+ class="mint-instrument-state"
37
+ :class="[
38
+ `mint-instrument-state--${config.tone}`,
39
+ { 'mint-instrument-state--pulse': shouldPulse },
40
+ { 'mint-instrument-state--offline': normalizedState === 'disconnected' },
41
+ ]"
42
+ >
43
+ <span v-if="shouldPulse" class="mint-instrument-state__dot" />
44
+ {{ label ?? config.label }}
45
+ </span>
46
+ </template>
47
+
48
+ <style>
49
+ @import '../styles/components/instrument-monitor.css';
50
+ </style>