@stacksjs/defaults 0.75.263 → 0.75.267
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-chat/SKILL.md +32 -0
- package/ai/skills/stacks-native/CATALOG.md +8 -8
- 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 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>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type {
|
|
3
5
|
DashboardDnsDomain,
|
|
@@ -61,10 +63,40 @@ function typeColor(type: string): string {
|
|
|
61
63
|
return 'bg-rose-500'
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
const TYPE_BADGE = 'font-mono font-semibold text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40'
|
|
67
|
+
|
|
68
|
+
const COLUMNS = [
|
|
69
|
+
{
|
|
70
|
+
key: 'type',
|
|
71
|
+
label: 'Type',
|
|
72
|
+
type: 'badge' as const,
|
|
73
|
+
sortable: true,
|
|
74
|
+
badgeClasses: { A: TYPE_BADGE, AAAA: TYPE_BADGE, CNAME: TYPE_BADGE, MX: TYPE_BADGE, TXT: TYPE_BADGE },
|
|
75
|
+
},
|
|
76
|
+
{ key: 'name', label: 'Name', sortable: true, class: 'font-mono text-neutral-900 whitespace-nowrap dark:text-white' },
|
|
77
|
+
{ key: 'value', label: 'Value', sortable: true, emptyText: 'Not set', class: 'max-w-md font-mono truncate' },
|
|
78
|
+
{ key: 'ttlLabel', label: 'TTL', class: 'whitespace-nowrap' },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const ROW_ACTIONS = [
|
|
82
|
+
{ action: 'inspect', label: 'Inspect', icon: 'i-hugeicons-view' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const recordRows = derived(() => filteredRecords().map((record, index) => ({
|
|
86
|
+
...record,
|
|
87
|
+
rowId: `${record.type}:${record.name}:${index}`,
|
|
88
|
+
ttlLabel: ttlLabel(record),
|
|
89
|
+
})))
|
|
90
|
+
|
|
64
91
|
function inspectRecord(record: DashboardDnsRecord): void {
|
|
65
92
|
selectedRecord.set(record)
|
|
66
93
|
}
|
|
67
94
|
|
|
95
|
+
function handleRowAction(payload: { action: string, row: DashboardDnsRecord }): void {
|
|
96
|
+
if (payload.action === 'inspect')
|
|
97
|
+
inspectRecord(payload.row)
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
function closeRecord(): void {
|
|
69
101
|
selectedRecord.set(null)
|
|
70
102
|
}
|
|
@@ -202,34 +234,20 @@ onMount(() => {
|
|
|
202
234
|
</div>
|
|
203
235
|
</div>
|
|
204
236
|
|
|
205
|
-
<div
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<th scope="col" class="px-5 py-3 font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400">TTL</th>
|
|
220
|
-
<th scope="col" class="px-5 py-3"><span class="sr-only">Actions</span></th>
|
|
221
|
-
</tr>
|
|
222
|
-
</thead>
|
|
223
|
-
<tbody class="divide-neutral-100 divide-y dark:divide-neutral-800">
|
|
224
|
-
<tr :for="record in filteredRecords()" class="hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
|
|
225
|
-
<td class="px-5 py-4"><span class="inline-flex px-2 py-1 font-mono font-semibold text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md">{{ record.type }}</span></td>
|
|
226
|
-
<td class="px-5 py-4 font-mono text-neutral-900 text-sm whitespace-nowrap dark:text-white">{{ record.name }}</td>
|
|
227
|
-
<td class="px-5 py-4 max-w-md font-mono text-neutral-500 text-sm truncate dark:text-neutral-400">{{ record.value || 'Not set' }}</td>
|
|
228
|
-
<td class="px-5 py-4 text-neutral-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ ttlLabel(record) }}</td>
|
|
229
|
-
<td class="px-5 py-4 text-right"><Button variant="ghost" size="sm" @click="inspectRecord(record)">Inspect</Button></td>
|
|
230
|
-
</tr>
|
|
231
|
-
</tbody>
|
|
232
|
-
</table>
|
|
237
|
+
<div class="mt-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-700">
|
|
238
|
+
<Table
|
|
239
|
+
:columns="COLUMNS"
|
|
240
|
+
:data="recordRows()"
|
|
241
|
+
:loading="loading()"
|
|
242
|
+
:row-actions="ROW_ACTIONS"
|
|
243
|
+
action-label-key="name"
|
|
244
|
+
row-key="rowId"
|
|
245
|
+
paginated
|
|
246
|
+
:page-size="25"
|
|
247
|
+
:empty-title="records().length ? 'No matching DNS records' : 'No DNS records configured'"
|
|
248
|
+
:empty-description="records().length ? 'Adjust the search or record type filter.' : 'Add records to config/dns.ts, then refresh this page.'"
|
|
249
|
+
@row-action="handleRowAction"
|
|
250
|
+
/>
|
|
233
251
|
</div>
|
|
234
252
|
</section>
|
|
235
253
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { RecoveryPoint, RecoveryPolicy } from '../../../../functions/operations'
|
|
3
5
|
|
|
@@ -6,17 +8,8 @@ const policyItems = useReactiveProp('policies', [])
|
|
|
6
8
|
const busyId = useReactiveProp('busyId', '')
|
|
7
9
|
const emit = defineEmits()
|
|
8
10
|
|
|
9
|
-
function points(): RecoveryPoint[] { return pointItems() as RecoveryPoint[] }
|
|
10
|
-
function policies(): RecoveryPolicy[] { return policyItems() as RecoveryPolicy[] }
|
|
11
|
-
|
|
12
11
|
function policyName(id?: string): string {
|
|
13
|
-
return
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function formatDate(value?: string): string {
|
|
17
|
-
if (!value) return 'Not recorded'
|
|
18
|
-
const date = new Date(value)
|
|
19
|
-
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
12
|
+
return (policyItems() as RecoveryPolicy[]).find(policy => policy.id === id)?.name || 'Manual recovery point'
|
|
20
13
|
}
|
|
21
14
|
|
|
22
15
|
function formatBytes(value: number): string {
|
|
@@ -26,57 +19,75 @@ function formatBytes(value: number): string {
|
|
|
26
19
|
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
|
27
20
|
}
|
|
28
21
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (state === '
|
|
32
|
-
if (state === '
|
|
33
|
-
return '
|
|
22
|
+
/** The pill's colour: verified, failed in either way, in progress, or not yet. */
|
|
23
|
+
function verificationTone(state: RecoveryPoint['verificationState']): string {
|
|
24
|
+
if (state === 'verified') return 'verified'
|
|
25
|
+
if (state === 'corrupt' || state === 'failed') return 'failed'
|
|
26
|
+
if (state === 'verifying') return 'verifying'
|
|
27
|
+
return 'unverified'
|
|
34
28
|
}
|
|
29
|
+
|
|
30
|
+
/** Protection is slotted, for its two independent badges. */
|
|
31
|
+
const COLUMNS = [
|
|
32
|
+
{ key: 'policyName', label: 'Recovery point', sortable: true, subtitleKey: 'detail', class: 'font-medium text-gray-900 dark:text-white' },
|
|
33
|
+
{ key: 'pointInTime', label: 'Created', type: 'datetime' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
|
|
34
|
+
{
|
|
35
|
+
key: 'verificationTone',
|
|
36
|
+
label: 'Verification',
|
|
37
|
+
type: 'badge' as const,
|
|
38
|
+
sortable: true,
|
|
39
|
+
badgeLabelKey: 'verificationState',
|
|
40
|
+
badgeClasses: {
|
|
41
|
+
verified: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40',
|
|
42
|
+
failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
|
|
43
|
+
verifying: 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40',
|
|
44
|
+
unverified: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{ key: 'protection', label: 'Protection', slotted: true, searchable: false },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const rows = derived(() => (pointItems() as RecoveryPoint[]).map(point => ({
|
|
51
|
+
...point,
|
|
52
|
+
policyName: policyName(point.policyId),
|
|
53
|
+
detail: `${point.id} · ${point.kind} · ${formatBytes(point.sizeBytes)}`,
|
|
54
|
+
verificationTone: verificationTone(point.verificationState),
|
|
55
|
+
protection: [point.pinned ? 'Pinned' : '', point.held ? 'Held' : ''].filter(Boolean).join(', ') || 'Retention policy',
|
|
56
|
+
})))
|
|
35
57
|
</script>
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
</
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<Button size="sm" :disabled="busyId() !== '' || point.status !== 'available'" :loading="busyId() === point.id + ':verify'" @click="emit('verify', point.id)">Verify</Button>
|
|
73
|
-
<Button size="sm" variant="outline" :disabled="busyId() !== '' || point.status !== 'available' || point.verificationState !== 'verified'" @click="emit('restore', point)">Restore</Button>
|
|
74
|
-
<Button size="sm" variant="ghost" :disabled="busyId() !== ''" :loading="busyId() === point.id + ':protect'" @click="emit('protect', { id: point.id, pinned: !point.pinned })">{{ point.pinned ? 'Unpin' : 'Pin' }}</Button>
|
|
75
|
-
</div>
|
|
76
|
-
</td>
|
|
77
|
-
</tr>
|
|
78
|
-
</template>
|
|
79
|
-
</tbody>
|
|
80
|
-
</table>
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
59
|
+
{{--
|
|
60
|
+
The actions are a slot: text buttons whose disabled and loading state
|
|
61
|
+
depends on the row and on which request is in flight.
|
|
62
|
+
--}}
|
|
63
|
+
<Table
|
|
64
|
+
:columns="COLUMNS"
|
|
65
|
+
:data="rows()"
|
|
66
|
+
paginated
|
|
67
|
+
:page-size="25"
|
|
68
|
+
empty-title="No recovery points yet"
|
|
69
|
+
empty-description="Create and run a recovery policy to produce the first independently verifiable point."
|
|
70
|
+
>
|
|
71
|
+
<template #cell="{ row }">
|
|
72
|
+
<div class="flex flex-wrap gap-1.5">
|
|
73
|
+
{{-- A directive on a component inside a slot is not evaluated, so each
|
|
74
|
+
conditional component sits in a template that carries it. --}}
|
|
75
|
+
<template :if="row.pinned"><Badge variant="primary">Pinned</Badge></template>
|
|
76
|
+
<template :if="row.held"><Badge variant="warning">Held</Badge></template>
|
|
77
|
+
<span :if="!row.pinned && !row.held" class="text-gray-500 text-xs dark:text-neutral-400">Retention policy</span>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<template #actions="{ row }">
|
|
82
|
+
<div class="inline-flex gap-2 items-center justify-end w-full whitespace-nowrap">
|
|
83
|
+
<Button size="sm" :disabled="busyId() !== '' || row.status !== 'available'" :loading="busyId() === row.id + ':verify'" @click="emit('verify', row.id)">Verify</Button>
|
|
84
|
+
<Button size="sm" variant="outline" :disabled="busyId() !== '' || row.status !== 'available' || row.verificationState !== 'verified'" @click="emit('restore', row)">Restore</Button>
|
|
85
|
+
<template :if="row.pinned">
|
|
86
|
+
<Button size="sm" variant="ghost" :disabled="busyId() !== ''" :loading="busyId() === row.id + ':protect'" @click="emit('protect', { id: row.id, pinned: false })">Unpin</Button>
|
|
87
|
+
</template>
|
|
88
|
+
<template :else>
|
|
89
|
+
<Button size="sm" variant="ghost" :disabled="busyId() !== ''" :loading="busyId() === row.id + ':protect'" @click="emit('protect', { id: row.id, pinned: true })">Pin</Button>
|
|
90
|
+
</template>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
93
|
+
</Table>
|