Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 83 additions & 12 deletions docs/openapi/monitoring-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -10128,8 +10128,7 @@
"$ref": "#/components/schemas/TestNotificationPolicyRequest"
}
}
},
"required": true
}
},
"responses": {
"200": {
Expand Down Expand Up @@ -12921,6 +12920,7 @@
"Status Data"
],
"summary": "Get a single service by slug or UUID with current status, components, and recent incidents",
"description": "When ``summary=true``, the inline ``components`` list is trimmed to groups + showcase leaves + currently-impacted leaves + ungrouped leaves, and a ``componentsSummary`` block is added with the trimmed counts. Powers SSR for vendors with hundreds of components (Snowflake, Cloudflare, DigitalOcean) without OOM-ing the renderer. Default false for full back-compat.",
"operationId": "getService",
"parameters": [
{
Expand All @@ -12930,6 +12930,16 @@
"schema": {
"type": "string"
}
},
{
"name": "summary",
"in": "query",
"description": "Return a curated subset of components (groups + showcase + impacted + ungrouped) and a componentsSummary block; default false",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
Expand Down Expand Up @@ -13032,6 +13042,7 @@
"Status Data"
],
"summary": "List active components for a service with current status and inline uptime",
"description": "When ``groupId`` is supplied, only direct children of that group are returned — used by the pSEO renderer to lazy-load the leaves under a group that summary mode trimmed. Without ``groupId`` the response includes every active component for the service.",
"operationId": "getComponents",
"parameters": [
{
Expand All @@ -13041,6 +13052,16 @@
"schema": {
"type": "string"
}
},
{
"name": "groupId",
"in": "query",
"description": "Restrict result to direct children of this group component id",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
Expand Down Expand Up @@ -21894,6 +21915,35 @@
},
"description": "A single component position"
},
"ComponentsSummaryDto": {
"required": [
"groupComponentCounts",
"includedCount",
"totalCount"
],
"type": "object",
"properties": {
"totalCount": {
"type": "integer",
"description": "Total active components for this service across all groups",
"format": "int32"
},
"includedCount": {
"type": "integer",
"description": "Number of components actually returned in the inline ``components`` list",
"format": "int32"
},
"groupComponentCounts": {
"type": "object",
"additionalProperties": {
"type": "integer",
"description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip",
"format": "int32"
},
"description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip"
}
}
},
"ComponentStatusDto": {
"required": [
"id",
Expand Down Expand Up @@ -22239,8 +22289,7 @@
"CreateEnvironmentRequest": {
"required": [
"name",
"slug",
"isDefault"
"slug"
],
"type": "object",
"properties": {
Expand Down Expand Up @@ -22269,7 +22318,8 @@
},
"isDefault": {
"type": "boolean",
"description": "Whether this is the default environment for new monitors"
"description": "Whether this is the default environment for new monitors (default: false)",
"nullable": true
}
}
},
Expand Down Expand Up @@ -22992,11 +23042,10 @@
"subscribedEvents": {
"minItems": 1,
"type": "array",
"description": "Event types to deliver, e.g. monitor.created, incident.resolved",
"description": "Event types to deliver",
"items": {
"minLength": 1,
"type": "string",
"description": "Event types to deliver, e.g. monitor.created, incident.resolved",
"enum": [
"monitor.created",
"monitor.updated",
Expand Down Expand Up @@ -23895,6 +23944,7 @@
},
"ErrorResponse": {
"required": [
"code",
"message",
"status",
"timestamp"
Expand All @@ -23907,6 +23957,11 @@
"format": "int32",
"example": 404
},
"code": {
"type": "string",
"description": "Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status",
"example": "NOT_FOUND"
},
"message": {
"type": "string",
"description": "Human-readable error message; safe to surface to end users",
Expand All @@ -23917,13 +23972,21 @@
"description": "Server time when the error was produced (epoch milliseconds)",
"format": "int64",
"example": 1737302400000
},
"requestId": {
"type": "string",
"description": "Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets.",
"nullable": true,
"example": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a"
}
},
"description": "Uniform error envelope returned for every non-2xx response",
"example": {
"status": 404,
"code": "NOT_FOUND",
"message": "Monitor not found",
"timestamp": 1737302400000
"timestamp": 1737302400000,
"requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a"
}
},
"EscalationChain": {
Expand Down Expand Up @@ -25443,7 +25506,7 @@
"properties": {
"type": {
"type": "string",
"description": "Rule type, e.g. severity_gte, monitor_id_in, region_in",
"description": "Rule type used to evaluate incidents and status events",
"enum": [
"severity_gte",
"monitor_id_in",
Expand All @@ -25452,7 +25515,8 @@
"monitor_type_in",
"service_id_in",
"resource_group_id_in",
"component_name_in"
"component_name_in",
"monitor_tag_in"
]
},
"value": {
Expand Down Expand Up @@ -28172,6 +28236,14 @@
"$ref": "#/components/schemas/ServiceComponentDto"
}
},
"componentsSummary": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/ComponentsSummaryDto"
}
]
},
"uptime": {
"nullable": true,
"allOf": [
Expand Down Expand Up @@ -32541,7 +32613,6 @@
"nullable": true,
"items": {
"type": "string",
"description": "Replace subscribed events; null preserves current",
"enum": [
"monitor.created",
"monitor.updated",
Expand Down Expand Up @@ -32952,4 +33023,4 @@
}
}
}
}
}
6 changes: 3 additions & 3 deletions src/commands/alert-channels/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export default class AlertChannelsTest extends Command {
async run() {
const {args, flags} = await this.parse(AlertChannelsTest)
const client = buildClient(flags)
const resp = (await checkedFetch(
const resp = await checkedFetch(
client.POST('/api/v1/alert-channels/{id}/test', {params: {path: {id: args.id}}}),
)) as {data?: {success?: boolean}}
this.log(resp.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.')
)
this.log(resp?.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.')
}
}
5 changes: 4 additions & 1 deletion src/commands/auth/context/delete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Command, Args} from '@oclif/core'
import {globalFlags} from '../../../lib/base-command.js'
import {removeContext} from '../../../lib/auth.js'
import {EXIT_CODES} from '../../../lib/errors.js'

export default class AuthContextDelete extends Command {
static description = 'Delete an auth context'
Expand All @@ -11,7 +12,9 @@ export default class AuthContextDelete extends Command {
async run() {
const {args} = await this.parse(AuthContextDelete)
const ok = removeContext(args.name)
if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) }
if (!ok) {
this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION})
}
this.log(`Context '${args.name}' deleted.`)
}
}
5 changes: 4 additions & 1 deletion src/commands/auth/context/use.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Command, Args} from '@oclif/core'
import {globalFlags} from '../../../lib/base-command.js'
import {setCurrentContext} from '../../../lib/auth.js'
import {EXIT_CODES} from '../../../lib/errors.js'

export default class AuthContextUse extends Command {
static description = 'Switch to a different auth context'
Expand All @@ -11,7 +12,9 @@ export default class AuthContextUse extends Command {
async run() {
const {args} = await this.parse(AuthContextUse)
const ok = setCurrentContext(args.name)
if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) }
if (!ok) {
this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION})
}
this.log(`Switched to context '${args.name}'.`)
}
}
18 changes: 8 additions & 10 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Command, Flags} from '@oclif/core'
import {globalFlags} from '../../lib/base-command.js'
import {createApiClient, checkedFetch, apiGet} from '../../lib/api-client.js'
import {saveContext, resolveApiUrl} from '../../lib/auth.js'
import {EXIT_CODES} from '../../lib/errors.js'
import * as readline from 'node:readline'

export default class AuthLogin extends Command {
Expand All @@ -25,18 +26,12 @@ export default class AuthLogin extends Command {
const client = createApiClient({baseUrl: apiUrl, token})

try {
const resp = (await checkedFetch(client.GET('/api/v1/auth/me'))) as {
data?: {
organization?: {name?: string; id?: string | number}
key?: {name?: string}
plan?: {tier?: string}
}
}
if (!resp.data) {
const resp = await checkedFetch(client.GET('/api/v1/auth/me'))
const me = resp?.data
if (!me) {
throw new Error('Empty response')
}

const me = resp.data
saveContext({name: flags.name, apiUrl, token}, true)
this.log('')
this.log(` Authenticated successfully.`)
Expand All @@ -57,7 +52,10 @@ export default class AuthLogin extends Command {
this.log(` Authenticated successfully.`)
this.log(` Context '${flags.name}' saved to ~/.devhelm/contexts.json`)
} catch {
this.error('Invalid token. Authentication failed.', {exit: 2})
// Token rejected by the API. Surface as DevhelmAuthError-equivalent
// (exit 11 — same as any other 4xx) so scripts can branch on the
// canonical API error code instead of a bespoke "auth login" exit.
this.error('Invalid token. Authentication failed.', {exit: EXIT_CODES.API})
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/commands/auth/token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Command} from '@oclif/core'
import {globalFlags} from '../../lib/base-command.js'
import {resolveToken} from '../../lib/auth.js'
import {EXIT_CODES} from '../../lib/errors.js'

export default class AuthToken extends Command {
static description = 'Print the current API token'
Expand All @@ -10,7 +11,9 @@ export default class AuthToken extends Command {
async run() {
const {flags} = await this.parse(AuthToken)
const token = flags['api-token'] || resolveToken()
if (!token) { this.error('No token found. Run `devhelm auth login` first.', {exit: 2}) }
if (!token) {
this.error('No token found. Run `devhelm auth login` first.', {exit: EXIT_CODES.VALIDATION})
}
this.log(token)
}
}
6 changes: 3 additions & 3 deletions src/commands/dependencies/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ export default class DependenciesTrack extends Command {
async run() {
const {args, flags} = await this.parse(DependenciesTrack)
const client = buildClient(flags)
const resp = (await checkedFetch(
const resp = await checkedFetch(
client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}}),
)) as {data?: {name?: string}}
this.log(`Now tracking '${resp.data?.name}' as a dependency.`)
)
this.log(`Now tracking '${resp?.data?.name ?? args.slug}' as a dependency.`)
}
}
15 changes: 11 additions & 4 deletions src/commands/deploy/force-unlock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Command, Flags} from '@oclif/core'
import {createApiClient, apiDelete} from '../../lib/api-client.js'
import {resolveToken, resolveApiUrl} from '../../lib/auth.js'
import {DevhelmApiError, EXIT_CODES} from '../../lib/errors.js'
import {urlFlag} from '../../lib/validators.js'

export default class DeployForceUnlock extends Command {
Expand Down Expand Up @@ -40,7 +41,10 @@ export default class DeployForceUnlock extends Command {

const token = flags['api-token'] ?? resolveToken()
if (!token) {
this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1})
this.error(
'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.',
{exit: EXIT_CODES.VALIDATION},
)
}

const client = createApiClient({
Expand All @@ -53,12 +57,15 @@ export default class DeployForceUnlock extends Command {
await apiDelete(client, '/api/v1/deploy/lock/force')
this.log('Deploy lock released.')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('404') || msg.includes('Not Found')) {
// Branch on the typed error rather than substring-matching the message:
// the canonical DevhelmApiError carries a real HTTP status, so 404
// ("no lock to release") is unambiguous and we exit successfully.
if (err instanceof DevhelmApiError && err.status === 404) {
this.log('No active deploy lock found.')
return
}
this.error(`Failed to release lock: ${msg}`, {exit: 1})
const msg = err instanceof Error ? err.message : String(err)
this.error(`Failed to release lock: ${msg}`, {exit: EXIT_CODES.API})
}
}
}
Loading
Loading