@stacksjs/defaults 0.70.365 → 0.70.367
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 +9 -1
- package/ai/skills/stacks-dashboard/SKILL.md +71 -14
- package/ai/skills/stacks-deploy/SKILL.md +18 -0
- package/ai/skills/stacks-desktop/SKILL.md +11 -2
- package/ai/skills/stacks-stx/SKILL.md +25 -0
- package/app/Actions/Auth/LoginAction.ts +11 -2
- package/app/Actions/Auth/RefreshTokenAction.ts +6 -2
- package/app/Actions/Auth/RegisterAction.ts +6 -2
- package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +6 -2
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
- package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
- package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
- package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
- package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
- package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
- package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
- package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
- package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
- package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
- package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
- package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
- package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
- package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
- package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
- package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
- package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
- package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
- package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
- package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
- package/functions/deployments.ts +18 -0
- package/functions/operations.ts +361 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +0 -2
- package/resources/components/Dashboard/Auth/LoginDashboard.stx +1 -1
- package/resources/components/Dashboard/Auth/RegisterDashboard.stx +1 -3
- package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
- package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
- package/resources/components/Dashboard/GlobalSearch.stx +87 -91
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +9 -435
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +416 -0
- package/resources/components/Dashboard/Modals/BaseModal.stx +13 -36
- package/resources/components/Dashboard/Modals/Popups/Alert.stx +18 -44
- package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
- package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
- package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
- package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
- package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
- package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
- package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
- package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
- package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
- package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
- package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
- package/resources/components/Dashboard/UI/Modal.stx +43 -14
- package/resources/components/Dashboard/UI/Table.stx +30 -25
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +23 -0
- package/routes/dashboard.ts +1 -0
- package/views/dashboard/AUDIT.md +21 -16
- package/views/dashboard/operations/audit.stx +5 -0
- package/views/dashboard/operations/changes.stx +5 -0
- package/views/dashboard/operations/incidents.stx +5 -0
- package/views/dashboard/operations/migrations.stx +8 -0
- package/views/dashboard/operations/recovery.stx +8 -0
- package/views/dashboard/operations/scheduler.stx +8 -0
- package/views/dashboard/stores/ci.ts +2 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { DeploymentPreview } from '@stacksjs/types'
|
|
3
|
+
import { parseDeploymentPreview } from './deployment-preview'
|
|
4
|
+
|
|
5
|
+
const plan: DeploymentPreview = {
|
|
6
|
+
version: 1,
|
|
7
|
+
dryRun: true,
|
|
8
|
+
project: { name: 'Acme', slug: 'acme' },
|
|
9
|
+
provider: 'hetzner',
|
|
10
|
+
mode: 'server',
|
|
11
|
+
environment: 'production',
|
|
12
|
+
region: 'us-east-1',
|
|
13
|
+
target: { site: null, domain: null, attachTo: null },
|
|
14
|
+
sites: [],
|
|
15
|
+
operations: [],
|
|
16
|
+
warnings: [],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('dashboard deployment preview process', () => {
|
|
20
|
+
it('extracts the versioned contract from Buddy output', () => {
|
|
21
|
+
const output = `[env] loaded\nSTACKS_DEPLOY_PREVIEW_JSON=${JSON.stringify(plan)}\n`
|
|
22
|
+
expect(parseDeploymentPreview(output)).toEqual(plan)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects missing and malformed preview contracts', () => {
|
|
26
|
+
expect(() => parseDeploymentPreview('ordinary output')).toThrow('did not return')
|
|
27
|
+
expect(() => parseDeploymentPreview('STACKS_DEPLOY_PREVIEW_JSON={}')).toThrow('invalid')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DeploymentPreview } from '@stacksjs/types'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
const previewPrefix = 'STACKS_DEPLOY_PREVIEW_JSON='
|
|
6
|
+
|
|
7
|
+
export function parseDeploymentPreview(output: string): DeploymentPreview {
|
|
8
|
+
const line = output.split(/\r?\n/).find(entry => entry.startsWith(previewPrefix))
|
|
9
|
+
if (!line)
|
|
10
|
+
throw new Error('Buddy did not return a deployment preview contract.')
|
|
11
|
+
|
|
12
|
+
const plan = JSON.parse(line.slice(previewPrefix.length)) as DeploymentPreview
|
|
13
|
+
if (plan.version !== 1 || plan.dryRun !== true || !Array.isArray(plan.operations) || !Array.isArray(plan.sites))
|
|
14
|
+
throw new Error('Buddy returned an invalid deployment preview contract.')
|
|
15
|
+
return plan
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runDeploymentPreview(args: string[], timeoutMs = 30_000): Promise<DeploymentPreview> {
|
|
19
|
+
const child = Bun.spawn([join(process.cwd(), 'buddy'), ...args], {
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
stdout: 'pipe',
|
|
22
|
+
stderr: 'pipe',
|
|
23
|
+
})
|
|
24
|
+
const stdoutPromise = new Response(child.stdout).text()
|
|
25
|
+
const stderrPromise = new Response(child.stderr).text()
|
|
26
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
27
|
+
const exitPromise = Promise.race([
|
|
28
|
+
child.exited,
|
|
29
|
+
new Promise<never>((_resolve, reject) => {
|
|
30
|
+
timeout = setTimeout(() => {
|
|
31
|
+
child.kill()
|
|
32
|
+
reject(new Error(`Deployment preview timed out after ${timeoutMs}ms.`))
|
|
33
|
+
}, timeoutMs)
|
|
34
|
+
}),
|
|
35
|
+
])
|
|
36
|
+
const [exitCode, stdout, stderr] = await Promise.all([exitPromise, stdoutPromise, stderrPromise])
|
|
37
|
+
.finally(() => clearTimeout(timeout))
|
|
38
|
+
if (exitCode !== 0)
|
|
39
|
+
throw new Error(stderr.trim() || `buddy deploy preview exited with code ${exitCode}`)
|
|
40
|
+
return parseDeploymentPreview(stdout)
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { deploymentRollbackInput } from './deployment-rollback'
|
|
3
|
+
|
|
4
|
+
describe('deployment rollback input', () => {
|
|
5
|
+
it('normalizes a scoped rollback target', () => {
|
|
6
|
+
expect(deploymentRollbackInput({ environment: 'Staging', site: 'Dashboard', release: 'Release-42' })).toEqual({
|
|
7
|
+
environment: 'staging',
|
|
8
|
+
site: 'dashboard',
|
|
9
|
+
release: 'release-42',
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('rejects shell syntax instead of forwarding it', () => {
|
|
14
|
+
expect(() => deploymentRollbackInput({ environment: 'production; echo unsafe' })).toThrow()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
export interface DeploymentRollbackInput {
|
|
4
|
+
environment: string
|
|
5
|
+
site?: string
|
|
6
|
+
release?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class DeploymentRollbackError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'DeploymentRollbackError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function identifier(value: unknown, label: string, required = false): string {
|
|
17
|
+
const normalized = String(value || '').trim().toLowerCase()
|
|
18
|
+
if (!normalized && !required) return ''
|
|
19
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(normalized))
|
|
20
|
+
throw new DeploymentRollbackError(`${label} must contain only letters, numbers, dots, underscores, and hyphens.`)
|
|
21
|
+
return normalized
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deploymentRollbackInput(input: Record<string, unknown>): DeploymentRollbackInput {
|
|
25
|
+
return {
|
|
26
|
+
environment: identifier(input.environment || 'production', 'Environment', true),
|
|
27
|
+
site: identifier(input.site, 'Site') || undefined,
|
|
28
|
+
release: identifier(input.release || input.to, 'Release') || undefined,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function commandArgs(input: DeploymentRollbackInput, preview: boolean): string[] {
|
|
33
|
+
const args = ['deploy:rollback']
|
|
34
|
+
if (input.site) args.push(input.site)
|
|
35
|
+
args.push('--env', input.environment)
|
|
36
|
+
if (input.release) args.push('--to', input.release)
|
|
37
|
+
if (preview) args.push('--dry-run')
|
|
38
|
+
args.push('--no-interaction')
|
|
39
|
+
return args
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function plainOutput(value: string): string {
|
|
43
|
+
return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function run(input: DeploymentRollbackInput, preview: boolean): Promise<string> {
|
|
47
|
+
const child = Bun.spawn([`${process.cwd()}/buddy`, ...commandArgs(input, preview)], {
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
50
|
+
stdout: 'pipe',
|
|
51
|
+
stderr: 'pipe',
|
|
52
|
+
})
|
|
53
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
54
|
+
new Response(child.stdout).text(),
|
|
55
|
+
new Response(child.stderr).text(),
|
|
56
|
+
child.exited,
|
|
57
|
+
])
|
|
58
|
+
const output = plainOutput([stdout, stderr].filter(Boolean).join('\n'))
|
|
59
|
+
if (exitCode !== 0)
|
|
60
|
+
throw new DeploymentRollbackError(output || 'The rollback command failed.')
|
|
61
|
+
return output
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function previewDeploymentRollback(input: DeploymentRollbackInput) {
|
|
65
|
+
const output = await run(input, true)
|
|
66
|
+
const revision = new Bun.CryptoHasher('sha256').update(JSON.stringify(input)).update(output).digest('hex')
|
|
67
|
+
return {
|
|
68
|
+
revision,
|
|
69
|
+
environment: input.environment,
|
|
70
|
+
site: input.site || null,
|
|
71
|
+
release: input.release || null,
|
|
72
|
+
target: input.release || 'previous preserved release',
|
|
73
|
+
output,
|
|
74
|
+
warnings: [
|
|
75
|
+
'The active release pointer and service runtime will change.',
|
|
76
|
+
'Database migrations are not reversed automatically.',
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function executeDeploymentRollback(input: DeploymentRollbackInput, revision: string, confirmation: string) {
|
|
82
|
+
const preview = await previewDeploymentRollback(input)
|
|
83
|
+
if (preview.revision !== revision)
|
|
84
|
+
throw new DeploymentRollbackError('The rollback preview changed. Review the current preview before continuing.')
|
|
85
|
+
if (confirmation !== `rollback ${input.environment}`)
|
|
86
|
+
throw new DeploymentRollbackError(`Type rollback ${input.environment} to confirm this recovery action.`)
|
|
87
|
+
const output = await run(input, false)
|
|
88
|
+
return { message: `Rollback for ${input.site || input.environment} completed.`, output }
|
|
89
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { operationsControlPlane } from './control-plane'
|
|
4
|
+
|
|
5
|
+
export default new Action({
|
|
6
|
+
name: 'AuditIndexAction',
|
|
7
|
+
description: 'Returns the append-only operator event stream and its correlated operations.',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
apiResponse: true,
|
|
10
|
+
async handle(_request: RequestInstance) {
|
|
11
|
+
const controlPlane = operationsControlPlane()
|
|
12
|
+
const events = controlPlane.store.listEvents({ projectId: controlPlane.project.id, limit: 500 })
|
|
13
|
+
const operations = controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 200 })
|
|
14
|
+
return {
|
|
15
|
+
events: events.map(event => ({ ...event, actorName: event.actorId ? controlPlane.store.getActor(event.actorId)?.displayName || 'Unknown operator' : 'System' })),
|
|
16
|
+
operations,
|
|
17
|
+
summary: {
|
|
18
|
+
events: events.length,
|
|
19
|
+
operators: new Set(events.map(event => event.actorId).filter(Boolean)).size,
|
|
20
|
+
failures: events.filter(event => event.level === 'error').length,
|
|
21
|
+
latestSequence: events.reduce((latest, event) => Math.max(latest, event.sequence), 0),
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperator, operationsControlPlane } from './control-plane'
|
|
5
|
+
import { stringValue } from './recovery-input'
|
|
6
|
+
import { releaseStore } from './operations-runtime'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'ChangeApprovalAction',
|
|
10
|
+
description: 'Approves or rejects a release awaiting operator review.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const body = request.all() as Record<string, unknown>
|
|
15
|
+
const decision = stringValue(body.decision)
|
|
16
|
+
if (!['approved', 'rejected'].includes(decision))
|
|
17
|
+
return response.json({ message: 'Decision must be approved or rejected.' }, 422)
|
|
18
|
+
const store = releaseStore()
|
|
19
|
+
const release = store.get(String(request.getParam('id') || ''))
|
|
20
|
+
if (!release || release.projectId !== operationsControlPlane().project.id)
|
|
21
|
+
return response.json({ message: 'Release approval was not found.' }, 404)
|
|
22
|
+
if (release.status !== 'awaiting_approval')
|
|
23
|
+
return response.json({ message: 'This release is no longer awaiting approval.' }, 409)
|
|
24
|
+
const actor = await dashboardOperator(request)
|
|
25
|
+
const updated = store.approve(release.id, { actorId: actor.id, decision: decision as 'approved' | 'rejected', comment: stringValue(body.comment) || undefined })
|
|
26
|
+
return { success: true, release: updated, approvals: store.approvals(release.id) }
|
|
27
|
+
},
|
|
28
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { operationsControlPlane, operationsEnvironment, recentOperatorOperations } from './control-plane'
|
|
4
|
+
import { migrationPlan } from './migration-operations'
|
|
5
|
+
import { releaseStore } from './operations-runtime'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'ChangeIndexAction',
|
|
9
|
+
description: 'Returns one review surface for pending schema, deployment, and release changes.',
|
|
10
|
+
method: 'GET',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(_request: RequestInstance) {
|
|
13
|
+
const controlPlane = operationsControlPlane()
|
|
14
|
+
const environment = operationsEnvironment(controlPlane)
|
|
15
|
+
const releases = releaseStore().list({ projectId: controlPlane.project.id, environmentId: environment?.id })
|
|
16
|
+
const pendingReleases = releases.filter(release => release.status === 'awaiting_approval')
|
|
17
|
+
const migrations = await migrationPlan()
|
|
18
|
+
return {
|
|
19
|
+
environment,
|
|
20
|
+
migrations,
|
|
21
|
+
pendingReleases: pendingReleases.map(release => ({ ...release, approvals: releaseStore().approvals(release.id) })),
|
|
22
|
+
activeOperations: controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 100 }).filter(operation => ['queued', 'running'].includes(operation.state)),
|
|
23
|
+
recentOperations: recentOperatorOperations('dashboard.', 30),
|
|
24
|
+
summary: {
|
|
25
|
+
migrationChanges: migrations.summary.pending,
|
|
26
|
+
destructiveChanges: migrations.summary.destructive,
|
|
27
|
+
approvals: pendingReleases.length,
|
|
28
|
+
activeOperations: controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 100 }).filter(operation => ['queued', 'running'].includes(operation.state)).length,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { operationsControlPlane, operationsEnvironment, recentOperatorOperations } from './control-plane'
|
|
4
|
+
import { alertStore } from './operations-runtime'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'IncidentIndexAction',
|
|
8
|
+
description: 'Returns native application alerts, rules, health checks, and notification delivery state.',
|
|
9
|
+
method: 'GET',
|
|
10
|
+
apiResponse: true,
|
|
11
|
+
async handle(_request: RequestInstance) {
|
|
12
|
+
const controlPlane = operationsControlPlane()
|
|
13
|
+
const environment = operationsEnvironment(controlPlane)
|
|
14
|
+
const store = alertStore()
|
|
15
|
+
const alerts = store.listAlerts(controlPlane.project.id, { environmentId: environment?.id, limit: 200 })
|
|
16
|
+
return {
|
|
17
|
+
environment,
|
|
18
|
+
alerts: alerts.map(alert => ({ ...alert, ownerName: alert.ownerActorId ? controlPlane.store.getActor(alert.ownerActorId)?.displayName : undefined })),
|
|
19
|
+
rules: store.listRules(controlPlane.project.id, environment?.id),
|
|
20
|
+
healthChecks: store.listHealthChecks(controlPlane.project.id, environment?.id),
|
|
21
|
+
channels: store.listChannels(controlPlane.organization.id),
|
|
22
|
+
deliveries: store.listDeliveries({ limit: 100 }),
|
|
23
|
+
operations: recentOperatorOperations('dashboard.incidents.', 20),
|
|
24
|
+
summary: {
|
|
25
|
+
firing: alerts.filter(alert => alert.state === 'firing').length,
|
|
26
|
+
critical: alerts.filter(alert => alert.state === 'firing' && alert.severity === 'critical').length,
|
|
27
|
+
acknowledged: alerts.filter(alert => Boolean(alert.acknowledgedAt)).length,
|
|
28
|
+
rules: store.listRules(controlPlane.project.id, environment?.id).filter(rule => rule.enabled).length,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperator, operationsControlPlane, trackOperatorOperation } from './control-plane'
|
|
5
|
+
import { stringValue } from './recovery-input'
|
|
6
|
+
import { alertStore } from './operations-runtime'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'IncidentUpdateAction',
|
|
10
|
+
description: 'Acknowledges, assigns, or silences a native application alert.',
|
|
11
|
+
method: 'PATCH',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const body = request.all() as Record<string, unknown>
|
|
15
|
+
const action = stringValue(body.action)
|
|
16
|
+
if (!['acknowledge', 'assign_self', 'unassign', 'silence'].includes(action))
|
|
17
|
+
return response.json({ message: 'Unsupported incident action.' }, 422)
|
|
18
|
+
const store = alertStore()
|
|
19
|
+
const alert = store.getAlert(String(request.getParam('id') || ''))
|
|
20
|
+
if (!alert || alert.projectId !== operationsControlPlane().project.id)
|
|
21
|
+
return response.json({ message: 'Alert not found.' }, 404)
|
|
22
|
+
const actor = await dashboardOperator(request)
|
|
23
|
+
const tracked = await trackOperatorOperation(request, `dashboard.incidents.${action}`, { alertId: alert.id }, async () => {
|
|
24
|
+
if (action === 'acknowledge') return store.acknowledge(alert.id, actor.id)
|
|
25
|
+
if (action === 'assign_self') return store.assign(alert.id, actor.id, actor.id)
|
|
26
|
+
if (action === 'unassign') return store.assign(alert.id, undefined, actor.id)
|
|
27
|
+
const until = stringValue(body.until)
|
|
28
|
+
if (!until || Number.isNaN(new Date(until).getTime()) || new Date(until) <= new Date())
|
|
29
|
+
throw new Error('A future silence expiry is required.')
|
|
30
|
+
return store.silenceAlert(alert.id, new Date(until).toISOString(), actor.id)
|
|
31
|
+
})
|
|
32
|
+
return { success: true, alert: tracked.result, operation: tracked.operation }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { trackOperatorOperation } from './control-plane'
|
|
6
|
+
import { applyMigrationPlan, MigrationOperationError } from './migration-operations'
|
|
7
|
+
import { stringValue } from './recovery-input'
|
|
8
|
+
|
|
9
|
+
export default new Action({
|
|
10
|
+
name: 'MigrationApplyAction',
|
|
11
|
+
description: 'Applies an unchanged, explicitly confirmed model-derived migration plan.',
|
|
12
|
+
method: 'POST',
|
|
13
|
+
apiResponse: true,
|
|
14
|
+
async handle(request: RequestInstance) {
|
|
15
|
+
const input = request.all() as Record<string, unknown>
|
|
16
|
+
const revision = stringValue(input.revision)
|
|
17
|
+
const confirmation = stringValue(input.confirmation)
|
|
18
|
+
if (!revision || !confirmation)
|
|
19
|
+
return response.json({ message: 'Plan revision and typed confirmation are required.' }, 422)
|
|
20
|
+
try {
|
|
21
|
+
const tracked = await trackOperatorOperation(request, 'dashboard.migrations.apply', { revision }, () => applyMigrationPlan({ revision, confirmation }))
|
|
22
|
+
return { success: true, ...tracked.result, operation: tracked.operation }
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof MigrationOperationError)
|
|
26
|
+
return response.json({ message: error.message }, 409)
|
|
27
|
+
return dashboardOperationalError(error, 'The migration plan could not be applied.', 'MigrationApplyAction')
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
4
|
+
import { recentOperatorOperations } from './control-plane'
|
|
5
|
+
import { migrationPlan, reconcileMigrationLedgerPlan } from './migration-operations'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'MigrationIndexAction',
|
|
9
|
+
description: 'Returns the model-derived schema plan and migration ledger health.',
|
|
10
|
+
method: 'GET',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(_request: RequestInstance) {
|
|
13
|
+
try {
|
|
14
|
+
return {
|
|
15
|
+
...await migrationPlan(),
|
|
16
|
+
reconciliation: await reconcileMigrationLedgerPlan(false),
|
|
17
|
+
operatorOperations: recentOperatorOperations('dashboard.migrations.', 20),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return dashboardOperationalError(error, 'Migration operations could not be loaded.', 'MigrationIndexAction')
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { trackOperatorOperation } from './control-plane'
|
|
5
|
+
import { migrationPlan, reconcileMigrationLedgerPlan } from './migration-operations'
|
|
6
|
+
import { stringValue } from './recovery-input'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'MigrationReconcileAction',
|
|
10
|
+
description: 'Conservatively reconciles provable migration ledger drift.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const input = request.all() as Record<string, unknown>
|
|
15
|
+
const plan = await migrationPlan()
|
|
16
|
+
if (stringValue(input.revision) !== plan.revision)
|
|
17
|
+
return response.json({ message: 'The migration state changed. Refresh before reconciling.' }, 409)
|
|
18
|
+
if (stringValue(input.confirmation) !== `reconcile ${plan.environment}`)
|
|
19
|
+
return response.json({ message: `Type reconcile ${plan.environment} to confirm ledger repair.` }, 422)
|
|
20
|
+
const tracked = await trackOperatorOperation(request, 'dashboard.migrations.reconcile', { revision: plan.revision }, () => reconcileMigrationLedgerPlan(true))
|
|
21
|
+
return { success: true, result: tracked.result, operation: tracked.operation }
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { BackupDestinationProvider } from '@stacksjs/ts-cloud'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
6
|
+
import { appendOperatorEvent } from './control-plane'
|
|
7
|
+
import { positiveNumber, stringValue } from './recovery-input'
|
|
8
|
+
import { recoveryEnvironment, recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
|
|
9
|
+
|
|
10
|
+
export default new Action({
|
|
11
|
+
name: 'RecoveryDestinationStoreAction',
|
|
12
|
+
description: 'Creates a native recovery destination with encrypted optional credentials.',
|
|
13
|
+
method: 'POST',
|
|
14
|
+
apiResponse: true,
|
|
15
|
+
async handle(request: RequestInstance) {
|
|
16
|
+
const input = request.all() as Record<string, unknown>
|
|
17
|
+
const name = stringValue(input.name)
|
|
18
|
+
const provider = stringValue(input.provider) as BackupDestinationProvider
|
|
19
|
+
const bucket = stringValue(input.bucket)
|
|
20
|
+
const encryption = stringValue(input.encryption) || 'provider'
|
|
21
|
+
if (!name)
|
|
22
|
+
return response.json({ message: 'A destination name is required.' }, 422)
|
|
23
|
+
if (!['aws_s3', 's3_compatible', 'aws_backup'].includes(provider))
|
|
24
|
+
return response.json({ message: 'Choose a supported destination provider.' }, 422)
|
|
25
|
+
if (provider !== 'aws_backup' && !bucket)
|
|
26
|
+
return response.json({ message: 'An object storage bucket is required.' }, 422)
|
|
27
|
+
if (!['provider', 'client_side', 'both'].includes(encryption))
|
|
28
|
+
return response.json({ message: 'Choose a supported encryption mode.' }, 422)
|
|
29
|
+
|
|
30
|
+
const runtime = recoveryRuntime()
|
|
31
|
+
const { controlPlane } = recoveryEnvironment()
|
|
32
|
+
const secretRoot = `secret://data-services/backups/${controlPlane.project.id}/${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
|
|
33
|
+
const credentials = input.credentials && typeof input.credentials === 'object'
|
|
34
|
+
? input.credentials as Record<string, unknown>
|
|
35
|
+
: undefined
|
|
36
|
+
const accessKeyId = stringValue(credentials?.accessKeyId)
|
|
37
|
+
const secretAccessKey = stringValue(credentials?.secretAccessKey)
|
|
38
|
+
const sessionToken = stringValue(credentials?.sessionToken)
|
|
39
|
+
const encryptionKey = stringValue(input.encryptionKey)
|
|
40
|
+
const storedSecrets: string[] = []
|
|
41
|
+
if ((accessKeyId || secretAccessKey) && (!accessKeyId || !secretAccessKey))
|
|
42
|
+
return response.json({ message: 'Both access key id and secret access key are required.' }, 422)
|
|
43
|
+
if (encryption !== 'provider' && encryptionKey.length < 32)
|
|
44
|
+
return response.json({ message: 'Client encryption requires a key of at least 32 characters.' }, 422)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
let credentialRef: string | undefined
|
|
48
|
+
let encryptionKeyRef: string | undefined
|
|
49
|
+
if (accessKeyId || secretAccessKey) {
|
|
50
|
+
credentialRef = `${secretRoot}/credentials`
|
|
51
|
+
await runtime.secrets.put(credentialRef, JSON.stringify({ accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }))
|
|
52
|
+
storedSecrets.push(credentialRef)
|
|
53
|
+
}
|
|
54
|
+
if (encryption !== 'provider') {
|
|
55
|
+
encryptionKeyRef = `${secretRoot}/encryption-key`
|
|
56
|
+
await runtime.secrets.put(encryptionKeyRef, encryptionKey)
|
|
57
|
+
storedSecrets.push(encryptionKeyRef)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const destination = runtime.store.createDestination({
|
|
61
|
+
organizationId: controlPlane.organization.id,
|
|
62
|
+
projectId: controlPlane.project.id,
|
|
63
|
+
name,
|
|
64
|
+
provider,
|
|
65
|
+
endpoint: stringValue(input.endpoint) || undefined,
|
|
66
|
+
endpointPolicy: input.allowPrivate === true ? 'allow_private' : 'public_https',
|
|
67
|
+
bucket: bucket || undefined,
|
|
68
|
+
prefix: stringValue(input.prefix),
|
|
69
|
+
region: stringValue(input.region) || undefined,
|
|
70
|
+
forcePathStyle: input.forcePathStyle === true,
|
|
71
|
+
credentialRef,
|
|
72
|
+
encryption: encryption as 'provider' | 'client_side' | 'both',
|
|
73
|
+
encryptionKeyRef,
|
|
74
|
+
immutability: {
|
|
75
|
+
objectLock: Number(input.lockDays) > 0,
|
|
76
|
+
defaultRetentionDays: Number(input.lockDays) > 0 ? positiveNumber(input.lockDays, 1, 36_500) : undefined,
|
|
77
|
+
},
|
|
78
|
+
status: 'untested',
|
|
79
|
+
})
|
|
80
|
+
await appendOperatorEvent(request, 'backup.destination_created', {
|
|
81
|
+
destinationId: destination.id,
|
|
82
|
+
provider: destination.provider,
|
|
83
|
+
})
|
|
84
|
+
return response.json({ success: true, destination: safeRecoveryDestination(destination) }, 201)
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await Promise.all(storedSecrets.map(reference => runtime.secrets.remove(reference).catch(() => {})))
|
|
88
|
+
return dashboardOperationalError(error, 'The recovery destination could not be created.', 'RecoveryDestinationStoreAction', 422)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { S3BackupDestinationAdapter } from '@stacksjs/ts-cloud'
|
|
5
|
+
import { appendOperatorEvent } from './control-plane'
|
|
6
|
+
import { recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'RecoveryDestinationTestAction',
|
|
10
|
+
description: 'Tests recovery destination write, read, checksum, and cleanup behavior.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const runtime = recoveryRuntime()
|
|
15
|
+
const destination = runtime.store.getDestination(String(request.getParam('id') || ''))
|
|
16
|
+
if (!destination)
|
|
17
|
+
return response.json({ message: 'Recovery destination not found.' }, 404)
|
|
18
|
+
if (destination.provider === 'aws_backup')
|
|
19
|
+
return response.json({ message: 'AWS Backup destinations are verified by creating a provider recovery point.' }, 409)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await new S3BackupDestinationAdapter(runtime.secrets).test(destination)
|
|
23
|
+
const tested = runtime.store.recordDestinationTest(destination.id, { ok: true })
|
|
24
|
+
await appendOperatorEvent(request, 'backup.destination_tested', { destinationId: destination.id, ok: true })
|
|
25
|
+
return { success: true, destination: safeRecoveryDestination(tested), message: `${destination.name} passed write, read, checksum, and cleanup checks.` }
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
29
|
+
runtime.store.recordDestinationTest(destination.id, { ok: false, error: message })
|
|
30
|
+
await appendOperatorEvent(request, 'backup.destination_tested', { destinationId: destination.id, ok: false })
|
|
31
|
+
return response.json({ message }, 502)
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
4
|
+
import { recoveryEnvironment, recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'RecoveryIndexAction',
|
|
8
|
+
description: 'Returns recovery coverage, destinations, policies, points, jobs, and eligible resources.',
|
|
9
|
+
method: 'GET',
|
|
10
|
+
apiResponse: true,
|
|
11
|
+
async handle(_request: RequestInstance) {
|
|
12
|
+
try {
|
|
13
|
+
const { controlPlane, environment } = recoveryEnvironment()
|
|
14
|
+
const runtime = recoveryRuntime()
|
|
15
|
+
const policies = runtime.store.listPolicies(controlPlane.project.id, environment.id)
|
|
16
|
+
const policyIds = new Set(policies.map(policy => policy.id))
|
|
17
|
+
const recoveryPoints = runtime.store
|
|
18
|
+
.listRecoveryPoints(controlPlane.project.id)
|
|
19
|
+
.filter(point => !point.policyId || policyIds.has(point.policyId))
|
|
20
|
+
const jobs = runtime.store
|
|
21
|
+
.listJobs(controlPlane.project.id)
|
|
22
|
+
.filter(job => !job.policyId || policyIds.has(job.policyId))
|
|
23
|
+
const coverage = runtime.store
|
|
24
|
+
.coverage(controlPlane.project.id)
|
|
25
|
+
.filter(item => policyIds.has(item.policy.id))
|
|
26
|
+
const resources = controlPlane.store.listResources(controlPlane.project.id, environment.id)
|
|
27
|
+
const dataServices = runtime.dataServices.list(controlPlane.project.id, environment.id).map(service => ({
|
|
28
|
+
id: service.id,
|
|
29
|
+
name: service.name,
|
|
30
|
+
engine: service.engine,
|
|
31
|
+
provider: service.provider,
|
|
32
|
+
status: service.status,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
environment: { id: environment.id, name: environment.name, slug: environment.slug },
|
|
37
|
+
coverage,
|
|
38
|
+
destinations: runtime.store.listDestinations(controlPlane.project.id).map(safeRecoveryDestination),
|
|
39
|
+
policies,
|
|
40
|
+
recoveryPoints,
|
|
41
|
+
jobs,
|
|
42
|
+
resources: resources.map(resource => ({ id: resource.id, kind: resource.kind, name: resource.name, slug: resource.slug })),
|
|
43
|
+
dataServices,
|
|
44
|
+
summary: {
|
|
45
|
+
policies: policies.length,
|
|
46
|
+
protected: coverage.filter(item => !item.missedRpo && item.destinationHealthy).length,
|
|
47
|
+
missedRpo: coverage.filter(item => item.missedRpo).length,
|
|
48
|
+
unverified: recoveryPoints.filter(point => point.verificationState !== 'verified' && point.status === 'available').length,
|
|
49
|
+
queuedJobs: jobs.filter(job => job.status === 'queued' || job.status === 'running').length,
|
|
50
|
+
latestPoint: recoveryPoints.map(point => point.pointInTime).sort().at(-1) || null,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return dashboardOperationalError(error, 'Recovery operations could not be loaded.', 'RecoveryIndexAction')
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
})
|