@stacksjs/defaults 0.75.263 → 0.75.266
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/ai/skills/stacks-browse/SKILL.md +3 -0
- package/ai/skills/stacks-browse/scripts/browse.ts +73 -14
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +4 -4
- package/resources/components/Dashboard/App/RequestsOverview.stx +75 -58
- package/resources/components/Dashboard/App/SourceInventory.stx +68 -59
- package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
- package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
- package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
- package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
- package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
- package/resources/components/Dashboard/Infrastructure/DnsDashboard.stx +46 -28
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
- package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
- package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +35 -61
- package/resources/components/Dashboard/Teams/TeamPeopleDashboard.stx +127 -143
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script server>
|
|
2
4
|
import { publicApplicationUrl } from '../../../../functions/public-application-url'
|
|
3
5
|
|
|
@@ -102,57 +104,47 @@ defineClientPayload({ blogUrl })
|
|
|
102
104
|
</div>
|
|
103
105
|
|
|
104
106
|
<div x-show="!isLoading() && !loadError() && filteredPosts().length > 0" class="overflow-x-auto max-w-full w-full">
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</Button>
|
|
147
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + post.title" @click="askDelete(post)">
|
|
148
|
-
<span class="block h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-waste"></span>
|
|
149
|
-
</Button>
|
|
150
|
-
</div>
|
|
151
|
-
</td>
|
|
152
|
-
</tr>
|
|
153
|
-
</template>
|
|
154
|
-
</tbody>
|
|
155
|
-
</table>
|
|
107
|
+
{{--
|
|
108
|
+
The page owns the search, the status filter and the loading, error
|
|
109
|
+
and empty states, so the table brings only sorting and paging. The
|
|
110
|
+
post is slotted for its featured mark; the actions are a slot for the
|
|
111
|
+
view link, which only a published post has.
|
|
112
|
+
--}}
|
|
113
|
+
<Table
|
|
114
|
+
:columns="POST_COLUMNS"
|
|
115
|
+
:data="postRows()"
|
|
116
|
+
row-key="slug"
|
|
117
|
+
paginated
|
|
118
|
+
:page-size="25"
|
|
119
|
+
>
|
|
120
|
+
<template #cell="{ row }">
|
|
121
|
+
<div class="flex gap-2 items-center">
|
|
122
|
+
<span class="font-medium" x-text="row.title"></span>
|
|
123
|
+
<span :if="row.featured" class="inline-flex px-1.5 py-0.5 font-medium text-[11px] text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded">Featured</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="mt-1 max-w-xl text-neutral-500 text-xs truncate dark:text-neutral-400" x-text="row.summary"></div>
|
|
126
|
+
</template>
|
|
127
|
+
<template #actions="{ row }">
|
|
128
|
+
<div class="flex gap-1 justify-end">
|
|
129
|
+
<a
|
|
130
|
+
:if="!row.draft"
|
|
131
|
+
:href="row.publicUrl"
|
|
132
|
+
target="_blank"
|
|
133
|
+
rel="noopener"
|
|
134
|
+
:title="'View ' + row.title"
|
|
135
|
+
class="p-2 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
|
|
136
|
+
>
|
|
137
|
+
<span class="block h-4 w-4 i-hugeicons-view"></span>
|
|
138
|
+
</a>
|
|
139
|
+
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + row.title" @click="openEditor(row.post)">
|
|
140
|
+
<span class="block h-4 w-4 i-hugeicons-edit-01"></span>
|
|
141
|
+
</Button>
|
|
142
|
+
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + row.title" @click="askDelete(row.post)">
|
|
143
|
+
<span class="block h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-waste"></span>
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
147
|
+
</Table>
|
|
156
148
|
</div>
|
|
157
149
|
|
|
158
150
|
<!-- Empty: distinguishes "no posts yet" from "nothing matched the filter". -->
|
|
@@ -272,22 +264,36 @@ const filteredPosts = derived(() => {
|
|
|
272
264
|
})
|
|
273
265
|
})
|
|
274
266
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
: '
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
267
|
+
const POST_COLUMNS = [
|
|
268
|
+
{ key: 'title', label: 'Post', sortable: true, slotted: true },
|
|
269
|
+
{
|
|
270
|
+
key: 'status',
|
|
271
|
+
label: 'Status',
|
|
272
|
+
type: 'badge' as const,
|
|
273
|
+
sortable: true,
|
|
274
|
+
badgeClasses: {
|
|
275
|
+
draft: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
|
|
276
|
+
published: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
|
|
277
|
+
},
|
|
278
|
+
badgeLabels: { draft: 'Draft', published: 'Published' },
|
|
279
|
+
},
|
|
280
|
+
{ key: 'date', label: 'Date', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'text-neutral-600 dark:text-neutral-300' },
|
|
281
|
+
{ key: 'lengthLabel', label: 'Length', searchable: false, class: 'text-neutral-600 dark:text-neutral-300' },
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
/** What the table reads; `post` is the record the edit and delete actions take. */
|
|
285
|
+
const postRows = derived(() => filteredPosts().map(post => ({
|
|
286
|
+
slug: post.slug,
|
|
287
|
+
title: post.title,
|
|
288
|
+
featured: post.featured,
|
|
289
|
+
draft: post.draft,
|
|
290
|
+
summary: post.description || post.slug,
|
|
291
|
+
status: post.draft ? 'draft' : 'published',
|
|
292
|
+
date: post.date,
|
|
293
|
+
lengthLabel: `${post.readingTime} min read`,
|
|
294
|
+
publicUrl: publicPostUrl(post.slug),
|
|
295
|
+
post,
|
|
296
|
+
})))
|
|
291
297
|
|
|
292
298
|
function clearFilters() {
|
|
293
299
|
searchQuery.set('')
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
3
5
|
import { pushToast } from '../../../../functions/toasts'
|
|
@@ -214,14 +216,22 @@ function formatFileSize(bytes: number | null): string {
|
|
|
214
216
|
return `${Number.parseFloat((bytes / 1024 ** index).toFixed(2))} ${units[index]}`
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const date
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
const FILE_COLUMNS = [
|
|
220
|
+
{ key: 'name', label: 'Name', sortable: true, slotted: true },
|
|
221
|
+
{ key: 'size', label: 'Size', sortable: true, slotted: true, searchable: false },
|
|
222
|
+
{ key: 'lastModified', label: 'Modified', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'text-sm text-stone-500 dark:text-neutral-400' },
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
/** What the list view's table reads; `item` is what the click and delete handlers take. */
|
|
226
|
+
const fileRows = derived(() => filteredItems().map(item => ({
|
|
227
|
+
id: item.id,
|
|
228
|
+
name: item.name,
|
|
229
|
+
size: item.type === 'folder' ? -1 : item.size,
|
|
230
|
+
sizeLabel: item.type === 'folder' ? '-' : formatFileSize(item.size),
|
|
231
|
+
lastModified: item.lastModified,
|
|
232
|
+
iconClass: itemIconClass(item),
|
|
233
|
+
item,
|
|
234
|
+
})))
|
|
225
235
|
|
|
226
236
|
async function loadFiles(options: { resetPath?: boolean } = {}): Promise<void> {
|
|
227
237
|
loading.set(true)
|
|
@@ -505,10 +515,27 @@ onMount(() => { void loadFiles() })
|
|
|
505
515
|
</template>
|
|
506
516
|
</div>
|
|
507
517
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
518
|
+
{{--
|
|
519
|
+
The list view. The page owns search, the folder path and the loading
|
|
520
|
+
and empty states; the table brings sorting and paging, which a large
|
|
521
|
+
folder had none of. Name is slotted for its icon and the click that
|
|
522
|
+
opens a folder, and size is slotted so it reads as a size while
|
|
523
|
+
sorting on the byte count underneath.
|
|
524
|
+
--}}
|
|
525
|
+
<div :else>
|
|
526
|
+
<Table :columns="FILE_COLUMNS" :data="fileRows()" paginated :page-size="50" empty-title="No items" empty-description="Nothing to show in this folder.">
|
|
527
|
+
<template #cell="{ row, column }">
|
|
528
|
+
<template :if="column.key === 'name'">
|
|
529
|
+
<button type="button" class="flex items-center max-w-md text-left" @click="onItemClick(row.item)"><span :class="row.iconClass"></span><span class="font-medium text-sm text-stone-900 truncate dark:text-white" x-text="row.name"></span></button>
|
|
530
|
+
</template>
|
|
531
|
+
<template :else>
|
|
532
|
+
<span class="text-sm text-stone-500 dark:text-neutral-400" x-text="row.sizeLabel"></span>
|
|
533
|
+
</template>
|
|
534
|
+
</template>
|
|
535
|
+
<template #actions="{ row }">
|
|
536
|
+
<div class="text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + row.name" @click="openDelete(row.item, $event)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div>
|
|
537
|
+
</template>
|
|
538
|
+
</Table>
|
|
512
539
|
</div>
|
|
513
540
|
</div>
|
|
514
541
|
</div>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type {
|
|
3
5
|
DashboardActivity,
|
|
@@ -53,10 +55,6 @@ const filteredRecords = derived(() => {
|
|
|
53
55
|
return records().filter(record => searchableText(record).includes(query))
|
|
54
56
|
})
|
|
55
57
|
|
|
56
|
-
const activityRecords = derived(() => filteredRecords() as DashboardActivity[])
|
|
57
|
-
const userRecords = derived(() => filteredRecords() as DashboardUser[])
|
|
58
|
-
const teamRecords = derived(() => filteredRecords() as DashboardTeam[])
|
|
59
|
-
const subscriberRecords = derived(() => filteredRecords() as DashboardSubscriber[])
|
|
60
58
|
|
|
61
59
|
function searchableText(record: DataRecord): string {
|
|
62
60
|
return Object.values(record)
|
|
@@ -69,27 +67,78 @@ function statValue(key: string): number {
|
|
|
69
67
|
return Number(stats()[key] || 0)
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
function formatDate(value: string): string {
|
|
73
|
-
if (!value)
|
|
74
|
-
return 'Never'
|
|
75
|
-
const date = new Date(value)
|
|
76
|
-
return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString()
|
|
77
|
-
}
|
|
78
|
-
|
|
79
70
|
function formatSubject(record: DashboardActivity): string {
|
|
80
71
|
if (!record.subjectType)
|
|
81
72
|
return 'Application'
|
|
82
73
|
return record.subjectId > 0 ? `${record.subjectType} #${record.subjectId}` : record.subjectType
|
|
83
74
|
}
|
|
84
75
|
|
|
85
|
-
|
|
76
|
+
/** A status pill's colour: settled, ended, or still in progress. */
|
|
77
|
+
function statusTone(status: string): string {
|
|
86
78
|
if (['active', 'subscribed', 'completed'].includes(status))
|
|
87
|
-
return '
|
|
79
|
+
return 'positive'
|
|
88
80
|
if (['bounced', 'inactive', 'unsubscribed'].includes(status))
|
|
89
|
-
return '
|
|
90
|
-
return '
|
|
81
|
+
return 'negative'
|
|
82
|
+
return 'pending'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const TONES = {
|
|
86
|
+
positive: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
87
|
+
negative: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
|
|
88
|
+
pending: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function capitalize(value: string): string {
|
|
92
|
+
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const recorded = (key: string, label: string) => ({ key, label, type: 'datetime' as const, align: 'right' as const, sortable: true, emptyText: 'Never', class: 'text-neutral-500 text-xs whitespace-nowrap dark:text-neutral-400' })
|
|
96
|
+
const statusColumn = { key: 'statusTone', label: 'Status', type: 'badge' as const, sortable: true, badgeLabelKey: 'statusLabel', badgeClasses: TONES }
|
|
97
|
+
|
|
98
|
+
const COLUMNS_BY_MODE = {
|
|
99
|
+
activity: [
|
|
100
|
+
{ key: 'description', label: 'Activity', sortable: true, subtitleKey: 'type', class: 'font-medium text-neutral-900 dark:text-white' },
|
|
101
|
+
{ key: 'subject', label: 'Subject', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
|
|
102
|
+
{ key: 'causer', label: 'Causer', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
|
|
103
|
+
recorded('createdAt', 'Recorded'),
|
|
104
|
+
],
|
|
105
|
+
users: [
|
|
106
|
+
{ key: 'name', label: 'User', sortable: true, subtitleKey: 'email', class: 'font-medium text-neutral-900 dark:text-white' },
|
|
107
|
+
{ key: 'verificationTone', label: 'Verification', type: 'badge' as const, sortable: true, badgeLabelKey: 'verificationLabel', badgeClasses: TONES },
|
|
108
|
+
recorded('updatedAt', 'Updated'),
|
|
109
|
+
recorded('createdAt', 'Registered'),
|
|
110
|
+
],
|
|
111
|
+
teams: [
|
|
112
|
+
{ key: 'name', label: 'Team', sortable: true, slotted: true },
|
|
113
|
+
{ key: 'memberCount', label: 'Members', type: 'number' as const, align: 'right' as const, sortable: true, class: 'font-mono text-neutral-700 dark:text-neutral-300' },
|
|
114
|
+
statusColumn,
|
|
115
|
+
recorded('createdAt', 'Created'),
|
|
116
|
+
],
|
|
117
|
+
subscribers: [
|
|
118
|
+
{ key: 'email', label: 'Subscriber', sortable: true, class: 'font-medium text-neutral-900 dark:text-white' },
|
|
119
|
+
{ key: 'sourceLabel', label: 'Source', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
|
|
120
|
+
statusColumn,
|
|
121
|
+
recorded('createdAt', 'Subscribed'),
|
|
122
|
+
],
|
|
91
123
|
}
|
|
92
124
|
|
|
125
|
+
const recordColumns = derived(() => COLUMNS_BY_MODE[mode()] ?? COLUMNS_BY_MODE.activity)
|
|
126
|
+
|
|
127
|
+
const recordRows = derived(() => filteredRecords().map((record) => {
|
|
128
|
+
const row = record as Record<string, any>
|
|
129
|
+
return {
|
|
130
|
+
...row,
|
|
131
|
+
subject: mode() === 'activity' ? formatSubject(record as DashboardActivity) : undefined,
|
|
132
|
+
verificationTone: row.emailVerifiedAt ? 'positive' : 'pending',
|
|
133
|
+
verificationLabel: row.emailVerifiedAt ? 'Verified' : 'Unverified',
|
|
134
|
+
statusTone: statusTone(String(row.status ?? '')),
|
|
135
|
+
statusLabel: capitalize(String(row.status ?? '')),
|
|
136
|
+
sourceLabel: capitalize(String(row.source ?? '')),
|
|
137
|
+
descriptionLabel: row.description || 'No description',
|
|
138
|
+
modelLink: `/models/team?q=${encodeURIComponent(String(row.name ?? ''))}`,
|
|
139
|
+
}
|
|
140
|
+
}))
|
|
141
|
+
|
|
93
142
|
async function loadRecords(): Promise<void> {
|
|
94
143
|
loading.set(true)
|
|
95
144
|
loadError.set('')
|
|
@@ -202,80 +251,28 @@ onMount(() => {
|
|
|
202
251
|
|
|
203
252
|
<EmptyState :if="!loading() && !loadError() && filteredRecords().length === 0" :icon="page().icon" :title="search() ? 'No matching records' : page().empty" :description="search() ? 'Try a different search term.' : 'New records will appear here automatically.'" />
|
|
204
253
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Team</th>
|
|
222
|
-
<th class="px-4 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Members</th>
|
|
223
|
-
<th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
|
|
224
|
-
<th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Created</th>
|
|
225
|
-
</tr>
|
|
226
|
-
<tr :if="mode() === 'subscribers'">
|
|
227
|
-
<th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Subscriber</th>
|
|
228
|
-
<th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Source</th>
|
|
229
|
-
<th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
|
|
230
|
-
<th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Subscribed</th>
|
|
231
|
-
</tr>
|
|
232
|
-
</thead>
|
|
233
|
-
<tbody class="divide-neutral-100 divide-y dark:divide-neutral-700">
|
|
234
|
-
<template :if="mode() === 'activity'" :for="record in activityRecords()">
|
|
235
|
-
<tr>
|
|
236
|
-
<td class="pl-5 pr-4 py-3.5">
|
|
237
|
-
<p class="font-medium text-neutral-900 text-sm dark:text-white">{{ record.description }}</p>
|
|
238
|
-
<p class="mt-0.5 font-mono text-neutral-500 text-xs dark:text-neutral-400">{{ record.type }}</p>
|
|
239
|
-
</td>
|
|
240
|
-
<td class="px-4 py-3.5 text-neutral-600 text-sm dark:text-neutral-300">{{ formatSubject(record) }}</td>
|
|
241
|
-
<td class="px-4 py-3.5 text-neutral-600 text-sm dark:text-neutral-300">{{ record.causer }}</td>
|
|
242
|
-
<td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
|
|
243
|
-
</tr>
|
|
244
|
-
</template>
|
|
245
|
-
<template :if="mode() === 'users'" :for="record in userRecords()">
|
|
246
|
-
<tr>
|
|
247
|
-
<td class="pl-5 pr-4 py-3.5">
|
|
248
|
-
<p class="font-medium text-neutral-900 text-sm dark:text-white">{{ record.name }}</p>
|
|
249
|
-
<p class="mt-0.5 text-neutral-500 text-xs dark:text-neutral-400">{{ record.email }}</p>
|
|
250
|
-
</td>
|
|
251
|
-
<td class="px-4 py-3.5">
|
|
252
|
-
<span :class="'inline-flex px-2 py-0.5 font-medium text-xs rounded ' + (record.emailVerifiedAt ? statusClass('active') : statusClass('pending'))">{{ record.emailVerifiedAt ? 'Verified' : 'Unverified' }}</span>
|
|
253
|
-
</td>
|
|
254
|
-
<td class="px-4 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.updatedAt) }}</td>
|
|
255
|
-
<td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
|
|
256
|
-
</tr>
|
|
257
|
-
</template>
|
|
258
|
-
<template :if="mode() === 'teams'" :for="record in teamRecords()">
|
|
259
|
-
<tr>
|
|
260
|
-
<td class="pl-5 pr-4 py-3.5">
|
|
261
|
-
<StxLink :to="'/models/team?q=' + encodeURIComponent(record.name)" class="font-medium text-neutral-900 text-sm dark:text-white hover:text-blue-600 dark:hover:text-blue-400">{{ record.name }}</StxLink>
|
|
262
|
-
<p class="mt-0.5 max-w-xl text-neutral-500 text-xs dark:text-neutral-400">{{ record.description || 'No description' }}</p>
|
|
263
|
-
</td>
|
|
264
|
-
<td class="px-4 py-3.5 font-mono text-neutral-700 text-right text-sm dark:text-neutral-300">{{ record.memberCount }}</td>
|
|
265
|
-
<td class="px-4 py-3.5"><span :class="'inline-flex px-2 py-0.5 font-medium text-xs capitalize rounded ' + statusClass(record.status)">{{ record.status }}</span></td>
|
|
266
|
-
<td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
|
|
267
|
-
</tr>
|
|
268
|
-
</template>
|
|
269
|
-
<template :if="mode() === 'subscribers'" :for="record in subscriberRecords()">
|
|
270
|
-
<tr>
|
|
271
|
-
<td class="pl-5 pr-4 py-3.5 font-medium text-neutral-900 text-sm dark:text-white">{{ record.email }}</td>
|
|
272
|
-
<td class="px-4 py-3.5 capitalize text-neutral-600 text-sm dark:text-neutral-300">{{ record.source }}</td>
|
|
273
|
-
<td class="px-4 py-3.5"><span :class="'inline-flex px-2 py-0.5 font-medium text-xs capitalize rounded ' + statusClass(record.status)">{{ record.status }}</span></td>
|
|
274
|
-
<td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
|
|
275
|
-
</tr>
|
|
254
|
+
{{--
|
|
255
|
+
Four column sets: the mode decides which. The page keeps its search,
|
|
256
|
+
loading and empty states; the table brings sorting and paging. Only a
|
|
257
|
+
team's name is slotted, for the link to its model page.
|
|
258
|
+
--}}
|
|
259
|
+
<div :if="!loading() && filteredRecords().length > 0">
|
|
260
|
+
{{--
|
|
261
|
+
Two tables rather than one with a slot: Table hands every cell to a
|
|
262
|
+
given cell slot when no column is slotted, so the slot can only be
|
|
263
|
+
passed in the mode that has a slotted column.
|
|
264
|
+
--}}
|
|
265
|
+
<template :if="mode() === 'teams'">
|
|
266
|
+
<Table :columns="recordColumns()" :data="recordRows()" paginated :page-size="25">
|
|
267
|
+
<template #cell="{ row }">
|
|
268
|
+
<StxLink :to="row.modelLink" class="font-medium text-neutral-900 text-sm dark:text-white hover:text-blue-600 dark:hover:text-blue-400">{{ row.name }}</StxLink>
|
|
269
|
+
<p class="mt-0.5 max-w-xl text-neutral-500 text-xs dark:text-neutral-400" x-text="row.descriptionLabel"></p>
|
|
276
270
|
</template>
|
|
277
|
-
</
|
|
278
|
-
</
|
|
271
|
+
</Table>
|
|
272
|
+
</template>
|
|
273
|
+
<template :else>
|
|
274
|
+
<Table :columns="recordColumns()" :data="recordRows()" paginated :page-size="25" />
|
|
275
|
+
</template>
|
|
279
276
|
</div>
|
|
280
277
|
</Card>
|
|
281
278
|
</div>
|
|
@@ -1,22 +1,11 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { DeploymentRecord } from '../../../../functions/deployments'
|
|
3
5
|
|
|
4
6
|
const deployments = useReactiveProp('deployments', [])
|
|
5
7
|
const loading = useReactiveProp('loading', false)
|
|
6
8
|
|
|
7
|
-
function rows(): DeploymentRecord[] {
|
|
8
|
-
return deployments() as DeploymentRecord[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function formatDate(value: string): string {
|
|
12
|
-
if (!value)
|
|
13
|
-
return 'Not recorded'
|
|
14
|
-
const date = new Date(value)
|
|
15
|
-
return Number.isNaN(date.getTime())
|
|
16
|
-
? value
|
|
17
|
-
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
9
|
function formatDuration(value: number | null): string {
|
|
21
10
|
if (value === null)
|
|
22
11
|
return '-'
|
|
@@ -27,61 +16,57 @@ function formatDuration(value: number | null): string {
|
|
|
27
16
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
|
28
17
|
}
|
|
29
18
|
|
|
30
|
-
function
|
|
19
|
+
function statusTone(status: string): string {
|
|
31
20
|
if (['success', 'successful', 'completed', 'ready'].includes(status))
|
|
32
|
-
return '
|
|
21
|
+
return 'succeeded'
|
|
33
22
|
if (['failed', 'error', 'cancelled'].includes(status))
|
|
34
|
-
return '
|
|
23
|
+
return 'failed'
|
|
35
24
|
if (['pending', 'queued', 'running', 'deploying'].includes(status))
|
|
36
|
-
return '
|
|
37
|
-
return '
|
|
25
|
+
return 'running'
|
|
26
|
+
return 'other'
|
|
38
27
|
}
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
const COLUMNS = [
|
|
30
|
+
{ key: 'commitLabel', label: 'Commit', sortable: true, subtitleKey: 'commitDetail', class: 'font-medium text-gray-900 dark:text-white' },
|
|
31
|
+
{ key: 'environment', label: 'Environment', sortable: true, emptyText: 'Not recorded' },
|
|
32
|
+
{
|
|
33
|
+
key: 'statusTone',
|
|
34
|
+
label: 'Status',
|
|
35
|
+
type: 'badge' as const,
|
|
36
|
+
sortable: true,
|
|
37
|
+
badgeLabelKey: 'statusLabel',
|
|
38
|
+
badgeClasses: {
|
|
39
|
+
succeeded: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
40
|
+
failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
|
|
41
|
+
running: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{ key: 'durationLabel', label: 'Duration', align: 'right' as const, searchable: false, class: 'font-mono' },
|
|
45
|
+
{ key: 'createdAt', label: 'Created', type: 'datetime' as const, align: 'right' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const rows = derived(() => (deployments() as DeploymentRecord[]).map(deployment => ({
|
|
49
|
+
...deployment,
|
|
50
|
+
commitLabel: deployment.commitMessage || 'No commit message',
|
|
51
|
+
commitDetail: `${deployment.commitHash || 'No commit hash'} · ${deployment.branch || 'No branch'}`,
|
|
52
|
+
statusTone: statusTone(deployment.status),
|
|
53
|
+
statusLabel: deployment.status ? deployment.status.charAt(0).toUpperCase() + deployment.status.slice(1) : 'Unknown',
|
|
54
|
+
durationLabel: formatDuration(deployment.duration),
|
|
55
|
+
})))
|
|
45
56
|
</script>
|
|
46
57
|
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Created</th>
|
|
63
|
-
<th class="px-4 py-3"><span class="sr-only">Actions</span></th>
|
|
64
|
-
</tr>
|
|
65
|
-
</thead>
|
|
66
|
-
<tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
67
|
-
<template :for="deployment in rows()">
|
|
68
|
-
<tr>
|
|
69
|
-
<td class="px-4 py-3">
|
|
70
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ deployment.commitMessage || 'No commit message' }}</p>
|
|
71
|
-
<p class="mt-0.5 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ deployment.commitHash || 'No commit hash' }} · {{ deployment.branch || 'No branch' }}</p>
|
|
72
|
-
</td>
|
|
73
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ deployment.environment || 'Not recorded' }}</td>
|
|
74
|
-
<td class="px-4 py-3">
|
|
75
|
-
<span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-md ' + statusClass(deployment.status)">{{ statusLabel(deployment.status) }}</span>
|
|
76
|
-
</td>
|
|
77
|
-
<td class="px-4 py-3 font-mono text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatDuration(deployment.duration) }}</td>
|
|
78
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(deployment.createdAt) }}</td>
|
|
79
|
-
<td class="px-4 py-3 text-right">
|
|
80
|
-
<StxLink :to="'/deployments/' + deployment.id" class="font-medium text-blue-600 text-sm dark:text-blue-400 hover:text-blue-500">View</StxLink>
|
|
81
|
-
</td>
|
|
82
|
-
</tr>
|
|
83
|
-
</template>
|
|
84
|
-
</tbody>
|
|
85
|
-
</table>
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
58
|
+
<Table
|
|
59
|
+
:columns="COLUMNS"
|
|
60
|
+
:data="rows()"
|
|
61
|
+
:loading="loading()"
|
|
62
|
+
paginated
|
|
63
|
+
:page-size="25"
|
|
64
|
+
empty-title="No deployment records found"
|
|
65
|
+
empty-description="Records appear when a deployment integration writes to the Deployment model."
|
|
66
|
+
>
|
|
67
|
+
<template #actions="{ row }">
|
|
68
|
+
<div class="text-right">
|
|
69
|
+
<StxLink :to="'/deployments/' + row.id" class="font-medium text-blue-600 text-sm dark:text-blue-400 hover:text-blue-500">View</StxLink>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
</Table>
|