@stacksjs/defaults 0.70.365 → 0.70.367

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 (90) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +71 -14
  3. package/ai/skills/stacks-deploy/SKILL.md +18 -0
  4. package/ai/skills/stacks-desktop/SKILL.md +11 -2
  5. package/ai/skills/stacks-stx/SKILL.md +25 -0
  6. package/app/Actions/Auth/LoginAction.ts +11 -2
  7. package/app/Actions/Auth/RefreshTokenAction.ts +6 -2
  8. package/app/Actions/Auth/RegisterAction.ts +6 -2
  9. package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +6 -2
  10. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  11. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  12. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  13. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  15. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  16. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  17. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  18. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  19. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  20. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  21. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  22. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  23. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  24. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  25. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  26. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  27. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  33. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  34. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  35. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  36. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  37. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  38. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  39. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  40. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  41. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  42. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  43. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  44. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  45. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  46. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  47. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  48. package/functions/deployments.ts +18 -0
  49. package/functions/operations.ts +361 -0
  50. package/ide/vscode/package.json +1 -1
  51. package/package.json +1 -1
  52. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +0 -2
  53. package/resources/components/Dashboard/Auth/LoginDashboard.stx +1 -1
  54. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +1 -3
  55. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  56. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  57. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  58. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  59. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +9 -435
  60. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +416 -0
  61. package/resources/components/Dashboard/Modals/BaseModal.stx +13 -36
  62. package/resources/components/Dashboard/Modals/Popups/Alert.stx +18 -44
  63. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  64. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  65. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  66. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  67. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  68. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  69. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  70. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  71. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  72. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  73. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  74. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  75. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  76. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  77. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  78. package/resources/components/Dashboard/UI/Modal.stx +43 -14
  79. package/resources/components/Dashboard/UI/Table.stx +30 -25
  80. package/resources/functions/dashboard/sidebar.ts +1 -0
  81. package/routes/dashboard-api.ts +23 -0
  82. package/routes/dashboard.ts +1 -0
  83. package/views/dashboard/AUDIT.md +21 -16
  84. package/views/dashboard/operations/audit.stx +5 -0
  85. package/views/dashboard/operations/changes.stx +5 -0
  86. package/views/dashboard/operations/incidents.stx +5 -0
  87. package/views/dashboard/operations/migrations.stx +8 -0
  88. package/views/dashboard/operations/recovery.stx +8 -0
  89. package/views/dashboard/operations/scheduler.stx +8 -0
  90. package/views/dashboard/stores/ci.ts +2 -2
@@ -0,0 +1,416 @@
1
+ <script client>
2
+ import { kanbanStore } from '~/storage/framework/defaults/views/dashboard/stores/kanban'
3
+
4
+ const kanban = kanbanStore
5
+ const openCard = derived(() => kanban.openCard())
6
+ const loadingCard = derived(() => kanban.loadingCard())
7
+ const errorCard = derived(() => kanban.errorCard())
8
+ const allLabels = derived(() => kanban.currentLabels())
9
+ const cardComments = derived(() => openCard()?.comments ?? [])
10
+ const cardLabels = derived(() => openCard()?.labels ?? [])
11
+ const cardAssignees = derived(() => openCard()?.assignees ?? [])
12
+ const titleDraft = state('')
13
+ const descDraft = state('')
14
+ const commentDraft = state('')
15
+ const editingCommentId = state<number | null>(null)
16
+ const commentEditDraft = state('')
17
+ const savingCommentId = state<number | null>(null)
18
+ const showLabelPicker = state(false)
19
+ const showAssigneePicker = state(false)
20
+ const showNewLabelComposer = state(false)
21
+ const newLabelName = state('')
22
+ const newLabelColor = state('slate')
23
+ const dueDateDraft = state('')
24
+ const pendingDelete = state<{ kind: 'comment' | 'label', id: number } | null>(null)
25
+ const deleting = state(false)
26
+ const labelColors = ['slate', 'red', 'amber', 'emerald', 'blue', 'violet', 'rose']
27
+ const { start: finishCommentEditCancel } = useTimeoutFn(cancelCommentEdit, 50, { immediate: false })
28
+ let activeCardId: number | null = null
29
+ let wasLoading = false
30
+
31
+ function syncDraftsFromCard(): void {
32
+ const card = openCard()
33
+ titleDraft.set(card?.title || '')
34
+ descDraft.set(card?.description || '')
35
+ dueDateDraft.set(card?.dueDate ? card.dueDate.slice(0, 10) : '')
36
+ commentDraft.set('')
37
+ editingCommentId.set(null)
38
+ commentEditDraft.set('')
39
+ savingCommentId.set(null)
40
+ showLabelPicker.set(false)
41
+ showAssigneePicker.set(false)
42
+ showNewLabelComposer.set(false)
43
+ newLabelName.set('')
44
+ newLabelColor.set('slate')
45
+ pendingDelete.set(null)
46
+ }
47
+
48
+ effect(() => {
49
+ const card = openCard()
50
+ const isLoading = loadingCard()
51
+ const cardId = card?.id ?? null
52
+ if (cardId !== activeCardId || (wasLoading && !isLoading)) {
53
+ activeCardId = cardId
54
+ syncDraftsFromCard()
55
+ }
56
+ wasLoading = isLoading
57
+ })
58
+
59
+ function close(): void {
60
+ if (!deleting())
61
+ kanban.closeCardDetail()
62
+ }
63
+
64
+ async function commitTitle(): Promise<void> {
65
+ const card = openCard()
66
+ if (!card)
67
+ return
68
+ const next = titleDraft().trim()
69
+ if (!next) {
70
+ titleDraft.set(card.title)
71
+ return
72
+ }
73
+ if (next !== card.title)
74
+ await kanban.updateCard({ title: next })
75
+ }
76
+
77
+ async function commitDescription(): Promise<void> {
78
+ const card = openCard()
79
+ if (!card)
80
+ return
81
+ const next = descDraft()
82
+ if (next !== (card.description || ''))
83
+ await kanban.updateCard({ description: next || null })
84
+ }
85
+
86
+ async function commitDueDate(): Promise<void> {
87
+ await kanban.updateCard({ dueDate: dueDateDraft() || null })
88
+ }
89
+
90
+ async function toggleLabel(labelId: number): Promise<void> {
91
+ const card = openCard()
92
+ if (!card)
93
+ return
94
+ const hasLabel = card.labels.some(label => label.id === labelId)
95
+ const labelIds = hasLabel
96
+ ? card.labels.filter(label => label.id !== labelId).map(label => label.id)
97
+ : [...card.labels.map(label => label.id), labelId]
98
+ await kanban.syncCardLabels(card.id, labelIds)
99
+ }
100
+
101
+ async function toggleAssignee(userId: number): Promise<void> {
102
+ const card = openCard()
103
+ if (!card)
104
+ return
105
+ const hasAssignee = card.assignees.some(assignee => assignee.userId === userId)
106
+ const userIds = hasAssignee
107
+ ? card.assignees.filter(assignee => assignee.userId !== userId).map(assignee => assignee.userId)
108
+ : [...card.assignees.map(assignee => assignee.userId), userId]
109
+ await kanban.syncCardAssignees(card.id, userIds)
110
+ }
111
+
112
+ async function submitComment(): Promise<void> {
113
+ const body = commentDraft().trim()
114
+ if (!body)
115
+ return
116
+ if (await kanban.addComment(body))
117
+ commentDraft.set('')
118
+ }
119
+
120
+ function startCommentEdit(id: number, body: string): void {
121
+ editingCommentId.set(id)
122
+ commentEditDraft.set(body)
123
+ }
124
+
125
+ function cancelCommentEdit(): void {
126
+ editingCommentId.set(null)
127
+ commentEditDraft.set('')
128
+ }
129
+
130
+ function cancelCommentEditWithoutClosing(): void {
131
+ finishCommentEditCancel()
132
+ }
133
+
134
+ async function saveCommentEdit(id: number): Promise<void> {
135
+ const body = commentEditDraft().trim()
136
+ if (!body || savingCommentId())
137
+ return
138
+ savingCommentId.set(id)
139
+ try {
140
+ if (await kanban.updateComment(id, body))
141
+ cancelCommentEdit()
142
+ }
143
+ finally {
144
+ savingCommentId.set(null)
145
+ }
146
+ }
147
+
148
+ async function submitNewLabel(): Promise<void> {
149
+ const name = newLabelName().trim()
150
+ const card = openCard()
151
+ if (!name || !card)
152
+ return
153
+ const created = await kanban.createLabel({ boardId: card.boardId, name, color: newLabelColor() })
154
+ if (!created)
155
+ return
156
+ await kanban.syncCardLabels(card.id, [...card.labels.map(label => label.id), created.id])
157
+ newLabelName.set('')
158
+ showNewLabelComposer.set(false)
159
+ }
160
+
161
+ const deleteTitle = derived(() => pendingDelete()?.kind === 'label'
162
+ ? 'Delete this label?'
163
+ : 'Delete this comment?')
164
+
165
+ const deleteDescription = derived(() => pendingDelete()?.kind === 'label'
166
+ ? 'The label will be removed from every card that currently uses it.'
167
+ : 'This action cannot be undone.')
168
+
169
+ function requestDelete(kind: 'comment' | 'label', id: number): void {
170
+ pendingDelete.set({ kind, id })
171
+ }
172
+
173
+ function closeDelete(): void {
174
+ if (!deleting())
175
+ pendingDelete.set(null)
176
+ }
177
+
178
+ async function confirmDelete(): Promise<void> {
179
+ const pending = pendingDelete()
180
+ if (!pending || deleting())
181
+ return
182
+ deleting.set(true)
183
+ try {
184
+ const deleted = pending.kind === 'comment'
185
+ ? await kanban.deleteComment(pending.id)
186
+ : await kanban.deleteLabel(pending.id)
187
+ if (deleted)
188
+ pendingDelete.set(null)
189
+ }
190
+ finally {
191
+ deleting.set(false)
192
+ }
193
+ }
194
+
195
+ function labelChipClass(color: string): string {
196
+ return ({
197
+ slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800/60 dark:text-slate-200',
198
+ amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200',
199
+ blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
200
+ emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200',
201
+ red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
202
+ violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200',
203
+ rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200',
204
+ })[color] || 'bg-slate-100 text-slate-700 dark:bg-slate-800/60 dark:text-slate-200'
205
+ }
206
+
207
+ function initialsOf(name: string | null, email: string | null): string {
208
+ const source = (name || email || '?').trim()
209
+ const parts = source.split(/\s+/).filter(Boolean)
210
+ if (parts.length === 0)
211
+ return '?'
212
+ if (parts.length === 1)
213
+ return parts[0].slice(0, 2).toUpperCase()
214
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
215
+ }
216
+
217
+ function formatTimestamp(iso: string | null): string {
218
+ if (!iso)
219
+ return ''
220
+ const date = new Date(iso)
221
+ if (Number.isNaN(date.getTime()))
222
+ return ''
223
+ return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
224
+ }
225
+ </script>
226
+
227
+ <Modal
228
+ :isOpen="Boolean(openCard())"
229
+ title="Card details"
230
+ description="Edit card content, ownership, labels, and activity."
231
+ size="xl"
232
+ :closable="!deleting && editingCommentId === null"
233
+ @close="close"
234
+ >
235
+ <div :if="openCard()" class="grid gap-6">
236
+ <label class="grid gap-2">
237
+ <span class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Title</span>
238
+ <input
239
+ x-model="titleDraft"
240
+ name="card_title"
241
+ required
242
+ autofocus
243
+ @blur="commitTitle()"
244
+ @keydown.enter.prevent="$event.currentTarget.blur()"
245
+ class="px-3 py-2 font-semibold text-lg text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-950 border border-zinc-200 rounded-md dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600"
246
+ />
247
+ </label>
248
+
249
+ <div :if="loadingCard()" role="status" class="flex gap-2 items-center text-zinc-500 text-sm">
250
+ <span class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
251
+ Refreshing card details
252
+ </div>
253
+
254
+ <div :if="errorCard()" role="alert" class="px-3 py-2 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-md dark:border-red-900/40">
255
+ {{ errorCard() }}
256
+ </div>
257
+
258
+ <div class="grid gap-6 grid-cols-1 md:grid-cols-3">
259
+ <div class="md:col-span-2 space-y-6">
260
+ <section>
261
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Description</h4>
262
+ <textarea
263
+ x-model="descDraft"
264
+ name="card_description"
265
+ @blur="commitDescription()"
266
+ rows="4"
267
+ placeholder="Add a more detailed description"
268
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
269
+ ></textarea>
270
+ </section>
271
+
272
+ <section>
273
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Activity</h4>
274
+ <div class="mb-4">
275
+ <textarea
276
+ x-model="commentDraft"
277
+ name="card_comment"
278
+ @keydown.meta.enter.prevent="submitComment()"
279
+ @keydown.ctrl.enter.prevent="submitComment()"
280
+ rows="2"
281
+ placeholder="Add a comment, then press Command+Enter to send"
282
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-none"
283
+ ></textarea>
284
+ <div class="flex justify-end mt-2">
285
+ <Button size="sm" :disabled="!commentDraft().trim()" @click="submitComment()">Comment</Button>
286
+ </div>
287
+ </div>
288
+
289
+ <div :if="openCard().comments.length === 0 && !loadingCard()" class="italic text-zinc-400 text-xs">No comments yet.</div>
290
+ <ul class="space-y-3">
291
+ <li :for="comment in cardComments" class="flex gap-3">
292
+ <span class="inline-flex flex-shrink-0 items-center justify-center h-7 w-7 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(comment.authorName, comment.authorEmail)"></span>
293
+ <div class="flex-1 min-w-0">
294
+ <div class="flex flex-wrap gap-2 items-baseline">
295
+ <span class="font-medium text-zinc-700 text-xs dark:text-zinc-200" :text="comment.authorName || comment.authorEmail || 'Unknown'"></span>
296
+ <span class="text-[10px] text-zinc-400" :text="formatTimestamp(comment.createdAt)"></span>
297
+ <template :if="editingCommentId() !== comment.id">
298
+ <Button variant="ghost" size="xs" @click="startCommentEdit(comment.id, comment.body)">Edit</Button>
299
+ <Button variant="ghost" size="xs" @click="requestDelete('comment', comment.id)"><span class="text-red-500">Delete</span></Button>
300
+ </template>
301
+ </div>
302
+ <p :if="editingCommentId() !== comment.id" class="mt-0.5 text-zinc-700 text-xs whitespace-pre-wrap dark:text-zinc-300" :text="comment.body"></p>
303
+ <div :if="editingCommentId() === comment.id" class="mt-2">
304
+ <textarea
305
+ x-model="commentEditDraft"
306
+ @keydown.escape.stop.prevent="cancelCommentEditWithoutClosing()"
307
+ @keydown.meta.enter.prevent="saveCommentEdit(comment.id)"
308
+ @keydown.ctrl.enter.prevent="saveCommentEdit(comment.id)"
309
+ rows="3"
310
+ maxlength="10000"
311
+ :aria-label="'Edit comment by ' + (comment.authorName || comment.authorEmail || 'Unknown')"
312
+ class="px-3 py-2 w-full text-zinc-800 text-sm dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
313
+ ></textarea>
314
+ <div class="flex gap-2 justify-end mt-2">
315
+ <Button variant="secondary" size="xs" :disabled="savingCommentId() === comment.id" @click="cancelCommentEdit()">Cancel</Button>
316
+ <Button size="xs" :loading="savingCommentId() === comment.id" :disabled="!commentEditDraft().trim()" @click="saveCommentEdit(comment.id)">Save comment</Button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </li>
321
+ </ul>
322
+ </section>
323
+ </div>
324
+
325
+ <aside class="space-y-5">
326
+ <section>
327
+ <div class="flex items-center justify-between mb-2">
328
+ <h4 class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Labels</h4>
329
+ <Button variant="ghost" size="xs" interaction="toggle" :pressed="showLabelPicker()" @click="showLabelPicker.set(!showLabelPicker())">
330
+ <span :if="!showLabelPicker()">Edit</span>
331
+ <span :if="showLabelPicker()">Done</span>
332
+ </Button>
333
+ </div>
334
+
335
+ <div :if="!showLabelPicker()" class="flex flex-wrap gap-1">
336
+ <span :for="label in cardLabels" :class="`px-2 py-0.5 text-xs font-medium rounded ${labelChipClass(label.color)}`" :text="label.name"></span>
337
+ <span :if="openCard().labels.length === 0" class="italic text-zinc-400 text-xs">None</span>
338
+ </div>
339
+
340
+ <div :if="showLabelPicker()" class="space-y-2">
341
+ <div :for="label in allLabels" :class="`flex items-center gap-1 px-1 py-0.5 w-full rounded ${labelChipClass(label.color)}`">
342
+ <button type="button" class="flex flex-1 items-center justify-between px-1 py-0.5 min-w-0 text-left text-xs rounded hover:opacity-90" :aria-pressed="String(openCard().labels.some(cardLabel => cardLabel.id === label.id))" @click="toggleLabel(label.id)">
343
+ <span class="truncate" :text="label.name"></span>
344
+ <span :if="openCard().labels.some(cardLabel => cardLabel.id === label.id)" class="h-3 w-3 i-hugeicons-tick-02"></span>
345
+ </button>
346
+ <Button variant="ghost" size="xs" iconOnly :ariaLabel="'Delete label ' + label.name" @click="requestDelete('label', label.id)">
347
+ <span class="block h-3 w-3 text-red-500 i-hugeicons-cancel-01"></span>
348
+ </Button>
349
+ </div>
350
+
351
+ <div :if="showNewLabelComposer()" class="pt-2 space-y-2 border-t border-zinc-200 dark:border-zinc-800">
352
+ <input x-model="newLabelName" name="label_name" @keydown.enter.prevent="submitNewLabel()" placeholder="Label name" class="px-2 py-1 w-full text-xs bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none" />
353
+ <div class="flex gap-1 items-center" role="group" aria-label="Label color">
354
+ <button :for="color in labelColors" type="button" @click="newLabelColor.set(color)" :class="`w-5 h-5 rounded ${labelChipClass(color)} ${newLabelColor() === color ? 'ring-2 ring-offset-1 ring-violet-500 dark:ring-offset-zinc-900' : ''}`" :aria-label="'Use ' + color + ' label color'" :aria-pressed="String(newLabelColor() === color)"></button>
355
+ </div>
356
+ <div class="flex gap-1">
357
+ <Button size="xs" @click="submitNewLabel()">Create</Button>
358
+ <Button variant="ghost" size="xs" @click="showNewLabelComposer.set(false)">Cancel</Button>
359
+ </div>
360
+ </div>
361
+ <Button :if="!showNewLabelComposer()" variant="outline" size="sm" fullWidth @click="showNewLabelComposer.set(true)">+ New label</Button>
362
+ </div>
363
+ </section>
364
+
365
+ <section>
366
+ <div class="flex items-center justify-between mb-2">
367
+ <h4 class="font-medium text-zinc-500 text-xs tracking-wide uppercase">Assignees</h4>
368
+ <Button variant="ghost" size="xs" interaction="toggle" :pressed="showAssigneePicker()" @click="showAssigneePicker.set(!showAssigneePicker())">
369
+ <span :if="!showAssigneePicker()">Edit</span>
370
+ <span :if="showAssigneePicker()">Done</span>
371
+ </Button>
372
+ </div>
373
+
374
+ <div :if="!showAssigneePicker()" class="flex flex-wrap gap-2">
375
+ <div :for="assignee in cardAssignees" class="flex gap-1.5 items-center text-zinc-700 text-xs dark:text-zinc-200">
376
+ <span class="inline-flex items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(assignee.name, assignee.email)"></span>
377
+ <span :text="assignee.name || assignee.email || 'Unknown'"></span>
378
+ </div>
379
+ <span :if="openCard().assignees.length === 0" class="italic text-zinc-400 text-xs">Unassigned</span>
380
+ </div>
381
+
382
+ <div :if="showAssigneePicker()" class="overflow-y-auto space-y-1 max-h-48">
383
+ <p :if="kanban.users().length === 0" class="italic text-zinc-400 text-xs">No users to assign.</p>
384
+ <button :for="user in kanban.users" type="button" :aria-pressed="String(openCard().assignees.some(assignee => assignee.userId === user.id))" @click="toggleAssignee(user.id)" class="flex gap-2 items-center px-2 py-1.5 w-full text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded">
385
+ <span class="inline-flex flex-shrink-0 items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(user.name, user.email)"></span>
386
+ <span class="flex-1 text-zinc-700 truncate dark:text-zinc-200" :text="user.name || user.email"></span>
387
+ <span :if="openCard().assignees.some(assignee => assignee.userId === user.id)" class="h-3.5 w-3.5 text-violet-500 i-hugeicons-tick-02"></span>
388
+ </button>
389
+ </div>
390
+ </section>
391
+
392
+ <section>
393
+ <h4 class="mb-2 font-medium text-zinc-500 text-xs tracking-wide uppercase">Due date</h4>
394
+ <input type="date" x-model="dueDateDraft" name="due_date" @change="commitDueDate()" class="px-2 py-1 w-full text-zinc-800 text-xs dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none" />
395
+ </section>
396
+
397
+ <section class="pt-3 border-t border-zinc-200 dark:border-zinc-800">
398
+ <p class="text-[10px] text-zinc-400">Created <span :text="formatTimestamp(openCard().createdAt)"></span></p>
399
+ <p :if="openCard().updatedAt && openCard().updatedAt !== openCard().createdAt" class="text-[10px] text-zinc-400">Updated <span :text="formatTimestamp(openCard().updatedAt)"></span></p>
400
+ </section>
401
+ </aside>
402
+ </div>
403
+ </div>
404
+
405
+ </Modal>
406
+
407
+ <ConfirmDialog
408
+ :isOpen="Boolean(pendingDelete())"
409
+ :title="deleteTitle()"
410
+ :description="deleteDescription()"
411
+ :busy="deleting()"
412
+ :error="errorCard() || ''"
413
+ confirmLabel="Delete"
414
+ @close="closeDelete"
415
+ @confirm="confirmDelete"
416
+ />
@@ -1,5 +1,3 @@
1
- @import('../UI/Button')
2
-
3
1
  <script client>
4
2
  const liveId = useReactiveProp('id', '')
5
3
  const liveShow = useReactiveProp('show', true)
@@ -9,49 +7,28 @@ const generatedId = useId('modal')
9
7
 
10
8
  const modalId = derived(() => liveId() || generatedId)
11
9
 
12
- function closeModal(reason: 'backdrop' | 'button'): void {
10
+ function closeModal(reason: 'backdrop' | 'button' | 'escape'): void {
13
11
  liveShow.set(false)
14
12
  emit('update:show', false)
15
13
  emit('close', reason)
16
14
  }
17
15
  </script>
18
16
 
19
- <div
20
- :if="liveShow()"
21
- :data-modal="modalId()"
22
- class="flex fixed inset-0 z-50 items-end justify-center sm:items-center pb-6 px-4 sm:p-0 dashboard-modal-layer"
23
- >
24
- <button
25
- type="button"
26
- class="fixed inset-0 bg-gray-500/75 transition-opacity"
27
- aria-label="Close dialog"
28
- @click="closeModal('backdrop')"
29
- ></button>
30
-
31
- <div
32
- class="overflow-y-auto relative pb-4 pt-5 px-4 sm:p-6 max-h-[32rem] max-w-md w-full md:max-w-xl bg-white dark:bg-neutral-700 rounded-lg shadow-xl transition-all"
33
- role="dialog"
34
- aria-modal="true"
35
- :aria-label="liveAriaLabel()"
17
+ <div :data-modal="modalId()">
18
+ <Modal
19
+ :isOpen="liveShow()"
20
+ :ariaLabel="liveAriaLabel()"
21
+ size="md"
22
+ @close="closeModal"
36
23
  >
37
- <Button
38
- type="button"
39
- variant="ghost"
40
- size="sm"
41
- iconOnly
42
- class="absolute right-4 top-4"
43
- ariaLabel="Close dialog"
44
- @click="closeModal('button')"
45
- >
46
- <span aria-hidden="true" class="h-6 w-6 i-hugeicons-cancel-01"></span>
47
- </Button>
48
-
49
24
  <div class="flex flex-col">
50
25
  <slot name="body" />
51
26
  </div>
52
27
 
53
- <div class="sm:flex sm:flex-row-reverse mt-5 space-y-4 sm:mt-4 md:space-y-0">
54
- <slot name="actions" />
55
- </div>
56
- </div>
28
+ <template #footer>
29
+ <div class="flex flex-row-reverse flex-wrap gap-3 items-center justify-end w-full">
30
+ <slot name="actions" />
31
+ </div>
32
+ </template>
33
+ </Modal>
57
34
  </div>
@@ -1,5 +1,3 @@
1
- @import('../../UI/Button')
2
-
3
1
  <script client>
4
2
  type AlertType = 'info' | 'warning' | 'danger' | 'success' | 'error'
5
3
 
@@ -10,7 +8,6 @@ const liveConfirmationText = useReactiveProp('confirmationText', 'Confirm')
10
8
  const liveAbortText = useReactiveProp('abortText', 'Cancel')
11
9
  const liveShow = useReactiveProp('show', true)
12
10
  const emit = defineEmits()
13
- const titleId = useId('alert-title')
14
11
 
15
12
  const normalizedType = derived(() => liveType() === 'danger' ? 'error' : liveType())
16
13
  const iconClass = derived(() => ({
@@ -32,47 +29,25 @@ function dismiss(event: 'close' | 'cancel' | 'confirm'): void {
32
29
  }
33
30
  </script>
34
31
 
35
- <div :if="liveShow()" class="flex fixed inset-0 z-50 items-end justify-center sm:items-center pb-6 px-4 sm:p-0 dashboard-modal-layer">
36
- <button
37
- type="button"
38
- class="fixed inset-0 bg-gray-500/75 transition-opacity"
39
- aria-label="Close alert"
40
- @click="dismiss('close')"
41
- ></button>
42
-
43
- <div
44
- class="overflow-y-auto relative pb-4 pt-5 px-4 sm:p-6 max-h-[32rem] max-w-md w-full md:max-w-xl bg-white dark:bg-neutral-700 rounded-lg shadow-xl transition-all"
45
- role="alertdialog"
46
- aria-modal="true"
47
- :aria-labelledby="titleId"
48
- >
49
- <Button
50
- type="button"
51
- variant="ghost"
52
- size="sm"
53
- iconOnly
54
- class="absolute right-4 top-4"
55
- ariaLabel="Close alert"
56
- @click="dismiss('close')"
32
+ <Modal
33
+ :isOpen="liveShow()"
34
+ :title="liveTitle()"
35
+ :description="liveDescription()"
36
+ role="alertdialog"
37
+ size="md"
38
+ @close="dismiss('close')"
39
+ >
40
+ <template #header-leading>
41
+ <div
42
+ class="flex items-center justify-center shrink-0 h-10 w-10 rounded-full"
43
+ :class="iconWrapperClass()"
57
44
  >
58
- <span aria-hidden="true" class="h-6 w-6 i-hugeicons-cancel-01"></span>
59
- </Button>
60
-
61
- <div class="sm:flex sm:items-start">
62
- <div
63
- class="flex items-center justify-center shrink-0 mx-auto sm:mx-0 h-12 w-12 sm:h-10 sm:w-10 rounded-full"
64
- :class="iconWrapperClass()"
65
- >
66
- <span aria-hidden="true" class="h-6 w-6" :class="iconClass()"></span>
67
- </div>
68
-
69
- <div class="mt-3 sm:ml-4 sm:mt-0 text-center sm:text-left">
70
- <h3 :id="titleId" class="font-medium leading-6 text-gray-900 text-lg dark:text-neutral-100" :text="liveTitle()"></h3>
71
- <p :if="liveDescription()" class="mt-2 text-gray-500 text-sm dark:text-neutral-400" :text="liveDescription()"></p>
72
- </div>
45
+ <span aria-hidden="true" class="h-6 w-6" :class="iconClass()"></span>
73
46
  </div>
47
+ </template>
74
48
 
75
- <div class="sm:flex sm:flex-row-reverse mt-5 space-y-4 sm:mt-4 md:space-y-0">
49
+ <template #footer>
50
+ <div class="flex flex-row-reverse flex-wrap gap-3 items-center justify-end w-full">
76
51
  <Button
77
52
  :if="normalizedType() === 'info'"
78
53
  type="button"
@@ -113,11 +88,10 @@ function dismiss(event: 'close' | 'cancel' | 'confirm'): void {
113
88
  :if="liveAbortText()"
114
89
  type="button"
115
90
  variant="secondary"
116
- class="mr-4"
117
91
  @click="dismiss('cancel')"
118
92
  >
119
93
  {{ liveAbortText() }}
120
94
  </Button>
121
95
  </div>
122
- </div>
123
- </div>
96
+ </template>
97
+ </Modal>
@@ -0,0 +1,16 @@
1
+ <script client>
2
+ import type { AuditOperationsResponse } from '../../../../functions/operations'
3
+ import { fetchAuditOperations } from '../../../../functions/operations'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+ const data = state<AuditOperationsResponse | null>(null)
6
+ const loading = state(true)
7
+ async function load(): Promise<void> { loading.set(true); try { data.set(await fetchAuditOperations()) } catch (error) { pushToast('error', 'Could not load audit log', { detail: String(error) }) } finally { loading.set(false) } }
8
+ function date(value: string): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)) }
9
+ onMount(() => void load())
10
+ </script>
11
+ <div class="space-y-6">
12
+ <PageHeader title="Operations" description="Inspect the append-only operator event stream with actor and operation correlation."><template #actions><Button variant="secondary" :loading="loading()" @click="load"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button></template></PageHeader>
13
+ <OperationsNavigation active="audit" />
14
+ <section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><StatsCard title="Events" :value="String(data()?.summary.events || 0)" icon="i-hugeicons-activity-03" iconBg="primary" :loading="loading()" /><StatsCard title="Operators" :value="String(data()?.summary.operators || 0)" icon="i-hugeicons-user-group" iconBg="neutral" :loading="loading()" /><StatsCard title="Failures" :value="String(data()?.summary.failures || 0)" icon="i-hugeicons-alert-02" iconBg="warning" :loading="loading()" /><StatsCard title="Latest sequence" :value="String(data()?.summary.latestSequence || 0)" icon="i-hugeicons-list-view" iconBg="success" :loading="loading()" /></section>
15
+ <section class="overflow-hidden bg-white dark:bg-neutral-900 border border-gray-200 rounded-lg dark:border-neutral-800"><div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-800"><h2 class="font-semibold text-gray-900 dark:text-white">Operator events</h2><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Events are sequence ordered and retained in the ts-cloud control plane.</p></div><div :if="!loading() && !data()?.events.length" class="px-6 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">No operator events have been recorded.</div><div class="max-h-[42rem] overflow-auto divide-y divide-gray-100 dark:divide-neutral-800"><div :for="event in data()?.events || []" class="grid gap-2 px-4 py-3 md:grid-cols-[5rem_1fr_10rem_12rem]"><span class="font-mono text-gray-400 text-xs">#{{ event.sequence }}</span><div><p class="font-medium text-gray-900 text-sm dark:text-white">{{ event.type }}</p><p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ event.operationId || event.id }}</p></div><span class="text-gray-600 text-sm dark:text-neutral-300">{{ event.actorName }}</span><time class="text-gray-500 text-xs dark:text-neutral-400">{{ date(event.createdAt) }}</time></div></div></section>
16
+ </div>