@stacksjs/defaults 0.74.3 → 0.74.4
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-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-buddy/SKILL.md +53 -3
- package/ai/skills/stacks-cloud/SKILL.md +83 -11
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-composables/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-deploy/SKILL.md +97 -24
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-types/SKILL.md +1 -1
- package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
- package/app/Actions/Buddy/CommandsAction.ts +1 -10
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
- package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
- package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
- package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
- package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
- package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
- package/app/Actions/Monitoring/error-provider.ts +130 -0
- package/app/Models/EmailIdempotency.ts +4 -1
- package/app/Models/EmailSuppression.ts +4 -1
- package/app/Models/EmailWebhookEvent.ts +4 -1
- package/app/Models/Request.ts +4 -1
- package/functions/api-url.test.ts +61 -0
- package/functions/api-url.ts +19 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import type { DashboardLogQuery, DashboardLogRange } from './log-provider'
|
|
2
3
|
import { Action } from '@stacksjs/actions'
|
|
3
|
-
import { db } from '@stacksjs/database'
|
|
4
4
|
import { dashboardRequestValue } from '../dashboard-request'
|
|
5
5
|
import { dashboardOperationalError } from '../dashboard-response'
|
|
6
|
-
import { DASHBOARD_LOG_TYPES
|
|
7
|
-
|
|
8
|
-
const RANGES = ['1', '7', '30', '90', 'all'] as const
|
|
6
|
+
import { DASHBOARD_LOG_TYPES } from './log-dashboard'
|
|
7
|
+
import { DASHBOARD_LOG_RANGES, readDashboardLogs } from './log-provider'
|
|
9
8
|
|
|
10
9
|
function queryValue(request: RequestInstance, key: string): string {
|
|
11
10
|
return dashboardRequestValue(request, key)
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
/** Reads the filters off the request. Unknown values fall back rather than fail. */
|
|
14
|
+
function logQuery(request: RequestInstance): DashboardLogQuery {
|
|
15
|
+
const requestedType = queryValue(request, 'type').toLowerCase()
|
|
16
|
+
const requestedRange = queryValue(request, 'range').toLowerCase()
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
page: Math.max(1, Number.parseInt(queryValue(request, 'page') || '1', 10) || 1),
|
|
20
|
+
perPage: Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25)),
|
|
21
|
+
search: queryValue(request, 'search'),
|
|
22
|
+
type: DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
|
|
23
|
+
? requestedType
|
|
24
|
+
: '',
|
|
25
|
+
source: queryValue(request, 'source').toLowerCase(),
|
|
26
|
+
project: queryValue(request, 'project'),
|
|
27
|
+
range: DASHBOARD_LOG_RANGES.includes(requestedRange as DashboardLogRange)
|
|
28
|
+
? requestedRange as DashboardLogRange
|
|
29
|
+
: '30',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
export default new Action({
|
|
15
34
|
name: 'LogIndexAction',
|
|
16
35
|
description: 'Returns filtered, paginated Log model records for the dashboard.',
|
|
@@ -19,86 +38,7 @@ export default new Action({
|
|
|
19
38
|
|
|
20
39
|
async handle(request: RequestInstance) {
|
|
21
40
|
try {
|
|
22
|
-
|
|
23
|
-
const perPage = Math.min(100, Math.max(10, Number.parseInt(queryValue(request, 'per_page') || '25', 10) || 25))
|
|
24
|
-
const search = queryValue(request, 'search')
|
|
25
|
-
const requestedType = queryValue(request, 'type').toLowerCase()
|
|
26
|
-
const type = DASHBOARD_LOG_TYPES.includes(requestedType as typeof DASHBOARD_LOG_TYPES[number])
|
|
27
|
-
? requestedType
|
|
28
|
-
: ''
|
|
29
|
-
const source = queryValue(request, 'source').toLowerCase()
|
|
30
|
-
const project = queryValue(request, 'project')
|
|
31
|
-
const requestedRange = queryValue(request, 'range').toLowerCase()
|
|
32
|
-
const range = RANGES.includes(requestedRange as typeof RANGES[number]) ? requestedRange : '30'
|
|
33
|
-
const cutoff = range === 'all' ? 0 : Date.now() - Number(range) * 86400000
|
|
34
|
-
const searchPattern = `%${search}%`
|
|
35
|
-
|
|
36
|
-
const applyFilters = <T extends {
|
|
37
|
-
where: (...args: any[]) => T
|
|
38
|
-
whereAny: (columns: string[], operator: string, value: unknown) => T
|
|
39
|
-
}>(query: T): T => {
|
|
40
|
-
let filtered = query
|
|
41
|
-
if (cutoff)
|
|
42
|
-
filtered = filtered.where('timestamp', '>=', cutoff)
|
|
43
|
-
if (type)
|
|
44
|
-
filtered = filtered.where('type', '=', type)
|
|
45
|
-
if (source)
|
|
46
|
-
filtered = filtered.where('source', '=', source)
|
|
47
|
-
if (project)
|
|
48
|
-
filtered = filtered.where('project', '=', project)
|
|
49
|
-
if (search)
|
|
50
|
-
filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
|
|
51
|
-
return filtered
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
|
|
55
|
-
applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
|
|
56
|
-
.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
|
|
57
|
-
applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
|
|
58
|
-
.groupBy('type')
|
|
59
|
-
.execute() as Promise<Array<{ type: string, count: number | string }>>,
|
|
60
|
-
db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
|
|
61
|
-
db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
|
|
62
|
-
])
|
|
63
|
-
|
|
64
|
-
const total = Number(countRow?.count || 0)
|
|
65
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
|
66
|
-
const page = Math.min(requestedPage, totalPages)
|
|
67
|
-
const rows = await applyFilters(db.selectFrom('logs').select([
|
|
68
|
-
'id',
|
|
69
|
-
'timestamp',
|
|
70
|
-
'type',
|
|
71
|
-
'source',
|
|
72
|
-
'message',
|
|
73
|
-
'project',
|
|
74
|
-
'stacktrace',
|
|
75
|
-
'file',
|
|
76
|
-
'created_at',
|
|
77
|
-
'updated_at',
|
|
78
|
-
]))
|
|
79
|
-
.orderBy('timestamp', 'desc')
|
|
80
|
-
.orderBy('id', 'desc')
|
|
81
|
-
.limit(perPage)
|
|
82
|
-
.offset((page - 1) * perPage)
|
|
83
|
-
.execute()
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
logs: rows.map(normalizeDashboardLog),
|
|
87
|
-
summary: summarizeDashboardLogTypes(typeRows, total),
|
|
88
|
-
pagination: {
|
|
89
|
-
page,
|
|
90
|
-
perPage,
|
|
91
|
-
total,
|
|
92
|
-
totalPages,
|
|
93
|
-
},
|
|
94
|
-
options: {
|
|
95
|
-
sources: sourceRows.map(row => row.source).filter(Boolean),
|
|
96
|
-
projects: projectRows.map(row => row.project).filter(Boolean),
|
|
97
|
-
types: [...DASHBOARD_LOG_TYPES],
|
|
98
|
-
ranges: [...RANGES],
|
|
99
|
-
},
|
|
100
|
-
generatedAt: new Date().toISOString(),
|
|
101
|
-
}
|
|
41
|
+
return await readDashboardLogs(logQuery(request))
|
|
102
42
|
}
|
|
103
43
|
catch (error) {
|
|
104
44
|
return dashboardOperationalError(error, 'Logs could not be loaded.', 'LogIndexAction')
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { DashboardProviderUnavailable } from '../dashboard-provider'
|
|
2
|
+
import type { DashboardLogRecord, DashboardLogSummary } from './log-dashboard'
|
|
3
|
+
import { db } from '@stacksjs/database'
|
|
4
|
+
import { HQ_READ_UNAVAILABLE, readThroughProvider, resolveDashboardDriver } from '../dashboard-provider'
|
|
5
|
+
import { DASHBOARD_LOG_TYPES, normalizeDashboardLog, summarizeDashboardLogTypes } from './log-dashboard'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the Logs section reads from.
|
|
9
|
+
*
|
|
10
|
+
* The local provider is the query that used to sit inside `LogIndexAction`,
|
|
11
|
+
* moved without changing a line of its logic: the same five statements against
|
|
12
|
+
* the `logs` table, the same filters, the same ordering, and the same clamp of
|
|
13
|
+
* a requested page down to the last page that exists.
|
|
14
|
+
*
|
|
15
|
+
* The payload deliberately reuses `DashboardLogRecord` and
|
|
16
|
+
* `DashboardLogSummary` from `log-dashboard.ts`. Three stx components import
|
|
17
|
+
* those same types, so a provider that returns the wrong shape is a compile
|
|
18
|
+
* error against the declaration the components themselves consume rather than
|
|
19
|
+
* a surprise at runtime.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const DASHBOARD_LOG_RANGES = ['1', '7', '30', '90', 'all'] as const
|
|
23
|
+
|
|
24
|
+
export type DashboardLogRange = typeof DASHBOARD_LOG_RANGES[number]
|
|
25
|
+
|
|
26
|
+
export interface DashboardLogQuery {
|
|
27
|
+
page: number
|
|
28
|
+
perPage: number
|
|
29
|
+
search: string
|
|
30
|
+
/** One of {@link DASHBOARD_LOG_TYPES}, or empty for every level. */
|
|
31
|
+
type: string
|
|
32
|
+
source: string
|
|
33
|
+
project: string
|
|
34
|
+
range: DashboardLogRange
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DashboardLogPagination {
|
|
38
|
+
page: number
|
|
39
|
+
perPage: number
|
|
40
|
+
total: number
|
|
41
|
+
totalPages: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DashboardLogOptions {
|
|
45
|
+
sources: string[]
|
|
46
|
+
projects: string[]
|
|
47
|
+
types: string[]
|
|
48
|
+
ranges: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DashboardLogPayload extends Partial<DashboardProviderUnavailable> {
|
|
52
|
+
logs: DashboardLogRecord[]
|
|
53
|
+
summary: DashboardLogSummary
|
|
54
|
+
pagination: DashboardLogPagination
|
|
55
|
+
options: DashboardLogOptions
|
|
56
|
+
generatedAt: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DashboardLogsProvider {
|
|
60
|
+
logs: (query: DashboardLogQuery) => Promise<DashboardLogPayload>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An empty page of logs, carrying the reason there is nothing to show. */
|
|
64
|
+
export function emptyLogPayload(reason: string, query?: DashboardLogQuery): DashboardLogPayload {
|
|
65
|
+
return {
|
|
66
|
+
logs: [],
|
|
67
|
+
summary: { total: 0, error: 0, warning: 0, info: 0, success: 0 },
|
|
68
|
+
pagination: {
|
|
69
|
+
page: 1,
|
|
70
|
+
perPage: query?.perPage ?? 25,
|
|
71
|
+
total: 0,
|
|
72
|
+
totalPages: 1,
|
|
73
|
+
},
|
|
74
|
+
options: {
|
|
75
|
+
sources: [],
|
|
76
|
+
projects: [],
|
|
77
|
+
types: [...DASHBOARD_LOG_TYPES],
|
|
78
|
+
ranges: [...DASHBOARD_LOG_RANGES],
|
|
79
|
+
},
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
unavailable: reason,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLogPayload(payload: unknown): boolean {
|
|
86
|
+
if (!payload || typeof payload !== 'object')
|
|
87
|
+
return false
|
|
88
|
+
const candidate = payload as Partial<DashboardLogPayload>
|
|
89
|
+
return Array.isArray(candidate.logs)
|
|
90
|
+
&& Boolean(candidate.summary)
|
|
91
|
+
&& Boolean(candidate.pagination)
|
|
92
|
+
&& Boolean(candidate.options)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* This application's own `logs` table.
|
|
97
|
+
*
|
|
98
|
+
* Not wrapped in a catch. A failure here is a local database failure, and the
|
|
99
|
+
* action has always answered that with its operational error, which is the
|
|
100
|
+
* behaviour every existing caller and test expects.
|
|
101
|
+
*/
|
|
102
|
+
export const localLogsProvider: DashboardLogsProvider = {
|
|
103
|
+
async logs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
|
|
104
|
+
const cutoff = query.range === 'all' ? 0 : Date.now() - Number(query.range) * 86400000
|
|
105
|
+
const searchPattern = `%${query.search}%`
|
|
106
|
+
|
|
107
|
+
const applyFilters = <T extends {
|
|
108
|
+
where: (...args: any[]) => T
|
|
109
|
+
whereAny: (columns: string[], operator: string, value: unknown) => T
|
|
110
|
+
}>(builder: T): T => {
|
|
111
|
+
let filtered = builder
|
|
112
|
+
if (cutoff)
|
|
113
|
+
filtered = filtered.where('timestamp', '>=', cutoff)
|
|
114
|
+
if (query.type)
|
|
115
|
+
filtered = filtered.where('type', '=', query.type)
|
|
116
|
+
if (query.source)
|
|
117
|
+
filtered = filtered.where('source', '=', query.source)
|
|
118
|
+
if (query.project)
|
|
119
|
+
filtered = filtered.where('project', '=', query.project)
|
|
120
|
+
if (query.search)
|
|
121
|
+
filtered = filtered.whereAny(['message', 'project', 'file', 'stacktrace'], 'like', searchPattern)
|
|
122
|
+
return filtered
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [countRow, typeRows, sourceRows, projectRows] = await Promise.all([
|
|
126
|
+
applyFilters(db.selectFrom('logs').select(db.fn.count('id').as('count')))
|
|
127
|
+
.executeTakeFirst() as Promise<{ count: number | string } | undefined>,
|
|
128
|
+
applyFilters(db.selectFrom('logs').select(['type', db.fn.count('id').as('count')]))
|
|
129
|
+
.groupBy('type')
|
|
130
|
+
.execute() as Promise<Array<{ type: string, count: number | string }>>,
|
|
131
|
+
db.selectFrom('logs').select('source').distinct().orderBy('source', 'asc').limit(100).execute() as Promise<Array<{ source: string }>>,
|
|
132
|
+
db.selectFrom('logs').select('project').distinct().orderBy('project', 'asc').limit(500).execute() as Promise<Array<{ project: string }>>,
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
const total = Number(countRow?.count || 0)
|
|
136
|
+
const totalPages = Math.max(1, Math.ceil(total / query.perPage))
|
|
137
|
+
const page = Math.min(query.page, totalPages)
|
|
138
|
+
const rows = await applyFilters(db.selectFrom('logs').select([
|
|
139
|
+
'id',
|
|
140
|
+
'timestamp',
|
|
141
|
+
'type',
|
|
142
|
+
'source',
|
|
143
|
+
'message',
|
|
144
|
+
'project',
|
|
145
|
+
'stacktrace',
|
|
146
|
+
'file',
|
|
147
|
+
'created_at',
|
|
148
|
+
'updated_at',
|
|
149
|
+
]))
|
|
150
|
+
.orderBy('timestamp', 'desc')
|
|
151
|
+
.orderBy('id', 'desc')
|
|
152
|
+
.limit(query.perPage)
|
|
153
|
+
.offset((page - 1) * query.perPage)
|
|
154
|
+
.execute()
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
logs: rows.map(normalizeDashboardLog),
|
|
158
|
+
summary: summarizeDashboardLogTypes(typeRows, total),
|
|
159
|
+
pagination: {
|
|
160
|
+
page,
|
|
161
|
+
perPage: query.perPage,
|
|
162
|
+
total,
|
|
163
|
+
totalPages,
|
|
164
|
+
},
|
|
165
|
+
options: {
|
|
166
|
+
sources: sourceRows.map(row => row.source).filter(Boolean),
|
|
167
|
+
projects: projectRows.map(row => row.project).filter(Boolean),
|
|
168
|
+
types: [...DASHBOARD_LOG_TYPES],
|
|
169
|
+
ranges: [...DASHBOARD_LOG_RANGES],
|
|
170
|
+
},
|
|
171
|
+
generatedAt: new Date().toISOString(),
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reads the Logs section through whichever provider config selects. */
|
|
177
|
+
export async function readDashboardLogs(query: DashboardLogQuery): Promise<DashboardLogPayload> {
|
|
178
|
+
const driver = await resolveDashboardDriver('logs')
|
|
179
|
+
|
|
180
|
+
if (driver.name === 'local')
|
|
181
|
+
return localLogsProvider.logs(query)
|
|
182
|
+
|
|
183
|
+
return readThroughProvider(
|
|
184
|
+
'logs',
|
|
185
|
+
reason => emptyLogPayload(reason, query),
|
|
186
|
+
async () => emptyLogPayload(HQ_READ_UNAVAILABLE, query),
|
|
187
|
+
isLogPayload,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DashboardHqDriverOptions, DashboardProviderName, DashboardProviderSelection } from '@stacksjs/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The seam between a dashboard section and the data behind it.
|
|
5
|
+
*
|
|
6
|
+
* Every section in the dashboard used to reach for a local store directly:
|
|
7
|
+
* the Logs page queried the `logs` table, the Errors pages called into
|
|
8
|
+
* `@stacksjs/commerce`, and web Analytics aggregated the `Request` model. That
|
|
9
|
+
* works exactly once, for the application the dashboard is running inside. It
|
|
10
|
+
* cannot show the logs of a fleet, and it gave the hosted products (loghq,
|
|
11
|
+
* bughq, analyticshq) nowhere to plug in.
|
|
12
|
+
*
|
|
13
|
+
* This module resolves which provider a domain reads from. `local` is the
|
|
14
|
+
* default and is byte-for-byte the behaviour that shipped before the seam
|
|
15
|
+
* existed, including how it fails: a local provider is never wrapped, so a
|
|
16
|
+
* database outage still surfaces the same operational error it always did.
|
|
17
|
+
* Anything else is remote, and remote is wrapped, because a section whose
|
|
18
|
+
* backend is unreachable has to render empty with a reason rather than take
|
|
19
|
+
* the page down.
|
|
20
|
+
*
|
|
21
|
+
* Read paths only. No provider writes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The dashboard sections that resolve a provider. */
|
|
25
|
+
export type DashboardProviderDomain = 'logs' | 'errors' | 'analytics'
|
|
26
|
+
|
|
27
|
+
const PROVIDER_NAMES: readonly DashboardProviderName[] = ['local', 'hq']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why a section has no data.
|
|
31
|
+
*
|
|
32
|
+
* Carried on the payload beside the empty collections rather than thrown, so
|
|
33
|
+
* the section renders its own empty state instead of an error page. The string
|
|
34
|
+
* is written for whoever is looking at the dashboard, not for a log file.
|
|
35
|
+
*/
|
|
36
|
+
export interface DashboardProviderUnavailable {
|
|
37
|
+
unavailable: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedDashboardDriver {
|
|
41
|
+
name: DashboardProviderName
|
|
42
|
+
options: DashboardHqDriverOptions
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resolved = new Map<DashboardProviderDomain, ResolvedDashboardDriver>()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Forgets every memoized driver choice.
|
|
49
|
+
*
|
|
50
|
+
* Resolution is cached per domain so a request does not re-read config on
|
|
51
|
+
* every query. Tests that walk more than one configuration in a single process
|
|
52
|
+
* need that cache cleared between cases, and clearing it is much cheaper than
|
|
53
|
+
* `mock.module`, which replaces the shared module instance for every other
|
|
54
|
+
* test file in the run and is not undone by `mock.restore()`.
|
|
55
|
+
*/
|
|
56
|
+
export function resetDashboardProviders(): void {
|
|
57
|
+
resolved.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readSelection(section: unknown, domain: DashboardProviderDomain): DashboardProviderSelection {
|
|
61
|
+
if (!section || typeof section !== 'object')
|
|
62
|
+
return {}
|
|
63
|
+
const providers = (section as { providers?: unknown }).providers
|
|
64
|
+
if (!providers || typeof providers !== 'object')
|
|
65
|
+
return {}
|
|
66
|
+
const selection = (providers as Record<string, unknown>)[domain]
|
|
67
|
+
return selection && typeof selection === 'object' ? selection as DashboardProviderSelection : {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function selectDriverName(value: unknown, domain: DashboardProviderDomain): DashboardProviderName {
|
|
71
|
+
if (value === undefined || value === null || value === '')
|
|
72
|
+
return 'local'
|
|
73
|
+
|
|
74
|
+
switch (value) {
|
|
75
|
+
case 'local':
|
|
76
|
+
return 'local'
|
|
77
|
+
case 'hq':
|
|
78
|
+
return 'hq'
|
|
79
|
+
default:
|
|
80
|
+
// `satisfies DashboardConfig` is erased at runtime, and config files are
|
|
81
|
+
// ordinary TypeScript an app can hand-edit, so an unknown name reaches
|
|
82
|
+
// here as a plain string. Falling back to local keeps the section
|
|
83
|
+
// rendering; throwing would take it down over a typo.
|
|
84
|
+
console.warn(
|
|
85
|
+
`[dashboard/api] config.dashboard.providers.${domain}.driver is `
|
|
86
|
+
+ `"${String(value)}", which is not a known provider. Reading local data instead. `
|
|
87
|
+
+ `Available providers: ${PROVIDER_NAMES.join(', ')}.`,
|
|
88
|
+
)
|
|
89
|
+
return 'local'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads a domain's provider choice from config.
|
|
95
|
+
*
|
|
96
|
+
* Config is read through `overridesReady`. A read that happens before the
|
|
97
|
+
* application's own config files have been layered in returns the framework
|
|
98
|
+
* defaults with no error at all, which would silently resolve `local` in an
|
|
99
|
+
* application that had asked for something else.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveDashboardDriver(domain: DashboardProviderDomain): Promise<ResolvedDashboardDriver> {
|
|
102
|
+
const cached = resolved.get(domain)
|
|
103
|
+
if (cached)
|
|
104
|
+
return cached
|
|
105
|
+
|
|
106
|
+
let selection: DashboardProviderSelection = {}
|
|
107
|
+
try {
|
|
108
|
+
const { config, overridesReady } = await import('@stacksjs/config')
|
|
109
|
+
// Rejection means boot could not read the application's config at all.
|
|
110
|
+
// The framework defaults are still a usable answer, and they are local.
|
|
111
|
+
await overridesReady.catch(() => {})
|
|
112
|
+
selection = readSelection(config.dashboard, domain)
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.warn(`[dashboard/api] dashboard provider config for ${domain} could not be read, using local:`, error)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const driver: ResolvedDashboardDriver = {
|
|
119
|
+
name: selectDriverName(selection.driver, domain),
|
|
120
|
+
options: selection.drivers?.hq ?? {},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
resolved.set(domain, driver)
|
|
124
|
+
return driver
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runs a remote provider so that it cannot take a section down.
|
|
129
|
+
*
|
|
130
|
+
* Three ways a remote read goes wrong, all of which land on the same empty
|
|
131
|
+
* payload plus a reason: it rejects, it never answers, or it answers with
|
|
132
|
+
* something that is not the shape the section renders. The last one matters
|
|
133
|
+
* as much as the first two, because a payload that is merely missing its
|
|
134
|
+
* array reaches the component and fails there instead, which is a blank page
|
|
135
|
+
* rather than an empty state.
|
|
136
|
+
*/
|
|
137
|
+
export async function readThroughProvider<T>(
|
|
138
|
+
domain: DashboardProviderDomain,
|
|
139
|
+
empty: (reason: string) => T,
|
|
140
|
+
read: () => Promise<T>,
|
|
141
|
+
isValid: (payload: unknown) => boolean,
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
try {
|
|
144
|
+
const payload = await read()
|
|
145
|
+
if (!isValid(payload)) {
|
|
146
|
+
console.error(`[dashboard/api] the ${domain} provider answered with an unexpected shape`)
|
|
147
|
+
return empty('This section could not be read from its provider.')
|
|
148
|
+
}
|
|
149
|
+
return payload
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
console.error(`[dashboard/api] the ${domain} provider could not be read:`, error)
|
|
153
|
+
return empty('This section could not be reached. It is configured to read from a hosted provider.')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The reason an `hq` provider has no data yet.
|
|
159
|
+
*
|
|
160
|
+
* Both published SDKs are ingest clients. `@loghq/stacks` and `@bughq/stacks`
|
|
161
|
+
* send records and expose no function that reads any back, and a project's
|
|
162
|
+
* ingest key grants no read access by design. The hosted read APIs do exist,
|
|
163
|
+
* but they authenticate a person rather than a machine, so wiring one needs a
|
|
164
|
+
* product decision about service credentials before any code here can help.
|
|
165
|
+
*
|
|
166
|
+
* Selecting `hq` today therefore yields an empty section that says so, which
|
|
167
|
+
* is the honest answer and keeps the degrade path real rather than theoretical.
|
|
168
|
+
*/
|
|
169
|
+
export const HQ_READ_UNAVAILABLE
|
|
170
|
+
= 'No data. The hosted provider has no read API yet, so this section cannot be filled from it.'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorsByGroup } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Group',
|
|
@@ -14,8 +14,6 @@ export default new Action({
|
|
|
14
14
|
return response.json({ error: 'Both type and message are required' }, 400)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return response.json({ data: results })
|
|
17
|
+
return response.json(await readErrorsByGroup(type, message))
|
|
20
18
|
},
|
|
21
19
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readGroupedErrors } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Index',
|
|
7
7
|
description: 'Fetch all grouped errors (Sentry-like aggregation)',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: results })
|
|
10
|
+
return response.json(await readGroupedErrors())
|
|
13
11
|
},
|
|
14
12
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorById } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Show',
|
|
@@ -13,12 +13,12 @@ export default new Action({
|
|
|
13
13
|
if (id === null)
|
|
14
14
|
return response.json({ error: 'A numeric error id is required' }, 422)
|
|
15
15
|
|
|
16
|
-
const result = await
|
|
16
|
+
const result = await readErrorById(id)
|
|
17
17
|
|
|
18
|
-
if (!result) {
|
|
18
|
+
if (!result.data) {
|
|
19
19
|
return response.json({ error: 'Error not found' }, 404)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
return response.json(
|
|
22
|
+
return response.json(result)
|
|
23
23
|
},
|
|
24
24
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorStats } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Stats',
|
|
7
7
|
description: 'Fetch error statistics',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: stats })
|
|
10
|
+
return response.json(await readErrorStats())
|
|
13
11
|
},
|
|
14
12
|
})
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { errors } from '@stacksjs/commerce'
|
|
3
2
|
import { response } from '@stacksjs/router'
|
|
3
|
+
import { readErrorTimeline } from './error-provider'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'Error Timeline',
|
|
7
7
|
description: 'Fetch error timeline (hourly counts for last 24 hours)',
|
|
8
8
|
method: 'GET',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return response.json({ data: timeline })
|
|
10
|
+
return response.json(await readErrorTimeline())
|
|
13
11
|
},
|
|
14
12
|
})
|