@stacksjs/defaults 0.75.484 → 0.75.487
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-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +33 -2
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-error-handling/SKILL.md +8 -0
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/ai/skills/stacks-native/CATALOG.md +3 -3
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +2 -2
- package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +2 -13
- package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +5 -11
- package/app/Actions/Dashboard/Content/ContentDashboardAction.ts +1 -1
- package/app/Actions/Dashboard/Content/PostIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Content/post-input.ts +1 -1
- package/app/Actions/Dashboard/Jobs/JobDefinitionsAction.ts +26 -0
- package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -2
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +1 -11
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +9 -41
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +5 -7
- package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +6 -6
- package/app/Actions/Dashboard/Queue/queue-stats.ts +5 -1
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +47 -89
- package/app/Actions/Dashboard/Search/SearchEngineIndexAction.ts +59 -0
- package/app/Models/QueueWorker.ts +17 -0
- package/functions/jobs.ts +14 -0
- package/functions/search-engine.ts +13 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Jobs/JobDashboard.stx +24 -5
- package/resources/components/Dashboard/Queue/QueueDashboard.stx +2 -3
- package/resources/components/Dashboard/Search/SearchEngineDashboard.stx +58 -0
- package/resources/functions/dashboard/model-columns.ts +12 -0
- package/resources/functions/dashboard/sidebar.ts +4 -2
- package/routes/dashboard-api.ts +178 -166
- package/views/dashboard/search-engine/index.stx +8 -0
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { RequestInstance } from '@stacksjs/types'
|
|
2
2
|
import { Action } from '@stacksjs/actions/runtime'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { frameworkDefaultsRoot } from '@stacksjs/path'
|
|
3
|
+
import { getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
|
|
4
|
+
import { overridesReady } from '@stacksjs/config'
|
|
5
|
+
import { resolveModelSources } from '@stacksjs/database/model-sources'
|
|
6
|
+
import { hiddenModelColumns } from '../../../../resources/functions/dashboard/model-columns'
|
|
8
7
|
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
|
|
9
|
-
import { dashboardOperationalError } from '../dashboard-response'
|
|
8
|
+
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
|
|
10
9
|
|
|
11
10
|
interface SearchableModel {
|
|
12
11
|
name: string
|
|
13
12
|
table: string
|
|
14
13
|
slug: string
|
|
15
14
|
fields: string[]
|
|
15
|
+
primaryKey: string
|
|
16
|
+
model: any
|
|
16
17
|
icon: string
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -68,36 +69,6 @@ function isSafeIdentifier(value: string): boolean {
|
|
|
68
69
|
return /^[A-Z_][A-Z0-9_]*$/i.test(value) && value.length <= 64
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function modelFiles(root: string): string[] {
|
|
72
|
-
const files: string[] = []
|
|
73
|
-
|
|
74
|
-
function walk(directory: string): void {
|
|
75
|
-
let entries
|
|
76
|
-
try {
|
|
77
|
-
entries = readdirSync(directory, { withFileTypes: true })
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
81
|
-
return
|
|
82
|
-
throw new Error(`Could not scan searchable models in ${directory}: ${error instanceof Error ? error.message : String(error)}`)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
for (const entry of entries) {
|
|
86
|
-
const file = join(directory, entry.name)
|
|
87
|
-
if (entry.isDirectory()) {
|
|
88
|
-
walk(file)
|
|
89
|
-
continue
|
|
90
|
-
}
|
|
91
|
-
if (!['.ts', '.js'].includes(extname(entry.name))) continue
|
|
92
|
-
if (entry.name.startsWith('.') || entry.name.startsWith('_') || entry.name.startsWith('index')) continue
|
|
93
|
-
files.push(file)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
walk(root)
|
|
98
|
-
return files.sort()
|
|
99
|
-
}
|
|
100
|
-
|
|
101
72
|
function iconForModel(name: string): string {
|
|
102
73
|
const icons: Record<string, string> = {
|
|
103
74
|
User: 'i-hugeicons-user-group',
|
|
@@ -115,17 +86,11 @@ function iconForModel(name: string): string {
|
|
|
115
86
|
}
|
|
116
87
|
|
|
117
88
|
async function loadSearchableModels(): Promise<SearchableModel[]> {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const merged = new Map<string, string>()
|
|
121
|
-
|
|
122
|
-
for (const file of modelFiles(defaultsRoot))
|
|
123
|
-
merged.set(basename(file, extname(file)), file)
|
|
124
|
-
for (const file of modelFiles(userRoot))
|
|
125
|
-
merged.set(basename(file, extname(file)), file)
|
|
89
|
+
await overridesReady
|
|
90
|
+
const sources = resolveModelSources()?.models ?? []
|
|
126
91
|
|
|
127
92
|
const models: SearchableModel[] = []
|
|
128
|
-
for (const
|
|
93
|
+
for (const { name: fallbackName, file } of sources) {
|
|
129
94
|
try {
|
|
130
95
|
const module = await import(file)
|
|
131
96
|
const definition = await resolveDashboardModel(fallbackName, module.default ?? module)
|
|
@@ -134,9 +99,11 @@ async function loadSearchableModels(): Promise<SearchableModel[]> {
|
|
|
134
99
|
const search = definition.traits?.useSearch
|
|
135
100
|
if (!search || typeof search !== 'object' || !Array.isArray(search.searchable)) continue
|
|
136
101
|
|
|
102
|
+
const hidden = hiddenModelColumns(definition)
|
|
103
|
+
const stored = new Set(Object.keys(definition.attributes ?? {}).map(snakeCase))
|
|
137
104
|
const fields: string[] = (search.searchable as unknown[])
|
|
138
105
|
.map((field: unknown) => snakeCase(String(field)))
|
|
139
|
-
.filter((field: string) => isSafeIdentifier(field) && !HIDDEN_FIELDS.has(field))
|
|
106
|
+
.filter((field: string) => isSafeIdentifier(field) && !HIDDEN_FIELDS.has(field) && !hidden.has(field) && stored.has(field))
|
|
140
107
|
const table = String(definition.table || pluralize(snakeCase(name)))
|
|
141
108
|
if (fields.length === 0 || !isSafeIdentifier(table)) continue
|
|
142
109
|
|
|
@@ -145,6 +112,8 @@ async function loadSearchableModels(): Promise<SearchableModel[]> {
|
|
|
145
112
|
table,
|
|
146
113
|
slug: kebabCase(name),
|
|
147
114
|
fields: [...new Set(fields)],
|
|
115
|
+
primaryKey: String(definition.primaryKey || 'id'),
|
|
116
|
+
model: definition,
|
|
148
117
|
icon: iconForModel(name),
|
|
149
118
|
})
|
|
150
119
|
}
|
|
@@ -190,6 +159,28 @@ function resultSubtitle(model: SearchableModel, row: Record<string, unknown>, ti
|
|
|
190
159
|
return undefined
|
|
191
160
|
}
|
|
192
161
|
|
|
162
|
+
/** Use the configured ORM connection, including PostgreSQL, and select only approved fields. */
|
|
163
|
+
export async function searchModelRows(Model: any, fields: string[], primaryKey: string, term: string, limit = RESULTS_PER_MODEL): Promise<Array<Record<string, unknown>>> {
|
|
164
|
+
if (!isSafeIdentifier(primaryKey) || fields.some(field => !isSafeIdentifier(field)))
|
|
165
|
+
throw new TypeError('Search columns must be model identifiers.')
|
|
166
|
+
if (fields.length === 0) return []
|
|
167
|
+
const dialect = sqlHelpers(getDatabaseDialect())
|
|
168
|
+
const quote = (column: string) => dialect.isMysql ? `\`${column}\`` : `"${column}"`
|
|
169
|
+
const rows = await Model.query()
|
|
170
|
+
.select(...new Set([primaryKey, ...fields]))
|
|
171
|
+
.whereGroup((group: any) => {
|
|
172
|
+
for (const [index, column] of fields.entries()) {
|
|
173
|
+
const match = `LOWER(CAST(${quote(column)} AS ${dialect.isMysql ? 'CHAR' : 'TEXT'})) LIKE LOWER(?)`
|
|
174
|
+
group = index === 0 ? group.whereRaw(match, `%${term}%`) : group.orWhereRaw(match, `%${term}%`)
|
|
175
|
+
}
|
|
176
|
+
return group
|
|
177
|
+
})
|
|
178
|
+
.orderBy(primaryKey, 'asc')
|
|
179
|
+
.limit(limit)
|
|
180
|
+
.get()
|
|
181
|
+
return rows.map((row: any) => typeof row.toJSON === 'function' ? row.toJSON() : row.attributes ?? row)
|
|
182
|
+
}
|
|
183
|
+
|
|
193
184
|
export default new Action({
|
|
194
185
|
name: 'GlobalSearchAction',
|
|
195
186
|
description: 'Searches fields declared by model useSearch traits and groups matching records by model.',
|
|
@@ -202,52 +193,18 @@ export default new Action({
|
|
|
202
193
|
try {
|
|
203
194
|
const models = await catalog()
|
|
204
195
|
if (models.length === 0) return { results: {}, unavailable: [] }
|
|
205
|
-
const { Database } = await import('bun:sqlite')
|
|
206
|
-
const db = new Database(env.DB_DATABASE_PATH || 'database/stacks.sqlite', { readonly: true })
|
|
207
196
|
const results: Record<string, SearchResult[]> = {}
|
|
208
197
|
const unavailable: SearchUnavailable[] = []
|
|
209
198
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const columns = db.query(`PRAGMA table_info(${model.table})`).all() as Array<{ name: string }>
|
|
215
|
-
if (columns.length === 0) {
|
|
216
|
-
unavailable.push({
|
|
217
|
-
model: model.name,
|
|
218
|
-
reason: `Table ${model.table} has not been migrated.`,
|
|
219
|
-
})
|
|
220
|
-
continue
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const available = new Set(columns.map(column => column.name))
|
|
224
|
-
const fields = model.fields.filter(field => available.has(field))
|
|
225
|
-
if (fields.length === 0) {
|
|
226
|
-
unavailable.push({
|
|
227
|
-
model: model.name,
|
|
228
|
-
reason: 'Declared useSearch fields are not present in the migrated table.',
|
|
229
|
-
})
|
|
230
|
-
continue
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const selected = [...new Set(['id', ...fields])].filter(field => available.has(field))
|
|
234
|
-
const where = fields.map(field => `${field} LIKE ? COLLATE NOCASE`).join(' OR ')
|
|
235
|
-
const bindings = fields.map(() => `%${q}%`)
|
|
236
|
-
let rows: Array<Record<string, unknown>>
|
|
237
|
-
try {
|
|
238
|
-
rows = db
|
|
239
|
-
.query(`SELECT ${selected.join(', ')} FROM ${model.table} WHERE ${where} LIMIT ?`)
|
|
240
|
-
.all(...bindings, RESULTS_PER_MODEL) as Array<Record<string, unknown>>
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
throw new Error(`Could not search model ${model.name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
244
|
-
}
|
|
199
|
+
for (const model of models) {
|
|
200
|
+
if (Object.keys(results).length >= MAX_GROUPS) break
|
|
201
|
+
try {
|
|
202
|
+
const rows = await searchModelRows(model.model, model.fields, model.primaryKey, q, RESULTS_PER_MODEL)
|
|
245
203
|
if (rows.length === 0) continue
|
|
246
|
-
|
|
247
|
-
results[model.name] = rows.map((row, index) => {
|
|
204
|
+
results[model.name] = rows.map((row) => {
|
|
248
205
|
const title = resultTitle(model, row)
|
|
249
206
|
return {
|
|
250
|
-
id:
|
|
207
|
+
id: row[model.primaryKey] as string | number,
|
|
251
208
|
title,
|
|
252
209
|
subtitle: resultSubtitle(model, row, title),
|
|
253
210
|
href: `/models/${model.slug}?q=${encodeURIComponent(q)}`,
|
|
@@ -255,9 +212,10 @@ export default new Action({
|
|
|
255
212
|
}
|
|
256
213
|
})
|
|
257
214
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
215
|
+
catch (error) {
|
|
216
|
+
unavailable.push({ model: model.name, reason: 'Could not search model. Check its migrated schema and declared useSearch fields.' })
|
|
217
|
+
dashboardOperationalIssue(error, 'Could not search model.', `GlobalSearchAction.${model.name}`)
|
|
218
|
+
}
|
|
261
219
|
}
|
|
262
220
|
|
|
263
221
|
return { results, unavailable }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
2
|
+
import { overridesReady, searchEngine } from '@stacksjs/config'
|
|
3
|
+
import { resolveModelSources } from '@stacksjs/database/model-sources'
|
|
4
|
+
import { searchIndexName, settingsFromSearchTrait, useSearchEngine } from '@stacksjs/search-engine'
|
|
5
|
+
import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
|
|
6
|
+
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'Search Engine Inventory',
|
|
10
|
+
description: 'Inspect the configured search engine and indexes declared by application models.',
|
|
11
|
+
method: 'GET',
|
|
12
|
+
async handle() {
|
|
13
|
+
try {
|
|
14
|
+
await overridesReady
|
|
15
|
+
const models = []
|
|
16
|
+
for (const source of resolveModelSources()?.models ?? []) {
|
|
17
|
+
const module = await import(source.file)
|
|
18
|
+
const Model = await resolveDashboardModel(source.name, module.default ?? module)
|
|
19
|
+
const trait = Model?.traits?.useSearch
|
|
20
|
+
if (!trait) continue
|
|
21
|
+
const settings = settingsFromSearchTrait(typeof trait === 'object' ? trait : {})
|
|
22
|
+
models.push({
|
|
23
|
+
name: source.name,
|
|
24
|
+
index: searchIndexName(trait, Model.table),
|
|
25
|
+
searchable: settings.searchableAttributes ?? ['*'],
|
|
26
|
+
filterable: settings.filterableAttributes ?? [],
|
|
27
|
+
sortable: settings.sortableAttributes ?? [],
|
|
28
|
+
displayed: settings.displayedAttributes ?? ['*'],
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
let connected = false
|
|
32
|
+
let unavailable: string | null = null
|
|
33
|
+
let indexes: Array<{ name: string, documents: number | null }> = []
|
|
34
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
35
|
+
try {
|
|
36
|
+
const result = await Promise.race([
|
|
37
|
+
Promise.resolve(useSearchEngine().listAllIndexes()),
|
|
38
|
+
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error('Search engine inspection timed out.')), 8000) }),
|
|
39
|
+
])
|
|
40
|
+
if (!Array.isArray(result?.results)) throw new TypeError('Search index response must contain results.')
|
|
41
|
+
indexes = result.results.map(index => {
|
|
42
|
+
const value = index as unknown as Record<string, unknown>
|
|
43
|
+
return { name: String(value.uid), documents: typeof value.num_documents === 'number' ? value.num_documents : null }
|
|
44
|
+
})
|
|
45
|
+
connected = true
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
dashboardOperationalIssue(error, 'Search engine could not be reached.', 'SearchEngineIndexAction.provider')
|
|
49
|
+
unavailable = 'Search engine could not be reached. Check config/search-engine.ts, provider credentials, and the running service.'
|
|
50
|
+
}
|
|
51
|
+
finally { if (timeout) clearTimeout(timeout) }
|
|
52
|
+
models.sort((a, b) => a.name.localeCompare(b.name))
|
|
53
|
+
return { driver: searchEngine.driver, connected, unavailable, indexes, models }
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return dashboardOperationalError(error, 'Search engine inventory could not be loaded.', 'SearchEngineIndexAction')
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation/runtime'
|
|
3
|
+
|
|
4
|
+
/** Bounded worker telemetry, shared by queue workers and the dashboard process. */
|
|
5
|
+
export default defineModel({
|
|
6
|
+
name: 'QueueWorker',
|
|
7
|
+
table: 'queue_workers',
|
|
8
|
+
primaryKey: 'worker_id',
|
|
9
|
+
autoIncrement: false,
|
|
10
|
+
dashboard: { access: false },
|
|
11
|
+
indexes: [{ name: 'queue_workers_heartbeat_at_index', columns: ['heartbeat_at'] }],
|
|
12
|
+
attributes: {
|
|
13
|
+
worker_id: { required: true, fillable: true, validation: { rule: schema.string().required().max(255) } },
|
|
14
|
+
snapshot: { type: 'text', required: true, fillable: true, validation: { rule: schema.string().required() } },
|
|
15
|
+
heartbeat_at: { type: 'bigint', required: true, fillable: true, validation: { rule: schema.integer().required() } },
|
|
16
|
+
},
|
|
17
|
+
} as const)
|
package/functions/jobs.ts
CHANGED
|
@@ -145,3 +145,17 @@ export function useJobs() {
|
|
|
145
145
|
retryFailedJobs: retryAllFailedJobs,
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
export interface JobDefinition {
|
|
150
|
+
name: string
|
|
151
|
+
description: string
|
|
152
|
+
queue: string
|
|
153
|
+
tries: number
|
|
154
|
+
timeout: number | null
|
|
155
|
+
schedule: string
|
|
156
|
+
enabled: boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function fetchJobDefinitions(): Promise<{ data: JobDefinition[] }> {
|
|
160
|
+
return dashboardApi<{ data: JobDefinition[] }>('/api/dashboard/jobs/definitions')
|
|
161
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { dashboardApi } from './dashboard-api'
|
|
2
|
+
|
|
3
|
+
export interface SearchEngineInventory {
|
|
4
|
+
driver: string
|
|
5
|
+
connected: boolean
|
|
6
|
+
unavailable: string | null
|
|
7
|
+
indexes: Array<{ name: string, documents: number | null }>
|
|
8
|
+
models: Array<{ name: string, index: string, searchable: string[], filterable: string[], sortable: string[], displayed: string[] }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function fetchSearchEngineInventory(): Promise<SearchEngineInventory> {
|
|
12
|
+
return dashboardApi<SearchEngineInventory>('/api/dashboard/search-engine')
|
|
13
|
+
}
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.487",
|
|
6
6
|
"description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.487",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iconify-json/f7": "^1.2.2",
|
|
47
47
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
48
|
-
"@stacksjs/mobile": "0.75.
|
|
48
|
+
"@stacksjs/mobile": "0.75.487",
|
|
49
49
|
"@stacksjs/sanitizer": "0.2.807",
|
|
50
50
|
"@stacksjs/stx": "0.2.807",
|
|
51
51
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
|
-
import type { DashboardJob, JobStatsResponse } from '../../../../functions/jobs'
|
|
3
|
-
import { fetchJobs, fetchJobStats, retryJob } from '../../../../functions/jobs'
|
|
4
|
+
import type { DashboardJob, JobDefinition, JobStatsResponse } from '../../../../functions/jobs'
|
|
5
|
+
import { fetchJobDefinitions, fetchJobs, fetchJobStats, retryJob } from '../../../../functions/jobs'
|
|
4
6
|
import { pushToast } from '../../../../functions/toasts'
|
|
5
7
|
|
|
8
|
+
const definitions = state<JobDefinition[]>([])
|
|
9
|
+
const definitionColumns = [
|
|
10
|
+
{ key: 'name', label: 'Job', subtitleKey: 'description', sortable: true },
|
|
11
|
+
{ key: 'queue', label: 'Queue', sortable: true },
|
|
12
|
+
{ key: 'tries', label: 'Attempts', type: 'number' },
|
|
13
|
+
{ key: 'timeout', label: 'Timeout (seconds)', type: 'number', emptyText: 'Default' },
|
|
14
|
+
{ key: 'schedule', label: 'Schedule', emptyText: 'On dispatch' },
|
|
15
|
+
{ key: 'enabled', label: 'Enabled', type: 'boolean' },
|
|
16
|
+
]
|
|
6
17
|
const jobs = state<DashboardJob[]>([])
|
|
7
18
|
const stats = state<JobStatsResponse | null>(null)
|
|
8
19
|
const isLoading = state(true)
|
|
@@ -16,10 +27,12 @@ async function loadDashboard(): Promise<void> {
|
|
|
16
27
|
isLoading.set(true)
|
|
17
28
|
loadError.set('')
|
|
18
29
|
try {
|
|
19
|
-
const [list, summary] = await Promise.all([
|
|
30
|
+
const [list, summary, catalog] = await Promise.all([
|
|
20
31
|
fetchJobs({ page: 1, perPage: 10 }),
|
|
21
32
|
fetchJobStats(),
|
|
33
|
+
fetchJobDefinitions(),
|
|
22
34
|
])
|
|
35
|
+
definitions.set(catalog.data)
|
|
23
36
|
jobs.set(list.data)
|
|
24
37
|
stats.set(summary)
|
|
25
38
|
}
|
|
@@ -55,7 +68,7 @@ onMount(() => {
|
|
|
55
68
|
<header class="flex items-start justify-between">
|
|
56
69
|
<div>
|
|
57
70
|
<h1 class="font-bold text-2xl text-gray-900 dark:text-white">Jobs</h1>
|
|
58
|
-
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">
|
|
71
|
+
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Application job definitions, queued work, failures and shared worker metrics.</p>
|
|
59
72
|
</div>
|
|
60
73
|
<Button variant="secondary" :loading="isLoading()" @click="loadDashboard">
|
|
61
74
|
<span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
|
|
@@ -84,11 +97,17 @@ onMount(() => {
|
|
|
84
97
|
</div>
|
|
85
98
|
</section>
|
|
86
99
|
|
|
100
|
+
<section>
|
|
101
|
+
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Job definitions</h2>
|
|
102
|
+
<p :if="!isLoading && !loadError && definitions().length === 0" class="py-8 text-gray-500 text-sm">No application jobs have been defined.</p>
|
|
103
|
+
<Table :if="definitions().length > 0" :columns="definitionColumns" :data="definitions" row-key="name" paginated :page-size="25" />
|
|
104
|
+
</section>
|
|
105
|
+
|
|
87
106
|
<section>
|
|
88
107
|
<div class="flex items-end justify-between mb-3">
|
|
89
108
|
<div>
|
|
90
109
|
<h2 class="font-semibold text-gray-900 dark:text-white">Recent jobs</h2>
|
|
91
|
-
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">
|
|
110
|
+
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Metrics use worker heartbeats. Stopped sessions are retained for seven days.</p>
|
|
92
111
|
</div>
|
|
93
112
|
<StxLink to="/jobs/history" class="inline-flex items-center font-medium text-blue-600 text-sm dark:text-blue-400">
|
|
94
113
|
Full history
|
|
@@ -37,7 +37,6 @@ const QUEUE_COLUMNS = [
|
|
|
37
37
|
{ key: 'name', label: 'Queue', sortable: true, subtitleKey: 'status', class: 'font-medium text-gray-900 dark:text-white' },
|
|
38
38
|
count('pending', 'Pending'),
|
|
39
39
|
count('active', 'Active'),
|
|
40
|
-
count('completed', 'Completed'),
|
|
41
40
|
count('failed', 'Failed'),
|
|
42
41
|
{ ...count('total', 'Total'), class: 'font-medium text-gray-900 dark:text-white' },
|
|
43
42
|
]
|
|
@@ -97,7 +96,7 @@ onMount(() => {
|
|
|
97
96
|
</script>
|
|
98
97
|
|
|
99
98
|
<div class="space-y-6">
|
|
100
|
-
<PageHeader title="Queue" description="Recorded queue state and
|
|
99
|
+
<PageHeader title="Queue" description="Recorded queue state and shared worker heartbeats.">
|
|
101
100
|
<div class="flex gap-2 items-center">
|
|
102
101
|
<span :class="'inline-flex items-center px-2.5 py-1 font-medium text-xs rounded-full ' + (queueConnected() ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950' : 'text-red-700 bg-red-100 dark:text-red-300 dark:bg-red-950')">
|
|
103
102
|
<span :class="'mr-1.5 h-1.5 w-1.5 rounded-full ' + (queueConnected() ? 'bg-green-500' : 'bg-red-500')"></span>
|
|
@@ -137,7 +136,7 @@ onMount(() => {
|
|
|
137
136
|
<h2 class="font-semibold text-gray-900 dark:text-white">Queue breakdown</h2>
|
|
138
137
|
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Status totals come from Job records grouped by queue name.</p>
|
|
139
138
|
</div>
|
|
140
|
-
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ stats()?.throughputPerMinute || 0 }} jobs/min
|
|
139
|
+
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ stats()?.throughputPerMinute || 0 }} jobs/min across reporting workers</p>
|
|
141
140
|
</div>
|
|
142
141
|
<div :if="!isLoading() && queueRows().length === 0" class="px-5 py-12 text-center">
|
|
143
142
|
<div class="mb-3 mx-auto h-9 w-9 text-gray-400 i-hugeicons-queue-02"></div>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
3
|
+
<script client>
|
|
4
|
+
import type { SearchEngineInventory } from '../../../../functions/search-engine'
|
|
5
|
+
import { fetchSearchEngineInventory } from '../../../../functions/search-engine'
|
|
6
|
+
|
|
7
|
+
const inventory = state<SearchEngineInventory | null>(null)
|
|
8
|
+
const loading = state(true)
|
|
9
|
+
const error = state('')
|
|
10
|
+
const modelRows = derived(() => (inventory()?.models ?? []).map(model => ({
|
|
11
|
+
...model,
|
|
12
|
+
searchable: model.searchable.join(', '),
|
|
13
|
+
filterable: model.filterable.join(', ') || 'None',
|
|
14
|
+
sortable: model.sortable.join(', ') || 'None',
|
|
15
|
+
})))
|
|
16
|
+
const columns = [
|
|
17
|
+
{ key: 'name', label: 'Model', sortable: true },
|
|
18
|
+
{ key: 'index', label: 'Index', sortable: true },
|
|
19
|
+
{ key: 'searchable', label: 'Searchable fields' },
|
|
20
|
+
{ key: 'filterable', label: 'Filterable fields' },
|
|
21
|
+
{ key: 'sortable', label: 'Sortable fields' },
|
|
22
|
+
]
|
|
23
|
+
const indexColumns = [
|
|
24
|
+
{ key: 'name', label: 'Index', sortable: true },
|
|
25
|
+
{ key: 'documents', label: 'Documents', type: 'number', emptyText: 'Not reported' },
|
|
26
|
+
]
|
|
27
|
+
async function refresh() {
|
|
28
|
+
loading.set(true)
|
|
29
|
+
error.set('')
|
|
30
|
+
try { inventory.set(await fetchSearchEngineInventory()) }
|
|
31
|
+
catch { error.set('Search inventory could not be loaded. Try refreshing.') }
|
|
32
|
+
finally { loading.set(false) }
|
|
33
|
+
}
|
|
34
|
+
onMount(() => { void refresh() })
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div class="space-y-6">
|
|
38
|
+
<PageHeader title="Search Engine" description="Configured search service and model indexing conventions.">
|
|
39
|
+
<Button variant="secondary" :loading="loading" @click="refresh">Refresh</Button>
|
|
40
|
+
</PageHeader>
|
|
41
|
+
<p :if="loading" role="status" class="text-gray-500 text-sm">Loading search inventory...</p>
|
|
42
|
+
<p :if="error" role="alert" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 rounded-lg">{{ error }}</p>
|
|
43
|
+
<section :if="inventory" class="p-5 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
44
|
+
<p class="font-semibold text-gray-900 dark:text-white">{{ inventory()?.driver }}: {{ inventory()?.connected ? 'Connected' : 'Unavailable' }}</p>
|
|
45
|
+
<p :if="inventory()?.unavailable" role="status" class="mt-2 text-amber-700 text-sm dark:text-amber-300">{{ inventory()?.unavailable }}</p>
|
|
46
|
+
<p class="mt-2 text-gray-500 text-sm dark:text-neutral-400">Models are discovered from the application schema and their useSearch traits.</p>
|
|
47
|
+
</section>
|
|
48
|
+
<section>
|
|
49
|
+
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Searchable models</h2>
|
|
50
|
+
<p :if="!loading && modelRows().length === 0" class="py-8 text-gray-500 text-sm">No models declare the useSearch trait.</p>
|
|
51
|
+
<Table :if="modelRows().length > 0" :columns="columns" :data="modelRows" row-key="name" paginated :page-size="25" />
|
|
52
|
+
</section>
|
|
53
|
+
<section :if="inventory()?.connected">
|
|
54
|
+
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Provider indexes</h2>
|
|
55
|
+
<p :if="inventory()?.indexes.length === 0" class="py-8 text-gray-500 text-sm">The configured service has no indexes yet.</p>
|
|
56
|
+
<Table :if="inventory()?.indexes.length" :columns="indexColumns" :data="inventory()?.indexes || []" row-key="name" paginated :page-size="25" />
|
|
57
|
+
</section>
|
|
58
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared projection boundary for model records and global search. */
|
|
2
|
+
const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', 'two_factor_secret'])
|
|
3
|
+
|
|
4
|
+
export function hiddenModelColumns(Model: any): Set<string> {
|
|
5
|
+
const hidden = new Set(HIDDEN_COLUMNS)
|
|
6
|
+
for (const [name, definition] of Object.entries(Model?.attributes ?? {})) {
|
|
7
|
+
if ((definition as { hidden?: boolean })?.hidden)
|
|
8
|
+
hidden.add(name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase())
|
|
9
|
+
}
|
|
10
|
+
return hidden
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -18,6 +18,7 @@ type ModelCategory = 'userland' | 'data' | 'commerce' | 'content' | 'marketing'
|
|
|
18
18
|
* full type imports aren't available. Keep in sync with the source.
|
|
19
19
|
*/
|
|
20
20
|
interface DashboardModelOptionsLite {
|
|
21
|
+
access?: boolean
|
|
21
22
|
enabled?: boolean
|
|
22
23
|
label?: string
|
|
23
24
|
icon?: string
|
|
@@ -278,7 +279,7 @@ export function loadDiscoveredModels(): DiscoveredModel[] {
|
|
|
278
279
|
* explicitly disabled so callers can skip the row.
|
|
279
280
|
*/
|
|
280
281
|
function effectiveCategory(m: DiscoveredModel): string | undefined {
|
|
281
|
-
if (m.dashboard?.enabled === false)
|
|
282
|
+
if (m.dashboard?.access === false || m.dashboard?.enabled === false)
|
|
282
283
|
return undefined
|
|
283
284
|
if (m.dashboard?.section)
|
|
284
285
|
return m.dashboard.section
|
|
@@ -372,6 +373,7 @@ export function buildNavSections(
|
|
|
372
373
|
{ to: '/actions', icon: 'bolt', text: 'Actions' },
|
|
373
374
|
{ to: '/commands', icon: 'terminal', text: 'Commands' },
|
|
374
375
|
{ to: '/queue', icon: 'queue', text: 'Queue' },
|
|
376
|
+
{ to: '/search-engine', icon: 'search', text: 'Search Engine' },
|
|
375
377
|
{ to: '/jobs', icon: 'briefcase', text: 'Jobs' },
|
|
376
378
|
{ to: '/jobs/history', icon: 'clock', text: 'Job History' },
|
|
377
379
|
{ to: '/queries', icon: 'search', text: 'Queries' },
|
|
@@ -470,7 +472,7 @@ export function buildNavSections(
|
|
|
470
472
|
{ to: '/marketing/lists', icon: 'list-settings', text: 'Lists' },
|
|
471
473
|
{ to: '/marketing/social-posts', icon: 'clock', text: 'Social Posts' },
|
|
472
474
|
{ to: '/marketing/campaigns', icon: 'rocket', text: 'Campaigns' },
|
|
473
|
-
{ to: '/marketing/abandoned-carts', icon: 'cart', text: 'Abandoned Carts' },
|
|
475
|
+
...(toggles.commerce ? [{ to: '/marketing/abandoned-carts', icon: 'cart', text: 'Abandoned Carts' }] : []),
|
|
474
476
|
// Product reviews are commerce records, so with commerce off this page
|
|
475
477
|
// could only say so (stacksjs/stacks#3186).
|
|
476
478
|
...(toggles.commerce ? [{ to: '/marketing/reviews', icon: 'star', text: 'Reviews' }] : []),
|