@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.
Files changed (36) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-dashboard/SKILL.md +33 -2
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-error-handling/SKILL.md +8 -0
  6. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  7. package/ai/skills/stacks-models/SKILL.md +2 -2
  8. package/ai/skills/stacks-native/CATALOG.md +3 -3
  9. package/ai/skills/stacks-orm/SKILL.md +2 -2
  10. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +2 -2
  11. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +2 -13
  12. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +5 -11
  13. package/app/Actions/Dashboard/Content/ContentDashboardAction.ts +1 -1
  14. package/app/Actions/Dashboard/Content/PostIndexAction.ts +1 -1
  15. package/app/Actions/Dashboard/Content/post-input.ts +1 -1
  16. package/app/Actions/Dashboard/Jobs/JobDefinitionsAction.ts +26 -0
  17. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -2
  18. package/app/Actions/Dashboard/Models/ModelShowAction.ts +1 -11
  19. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +9 -41
  20. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +5 -7
  21. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +6 -6
  22. package/app/Actions/Dashboard/Queue/queue-stats.ts +5 -1
  23. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +47 -89
  24. package/app/Actions/Dashboard/Search/SearchEngineIndexAction.ts +59 -0
  25. package/app/Models/QueueWorker.ts +17 -0
  26. package/functions/jobs.ts +14 -0
  27. package/functions/search-engine.ts +13 -0
  28. package/ide/vscode/package.json +1 -1
  29. package/package.json +2 -2
  30. package/resources/components/Dashboard/Jobs/JobDashboard.stx +24 -5
  31. package/resources/components/Dashboard/Queue/QueueDashboard.stx +2 -3
  32. package/resources/components/Dashboard/Search/SearchEngineDashboard.stx +58 -0
  33. package/resources/functions/dashboard/model-columns.ts +12 -0
  34. package/resources/functions/dashboard/sidebar.ts +4 -2
  35. package/routes/dashboard-api.ts +178 -166
  36. 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 { env } from '@stacksjs/env'
4
- import { readdirSync } from 'node:fs'
5
- import { basename, extname, join } from 'node:path'
6
- import process from 'node:process'
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
- const defaultsRoot = join(frameworkDefaultsRoot(), 'app/Models')
119
- const userRoot = join(process.cwd(), 'app/Models')
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 [fallbackName, file] of merged) {
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
- try {
211
- for (const model of models) {
212
- if (Object.keys(results).length >= MAX_GROUPS) break
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: (row.id as string | number | undefined) ?? index,
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
- finally {
260
- db.close()
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
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.484",
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.484",
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.484",
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">Queue activity from Job and FailedJob records plus the current worker metrics.</p>
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">No synthetic throughput is shown. Metrics reflect the running queue process.</p>
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 current-process worker metrics.">
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 in this process</p>
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' }] : []),