diff --git a/addon/components/api-event/credential-cell.hbs b/addon/components/api-event/credential-cell.hbs new file mode 100644 index 0000000..bf65227 --- /dev/null +++ b/addon/components/api-event/credential-cell.hbs @@ -0,0 +1,8 @@ +{{#if @value}} + + + {{or (get @column.cellComponentArgs.names @value) (mask-key @value)}} + +{{else}} + — +{{/if}} diff --git a/addon/components/api-event/data.hbs b/addon/components/api-event/data.hbs deleted file mode 100644 index 94f8974..0000000 --- a/addon/components/api-event/data.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - {{#if @apiEvent.data}} - - {{else}} -
- - {{t "developers.component.api-event.data.no-data"}} - -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/api-event/details.hbs b/addon/components/api-event/details.hbs deleted file mode 100644 index c36d9fe..0000000 --- a/addon/components/api-event/details.hbs +++ /dev/null @@ -1,37 +0,0 @@ - - <:title> -
- -
- - <:default> - - - - - - - - - - - - - - - -
- {{t "developers.common.date"}} - - {{@apiEvent.createdAt}} -
- {{t "developers.common.source"}} - - {{@apiEvent.source}} -
- {{t "developers.common.description"}} - - {{@apiEvent.description}} -
- -
\ No newline at end of file diff --git a/addon/components/api-event/panel.hbs b/addon/components/api-event/panel.hbs new file mode 100644 index 0000000..012bddb --- /dev/null +++ b/addon/components/api-event/panel.hbs @@ -0,0 +1,76 @@ +{{! template-lint-disable require-presentational-children no-invalid-interactive }} +
+
+ +
+ {{this.event.event}} {{this.event.public_id}} + {{this.event.description}} · {{this.createdAt}} +
+
+
+
+ + + {{#if (eq this.activeTab "payload")}} + + {{else if (eq this.activeTab "deliveries")}} + + <:default> + {{#if this.load.isRunning}} +
+ {{else if this.error}} + + {{else if this.deliveryRows.length}} + + + {{#each this.deliveryRows as |row|}} + + + + + + {{/each}} + +
+
+ + + {{row.shortUrl}} + {{t "developers.webhooks.deliveries.attempt-n" n=(or row.attempt.attempt 1)}} · {{or row.duration "—"}} · {{row.at}} + +
+
+ {{else}} + + {{/if}} + +
+ {{else}} +
+
{{t "developers.common.source"}}
+
{{#if this.event.method}}{{this.event.method}}{{/if}}
+
{{t "developers.webhooks.index.credential"}}
+
{{#if this.credentialName}}{{this.credentialName}}{{else}}—{{/if}}
+
{{t "developers.events.panel.resource"}}
+
{{#if this.resourceId}}{{this.resourceId}}{{else}}—{{/if}}
+
{{t "developers.common.description"}}
+
{{this.event.description}}
+
{{t "developers.common.time"}}
+
{{this.createdAt}}
+
{{t "developers.application.sidebar.items.channels"}}
+
+ {{#each this.channels as |channel|}} + + {{/each}} +
+
+
+ +
+ {{/if}} +
+
diff --git a/addon/components/api-event/panel.js b/addon/components/api-event/panel.js new file mode 100644 index 0000000..fc77781 --- /dev/null +++ b/addon/components/api-event/panel.js @@ -0,0 +1,127 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { shortUrl, timeStamp, formatSeconds } from '../../utils/format'; +import { eventIcon } from '../../utils/resource-descriptors'; + +/** + * The event detail: facts, payload and the webhook deliveries this event + * produced. Used by the drawer and the full page. + */ +export default class ApiEventPanelComponent extends Component { + @service store; + @service intl; + @service hostRouter; + @service developerPanels; + @service notifications; + + @tracked deliveries = []; + @tracked error = null; + @tracked activeTab = 'overview'; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get event() { + return this.args.resource ?? this.args.model; + } + + get icon() { + return eventIcon(this.event?.event); + } + + get createdAt() { + return timeStamp(this.event?.created_at, 'MMM d, yyyy HH:mm:ss'); + } + + get resourceId() { + const data = this.event?.data; + + return data && typeof data === 'object' ? (data.public_id ?? data.id ?? null) : null; + } + + get credentialName() { + const id = this.event?.api_credential_uuid; + + if (!id) { + return null; + } + + return this.store.peekRecord('api-credential', id)?.name ?? id; + } + + get channels() { + const channels = []; + const companyId = this.event?.company_uuid; + + if (companyId) { + channels.push(`company.${companyId}`); + } + + if (this.event?.api_credential_uuid) { + channels.push(`api.${this.event.api_credential_uuid}`); + } + + return channels; + } + + get tabs() { + return [ + { id: 'overview', label: this.intl.t('developers.common.overview'), icon: 'circle-info' }, + { id: 'payload', label: this.intl.t('developers.events.panel.payload'), icon: 'code' }, + { id: 'deliveries', label: this.intl.t('developers.webhooks.deliveries.title'), icon: 'paper-plane', badge: this.deliveries.length || null }, + ]; + } + + get deliveryRows() { + return this.deliveries.map((attempt) => ({ + attempt, + shortUrl: shortUrl(attempt.url), + ok: attempt.status === 'successful', + statusText: [attempt.status_code, attempt.reason_phrase].filter(Boolean).join(' '), + duration: formatSeconds(attempt.duration), + at: timeStamp(attempt.sent_at ?? attempt.created_at), + })); + } + + @task *load() { + const event = this.event; + + if (!event?.id) { + return; + } + + try { + const deliveries = yield this.store.query('webhook-request-log', { api_event_uuid: event.id, limit: 50, sort: '-created_at' }); + this.deliveries = Array.from(deliveries); + this.error = null; + } catch (error) { + this.error = error?.message ?? null; + } + } + + @action setTab(tab) { + this.activeTab = tab.id; + } + + @action openAttempt(attempt) { + return this.developerPanels.openAttempt(attempt); + } + + @action openFullPage() { + return this.developerPanels.openFullPage('events.view', this.event); + } + + @action watchChannel(name) { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.sockets.view', name); + } + + @action copyId() { + return navigator.clipboard?.writeText(this.event.public_id).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied'))); + } +} diff --git a/addon/components/api-event/source-cell.hbs b/addon/components/api-event/source-cell.hbs new file mode 100644 index 0000000..ae734b3 --- /dev/null +++ b/addon/components/api-event/source-cell.hbs @@ -0,0 +1,6 @@ + + + {{#if @row.method}} + {{@row.method}} + {{/if}} + diff --git a/addon/components/api-key/drawer-header.hbs b/addon/components/api-key/drawer-header.hbs new file mode 100644 index 0000000..d8c090d --- /dev/null +++ b/addon/components/api-key/drawer-header.hbs @@ -0,0 +1,19 @@ +
+
+ + + + {{or this.apiKey.name (mask-key this.apiKey.key)}} + + + + {{this.apiKey.key}} + + +
+
+ +
+
diff --git a/addon/components/api-key/drawer-header.js b/addon/components/api-key/drawer-header.js new file mode 100644 index 0000000..673dba3 --- /dev/null +++ b/addon/components/api-key/drawer-header.js @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * Drawer header for an API key: identity block, header actions, open full page, close. + * Rendered by the resource context panel in place of its default header. + */ +export default class ApiKeyDrawerHeaderComponent extends Component { + @service developerPanels; + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get isExpired() { + const expiresAt = this.apiKey?.expires_at; + + return expiresAt ? new Date(expiresAt).getTime() < Date.now() : false; + } + + get tone() { + if (this.isExpired) { + return 'is-red'; + } + + return this.apiKey?.test_mode ? 'is-amber' : 'is-green'; + } + + get environment() { + if (this.isExpired) { + return { status: 'danger', key: 'developers.api-keys.badges.expired' }; + } + + return this.apiKey?.test_mode ? { status: 'warning', key: 'developers.common.test' } : { status: 'success', key: 'developers.common.live' }; + } + + @action openFullPage() { + return this.developerPanels.openFullPage('api-keys.index', { queryParams: { view_api_key: this.apiKey.id } }); + } +} diff --git a/addon/components/api-key/expiry-cell.hbs b/addon/components/api-key/expiry-cell.hbs new file mode 100644 index 0000000..4babd7d --- /dev/null +++ b/addon/components/api-key/expiry-cell.hbs @@ -0,0 +1,7 @@ + + {{#if this.showBadge}} + + {{else}} + {{this.text}} + {{/if}} + diff --git a/addon/components/api-key/expiry-cell.js b/addon/components/api-key/expiry-cell.js new file mode 100644 index 0000000..e667d96 --- /dev/null +++ b/addon/components/api-key/expiry-cell.js @@ -0,0 +1,70 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { timeStamp } from '../../utils/format'; +import { formatDistanceToNowStrict } from 'date-fns'; + +/** + * The expiry column: Never, "in 18 days" (amber under 30 days), or Expired (red). + */ +export default class ApiKeyExpiryCellComponent extends Component { + @service intl; + + get expiresAt() { + const value = this.args.row?.expires_at ?? this.args.value; + + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + + return Number.isNaN(date.getTime()) ? null : date; + } + + get state() { + const date = this.expiresAt; + + if (!date) { + return 'never'; + } + + const delta = date.getTime() - Date.now(); + + if (delta < 0) { + return 'expired'; + } + + if (delta < 30 * 86400000) { + return 'soon'; + } + + return 'later'; + } + + get text() { + const date = this.expiresAt; + + switch (this.state) { + case 'never': + return this.intl.t('developers.common.never'); + case 'expired': + return this.intl.t('developers.api-keys.badges.expired'); + case 'soon': + return this.intl.t('developers.api-keys.expiry-in', { when: formatDistanceToNowStrict(date) }); + default: + return timeStamp(date, 'yyyy-MM-dd'); + } + } + + get title() { + return this.expiresAt ? timeStamp(this.expiresAt, 'yyyy-MM-dd HH:mm') : null; + } + + get badgeStatus() { + return this.state === 'expired' ? 'danger' : 'warning'; + } + + get showBadge() { + return this.state === 'expired' || this.state === 'soon'; + } +} diff --git a/addon/components/api-key/key-cell.hbs b/addon/components/api-key/key-cell.hbs new file mode 100644 index 0000000..798f1f6 --- /dev/null +++ b/addon/components/api-key/key-cell.hbs @@ -0,0 +1,3 @@ + + {{mask-key @value}} + diff --git a/addon/components/api-key/last-used-cell.hbs b/addon/components/api-key/last-used-cell.hbs new file mode 100644 index 0000000..1e47180 --- /dev/null +++ b/addon/components/api-key/last-used-cell.hbs @@ -0,0 +1,7 @@ +{{#let (or @row.last_used_at @value) as |lastUsed|}} + {{#if lastUsed}} + {{time-ago lastUsed}} + {{else}} + {{t "developers.common.never-used"}} + {{/if}} +{{/let}} diff --git a/addon/components/api-key/panel.hbs b/addon/components/api-key/panel.hbs new file mode 100644 index 0000000..504f1f8 --- /dev/null +++ b/addon/components/api-key/panel.hbs @@ -0,0 +1,110 @@ +{{! template-lint-disable require-presentational-children no-invalid-interactive }} +
+
+ +
+ {{or this.apiKey.name (mask-key this.apiKey.key)}} + {{mask-key this.apiKey.key}} +
+
+ + + <:actions> +
diff --git a/addon/components/api-key/panel.js b/addon/components/api-key/panel.js new file mode 100644 index 0000000..4503e84 --- /dev/null +++ b/addon/components/api-key/panel.js @@ -0,0 +1,115 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { shortUrl, timeAgo } from '../../utils/format'; +import { endpointHealth, HEALTH_BADGE } from '../widget/endpoint-health'; + +/** + * The API key detail drawer: facts, linked webhook endpoints and the key's + * channel, recent requests. Opened by the developer-panels service. + */ +export default class ApiKeyPanelComponent extends Component { + @service store; + @service hostRouter; + @service apiKeyActions; + @service currentUser; + + @tracked webhooks = []; + @tracked requests = []; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get isExpired() { + const { expires_at } = this.apiKey ?? {}; + + if (!expires_at) { + return false; + } + + return new Date(expires_at).getTime() < Date.now(); + } + + get environmentBadge() { + if (this.isExpired) { + return { status: 'danger', text: 'Expired' }; + } + + return this.apiKey?.test_mode ? { status: 'warning', text: 'Test' } : { status: 'success', text: 'Live' }; + } + + get origins() { + const origins = this.apiKey?.browser_origins; + + return Array.isArray(origins) ? origins : []; + } + + get channelName() { + return `api.${this.apiKey?.id}`; + } + + get linkedWebhooks() { + return this.webhooks.map((webhook) => { + const health = endpointHealth({ status: webhook.status, success_rate: webhook.success_rate, deliveries: webhook.deliveries, failures: webhook.failures }); + + return { webhook, shortUrl: shortUrl(webhook.url), health, badge: HEALTH_BADGE[health] }; + }); + } + + get recentRequests() { + return this.requests.map((log) => ({ log, when: timeAgo(log.created_at) })); + } + + @task *load() { + const apiKey = this.apiKey; + + if (!apiKey?.id) { + return; + } + + try { + const [webhooks, requests] = yield Promise.all([ + this.store.query('webhook-endpoint', { api_credential_uuid: apiKey.id, limit: 10 }), + this.store.query('api-request-log', { key: apiKey.id, limit: 6, sort: '-created_at' }), + ]); + + this.webhooks = Array.from(webhooks); + this.requests = Array.from(requests); + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load'; + } + } + + @action openWebhook(webhook) { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.webhooks.view', webhook); + } + + @action watchChannel() { + this.args.close?.(); + return this.apiKeyActions.watchChannel(this.apiKey); + } + + @action viewRequests() { + this.args.close?.(); + return this.apiKeyActions.viewRequestLogs(this.apiKey); + } + + @action openRequest(log) { + return this.hostRouter.transitionTo('console.developers.logs.view', log); + } + + @action edit() { + return this.apiKeyActions.editApiKey(this.apiKey); + } +} diff --git a/addon/components/api-key/reveal.hbs b/addon/components/api-key/reveal.hbs new file mode 100644 index 0000000..f92ec1b --- /dev/null +++ b/addon/components/api-key/reveal.hbs @@ -0,0 +1,31 @@ +{{! The reveal-once step shared by create and roll. }} +
+
+ + {{or @warning (t "developers.api-keys.reveal.warning")}} +
+
+ +
+ {{@revealed.secret}} + +
+ {{t "developers.api-keys.reveal.secret-help"}} +
+
+ +
+ {{@revealed.key}} + +
+ {{t "developers.api-keys.reveal.key-help"}} +
+ +
diff --git a/addon/components/api-key/tabs/activity.hbs b/addon/components/api-key/tabs/activity.hbs new file mode 100644 index 0000000..7a2179a --- /dev/null +++ b/addon/components/api-key/tabs/activity.hbs @@ -0,0 +1,18 @@ +
+ + <:default> +
+ {{#each this.items as |item|}} +
+ +
+ {{t (concat "developers.api-keys.drawer.activity-" item.key)}} + {{time-stamp item.at "yyyy-MM-dd HH:mm"}} +
+
+ {{/each}} +
+
{{t "developers.api-keys.drawer.activity-note"}}
+ +
+
diff --git a/addon/components/api-key/tabs/activity.js b/addon/components/api-key/tabs/activity.js new file mode 100644 index 0000000..596a507 --- /dev/null +++ b/addon/components/api-key/tabs/activity.js @@ -0,0 +1,34 @@ +import Component from '@glimmer/component'; + +/** + * Activity tab: what the record itself tells us. A full audit trail needs the + * activities resource filtered by subject (v1.1). + */ +export default class ApiKeyTabsActivityComponent extends Component { + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get items() { + const items = []; + const apiKey = this.apiKey ?? {}; + + if (apiKey.expires_at) { + items.push({ key: 'expires', tone: new Date(apiKey.expires_at) < new Date() ? 'bad' : 'wait', at: apiKey.expires_at }); + } + + if (apiKey.updated_at && apiKey.updated_at !== apiKey.created_at) { + items.push({ key: 'updated', tone: 'wait', at: apiKey.updated_at }); + } + + if (apiKey.last_used_at) { + items.push({ key: 'last-used', tone: 'ok', at: apiKey.last_used_at }); + } + + items.push({ key: 'created', tone: 'ok', at: apiKey.created_at }); + + const sorted = items.sort((a, b) => new Date(b.at) - new Date(a.at)); + + return sorted.map((item, index) => ({ ...item, isLast: index === sorted.length - 1 })); + } +} diff --git a/addon/components/api-key/tabs/channel.hbs b/addon/components/api-key/tabs/channel.hbs new file mode 100644 index 0000000..2e180a7 --- /dev/null +++ b/addon/components/api-key/tabs/channel.hbs @@ -0,0 +1,25 @@ +
+ + <:actions> +
diff --git a/addon/components/api-key/tabs/channel.js b/addon/components/api-key/tabs/channel.js new file mode 100644 index 0000000..1a35e22 --- /dev/null +++ b/addon/components/api-key/tabs/channel.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * Channel tab: the key's own socket channel and what this browser has seen on it. + */ +export default class ApiKeyTabsChannelComponent extends Component { + @service socketChannels; + @service hostRouter; + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get channelName() { + return `api.${this.apiKey?.id}`; + } + + get state() { + return this.socketChannels.stateFor(this.channelName); + } + + @action watch() { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.sockets.view', this.channelName); + } +} diff --git a/addon/components/api-key/tabs/overview.hbs b/addon/components/api-key/tabs/overview.hbs new file mode 100644 index 0000000..be6c57a --- /dev/null +++ b/addon/components/api-key/tabs/overview.hbs @@ -0,0 +1,116 @@ +{{! template-lint-disable no-invalid-interactive require-presentational-children no-inline-styles }} +
+ {{#if this.insights.error}} + + {{else}} +
+
+
{{t "developers.api-keys.drawer.requests" days=this.retentionDays}}
+
{{#if this.insights.isLoaded}}{{format-number this.insights.total}}{{else}}{{/if}}
+
{{t "developers.api-keys.drawer.requests-hint" count=this.insights.logs.length}}
+
+
+
{{t "developers.api-keys.drawer.error-rate"}}
+
{{#if this.insights.isLoaded}}{{this.insights.errorRate}}%{{else}}{{/if}}
+
{{t "developers.api-keys.drawer.error-hint" count=this.insights.errors}}
+
+
+
{{t "developers.api-keys.drawer.p95"}}
+
{{#if this.insights.isLoaded}}{{if this.insights.p95Ms (concat this.insights.p95Ms " ms") "—"}}{{else}}{{/if}}
+
{{t "developers.api-keys.drawer.avg" value=(or this.insights.avgMs "—")}}
+
+
+
+ {{#each this.insights.bars as |bar|}} + + {{/each}} +
+ + + <:actions> +
diff --git a/addon/components/api-key/tabs/overview.js b/addon/components/api-key/tabs/overview.js new file mode 100644 index 0000000..a399f52 --- /dev/null +++ b/addon/components/api-key/tabs/overview.js @@ -0,0 +1,90 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { RETENTION_DAYS } from '../../../utils/retention'; +import { shortUrl } from '../../../utils/format'; + +/** + * Overview tab: usage tiles and bar strip from the request log sample, details, + * linked webhooks and channel, and the activity we can read from the record. + */ +export default class ApiKeyTabsOverviewComponent extends Component { + @service apiKeyInsights; + @service apiKeyActions; + @service developerPanels; + @service hostRouter; + @service intl; + + retentionDays = RETENTION_DAYS; + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get insights() { + return this.apiKeyInsights.ensure(this.apiKey); + } + + get origins() { + const origins = this.apiKey?.browser_origins; + + return Array.isArray(origins) ? origins.filter(Boolean) : []; + } + + get channelName() { + return `api.${this.apiKey?.id}`; + } + + get lastRequest() { + return this.insights.logs[0] ?? null; + } + + get linkedWebhooks() { + return this.insights.webhooks.map((webhook) => ({ webhook, label: shortUrl(webhook.url) ?? webhook.url, status: webhook.status })); + } + + get activity() { + const items = []; + + if (this.apiKey?.updated_at && this.apiKey.updated_at !== this.apiKey.created_at) { + items.push({ key: 'updated', tone: 'wait', at: this.apiKey.updated_at }); + } + + if (this.apiKey?.last_used_at) { + items.push({ key: 'last-used', tone: 'ok', at: this.apiKey.last_used_at }); + } + + items.push({ key: 'created', tone: 'ok', at: this.apiKey?.created_at }); + + const sorted = items.sort((a, b) => new Date(b.at) - new Date(a.at)); + + return sorted.map((item, index) => ({ ...item, isLast: index === sorted.length - 1 })); + } + + @action edit() { + return this.apiKeyActions.editApiKey(this.apiKey); + } + + @action roll() { + return this.apiKeyActions.rollApiKey(this.apiKey); + } + + @action openWebhook(webhook) { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.webhooks.view', webhook.id); + } + + @action watchChannel() { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.sockets.view', this.channelName); + } + + @action viewRequests() { + this.args.close?.(); + return this.apiKeyActions.viewRequestLogs(this.apiKey); + } + + @action refresh() { + return this.apiKeyInsights.refresh(this.apiKey); + } +} diff --git a/addon/components/api-key/tabs/requests.hbs b/addon/components/api-key/tabs/requests.hbs new file mode 100644 index 0000000..f60c654 --- /dev/null +++ b/addon/components/api-key/tabs/requests.hbs @@ -0,0 +1,32 @@ +{{! template-lint-disable no-invalid-interactive require-presentational-children }} +
+ + <:actions> +
diff --git a/addon/components/api-key/tabs/requests.js b/addon/components/api-key/tabs/requests.js new file mode 100644 index 0000000..05dd880 --- /dev/null +++ b/addon/components/api-key/tabs/requests.js @@ -0,0 +1,48 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { formatSeconds, timeStamp, statusTone } from '../../../utils/format'; + +/** + * Requests tab: the newest requests made with this key, from the shared sample. + */ +export default class ApiKeyTabsRequestsComponent extends Component { + @service apiKeyInsights; + @service apiKeyActions; + @service developerPanels; + @service store; + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get insights() { + return this.apiKeyInsights.ensure(this.apiKey); + } + + get rows() { + return this.insights.logs.slice(0, 50).map((log) => ({ + log, + tone: statusTone(log.status_code), + status: [log.status_code, log.reason_phrase].filter(Boolean).join(' '), + duration: formatSeconds(log.duration), + isSlow: Number(log.duration ?? 0) >= 1, + time: timeStamp(log.created_at, 'MMM d, HH:mm:ss'), + })); + } + + @action open(log) { + const record = this.store.push(this.store.normalize('api-request-log', log)); + + return this.developerPanels.openRequest(record); + } + + @action viewAll() { + this.args.close?.(); + return this.apiKeyActions.viewRequestLogs(this.apiKey); + } + + @action refresh() { + return this.apiKeyInsights.refresh(this.apiKey); + } +} diff --git a/addon/components/api-key/tabs/webhooks.hbs b/addon/components/api-key/tabs/webhooks.hbs new file mode 100644 index 0000000..c7d7c08 --- /dev/null +++ b/addon/components/api-key/tabs/webhooks.hbs @@ -0,0 +1,34 @@ +{{! template-lint-disable no-invalid-interactive require-presentational-children }} +
+ + <:actions> +
diff --git a/addon/components/api-key/tabs/webhooks.js b/addon/components/api-key/tabs/webhooks.js new file mode 100644 index 0000000..b07905f --- /dev/null +++ b/addon/components/api-key/tabs/webhooks.js @@ -0,0 +1,39 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { shortUrl } from '../../../utils/format'; + +/** + * Webhooks tab: endpoints that receive events from this key. + */ +export default class ApiKeyTabsWebhooksComponent extends Component { + @service apiKeyInsights; + @service hostRouter; + @service webhookActions; + + get apiKey() { + return this.args.resource ?? this.args.model; + } + + get insights() { + return this.apiKeyInsights.ensure(this.apiKey); + } + + get rows() { + return this.insights.webhooks.map((webhook) => ({ webhook, label: shortUrl(webhook.url) ?? webhook.url, events: Array.isArray(webhook.events) ? webhook.events.length : 0 })); + } + + @action open(webhook) { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.webhooks.view', webhook.id); + } + + @action add() { + this.args.close?.(); + return this.webhookActions.createWebhook(); + } + + @action refresh() { + return this.apiKeyInsights.refresh(this.apiKey); + } +} diff --git a/addon/components/api-request-log/credential-cell.hbs b/addon/components/api-request-log/credential-cell.hbs new file mode 100644 index 0000000..fc5a281 --- /dev/null +++ b/addon/components/api-request-log/credential-cell.hbs @@ -0,0 +1,8 @@ +{{#if @row.api_credential_uuid}} + + + {{or (credential-name @value) (mask-key @row.api_credential_uuid)}} + +{{else}} + — +{{/if}} diff --git a/addon/components/api-request-log/details.hbs b/addon/components/api-request-log/details.hbs deleted file mode 100644 index 699d40d..0000000 --- a/addon/components/api-request-log/details.hbs +++ /dev/null @@ -1,53 +0,0 @@ - - <:title> -
- -
- - <:default> - - - - - - - - - - - - - - - - - - - - - - - -
- {{t "developers.common.time"}} - - {{@apiRequest.createdAt}} -
- {{t "developers.component.api-request-log.details.duration"}} - - {{format-milliseconds @apiRequest.duration}} -
- {{t "developers.component.api-request-log.details.ip-address"}} - - {{@apiRequest.ip_address}} -
- {{t "developers.common.version"}} - - {{@apiRequest.version}} -
- {{t "developers.common.source"}} - - {{@apiRequest.source}} -
- -
\ No newline at end of file diff --git a/addon/components/api-request-log/duration-cell.hbs b/addon/components/api-request-log/duration-cell.hbs new file mode 100644 index 0000000..5f63b2f --- /dev/null +++ b/addon/components/api-request-log/duration-cell.hbs @@ -0,0 +1,3 @@ +{{#let (dev-duration @value unit="s") as |duration|}} + {{or duration "—"}} +{{/let}} diff --git a/addon/components/api-request-log/method-cell.hbs b/addon/components/api-request-log/method-cell.hbs new file mode 100644 index 0000000..80adebb --- /dev/null +++ b/addon/components/api-request-log/method-cell.hbs @@ -0,0 +1,5 @@ +{{#if @value}} + {{@value}} +{{else}} + — +{{/if}} diff --git a/addon/components/api-request-log/panel.hbs b/addon/components/api-request-log/panel.hbs new file mode 100644 index 0000000..d16fe2b --- /dev/null +++ b/addon/components/api-request-log/panel.hbs @@ -0,0 +1,109 @@ +
+
+ +
+

+ {{this.log.method}} + {{this.log.path}} + +

+
+ {{or this.duration "—"}} + · + {{this.createdAt}} + · + {{t "developers.logs.index.api-credential"}} {{#if this.log.api_credential_uuid}}{{or (credential-name this.log.api_credential_name) (mask-key this.log.api_credential_uuid)}}{{else}}—{{/if}} + {{#if this.log.ip_address}}·{{this.log.ip_address}}{{/if}} + · + {{t "developers.common.version"}} {{n-a this.log.version}} + · + {{t "developers.common.source"}} + {{#if this.log.content_type}}·{{this.log.content_type}}{{/if}} + · + {{this.log.public_id}} +
+
+
+
+
+ +
+
+
{{t "developers.logs.panel.full-url"}}
+
{{this.log.full_url}}
+
+
+
{{t "developers.logs.panel.tab-request-body"}}
+
{{or this.requestSize "0 B"}}{{#if this.log.content_type}} · {{this.log.content_type}}{{/if}}
+
+
+
{{t "developers.logs.panel.tab-response-body"}}
+
{{or this.responseSize "0 B"}}{{#if this.responseContentType}} · {{this.responseContentType}}{{/if}}
+
+
+
{{t "developers.logs.panel.related"}}
+
{{#if this.relatedResources.length}}{{#each this.relatedResources as |id|}}{{id}} {{/each}}{{else}}—{{/if}}
+
+
+ + + <:default> + +
+ {{#if (eq this.activeTab "query-params")}} + + {{else if (eq this.activeTab "request-headers")}} + + {{else if (eq this.activeTab "response-headers")}} + + {{else if (eq this.activeTab "response-body")}} + + {{else}} + + {{/if}} +
+
+ +
+ + {{#if this.isError}} +
+ + <:actions> + <:default> + + + <:actions> +
+ + +
+ + <:default> + + + {{#each this.headerRows as |row|}} + + {{else}} + + {{/each}} + +
{{row.name}}{{row.value}}{{#if row.masked}} {{/if}}
{{t "developers.component.api-request-log.request-headers.no-headers"}}
+ +
+
+ {{/if}} + +
+
+ {{t "developers.logs.panel.curl-preview"}} +
+
+
{{this.curl}}
+
+
diff --git a/addon/components/api-request-log/panel.js b/addon/components/api-request-log/panel.js new file mode 100644 index 0000000..e5a6da9 --- /dev/null +++ b/addon/components/api-request-log/panel.js @@ -0,0 +1,206 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { isBlank } from '@ember/utils'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; +import { byteSize, formatSeconds, timeStamp, statusTone } from '../../utils/format'; + +const SECRET_HEADERS = ['authorization', 'x-api-key', 'cookie', 'set-cookie']; + +function countKeys(value) { + return value && typeof value === 'object' ? Object.keys(value).length : 0; +} + +/** + * The request detail: facts, tabs for query params, request headers, request + * body, response headers and response body, and copy as cURL. Used by the + * drawer and the full page. + */ +export default class ApiRequestLogPanelComponent extends Component { + @service intl; + @service notifications; + @service developerPanels; + @service hostRouter; + + @tracked activeTab = 'request-body'; + @tracked headerSide = 'request'; + + get log() { + return this.args.resource ?? this.args.model; + } + + get statusBadge() { + return statusTone(this.log?.status_code); + } + + get statusText() { + return [this.log?.status_code, this.log?.reason_phrase].filter(Boolean).join(' '); + } + + get duration() { + return formatSeconds(this.log?.duration); + } + + get isSlow() { + return Number(this.log?.duration ?? 0) >= 1; + } + + get createdAt() { + return timeStamp(this.log?.created_at, 'MMM d, yyyy HH:mm:ss.SSS'); + } + + get isError() { + return Number(this.log?.status_code ?? 0) >= 400; + } + + get responseContentType() { + const headers = this.log?.response_headers; + + if (!headers || typeof headers !== 'object') { + return null; + } + + const key = Object.keys(headers).find((name) => name.toLowerCase() === 'content-type'); + const value = key ? headers[key] : null; + + return Array.isArray(value) ? value[0] : value; + } + + /** + * Header rows for the side-by-side error view, masked like the cURL output. + */ + get headerRows() { + const source = this.headerSide === 'response' ? this.log?.response_headers : this.maskedRequestHeaders; + + if (!source || typeof source !== 'object') { + return []; + } + + return Object.keys(source).map((name) => ({ + name: name.toLowerCase(), + value: Array.isArray(source[name]) ? source[name].join(', ') : String(source[name]), + masked: this.headerSide === 'request' && SECRET_HEADERS.includes(name.toLowerCase()), + })); + } + + @action setHeaderSide(side) { + this.headerSide = side; + } + + get requestBody() { + return isBlank(this.log?.request_body) ? this.log?.request_raw_body : this.log?.request_body; + } + + get responseBody() { + return isBlank(this.log?.response_body) ? this.log?.response_raw_body : this.log?.response_body; + } + + get requestSize() { + return byteSize(this.requestBody); + } + + get responseSize() { + return byteSize(this.responseBody); + } + + /** + * Request headers with credentials masked for display and cURL. + */ + get maskedRequestHeaders() { + const headers = this.log?.request_headers; + + if (!headers || typeof headers !== 'object') { + return headers; + } + + const masked = {}; + + Object.keys(headers).forEach((name) => { + const value = Array.isArray(headers[name]) ? headers[name].join(', ') : headers[name]; + masked[name] = SECRET_HEADERS.includes(name.toLowerCase()) ? this.maskSecret(String(value)) : value; + }); + + return masked; + } + + maskSecret(value) { + const match = value.match(/^(Bearer\s+)?(\S{0,12})/i); + const prefix = match ? `${match[1] ?? ''}${match[2] ?? ''}` : value.slice(0, 12); + + return `${prefix}••••`; + } + + get relatedResources() { + const related = this.log?.related_resources ?? this.log?.related; + + if (Array.isArray(related)) { + return related.map((item) => (typeof item === 'string' ? item : (item?.public_id ?? item?.id ?? JSON.stringify(item)))); + } + + if (related && typeof related === 'object') { + return Object.values(related).flat().map(String); + } + + return []; + } + + get curl() { + const log = this.log ?? {}; + const lines = [`curl -X ${log.method ?? 'GET'} '${log.full_url ?? log.path ?? ''}'`]; + const headers = this.maskedRequestHeaders && typeof this.maskedRequestHeaders === 'object' ? this.maskedRequestHeaders : {}; + + Object.keys(headers) + .filter((name) => !['host', 'content-length', 'connection', 'accept-encoding'].includes(name.toLowerCase())) + .forEach((name) => { + lines.push(` -H '${name}: ${String(headers[name]).replace(/'/g, "'\\''")}'`); + }); + + const body = this.requestBody; + + if (!isBlank(body) && log.method && log.method !== 'GET') { + const raw = typeof body === 'string' ? body : JSON.stringify(body); + lines.push(` --data-raw '${raw.replace(/'/g, "'\\''")}'`); + } + + return lines.join(' \\\n'); + } + + get tabs() { + const log = this.log ?? {}; + + return [ + { id: 'query-params', label: this.intl.t('developers.logs.panel.tab-query'), badge: countKeys(log.query_params) || null }, + { id: 'request-headers', label: this.intl.t('developers.logs.panel.tab-request-headers'), badge: countKeys(log.request_headers) || null }, + { id: 'request-body', label: this.intl.t('developers.logs.panel.tab-request-body') }, + { id: 'response-headers', label: this.intl.t('developers.logs.panel.tab-response-headers'), badge: countKeys(log.response_headers) || null }, + { id: 'response-body', label: this.intl.t('developers.logs.panel.tab-response-body') }, + ]; + } + + @action setTab(tab) { + this.activeTab = tab.id; + } + + @action copyCurl() { + return copyToClipboard(this.curl).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied-curl'))); + } + + @action copyId() { + return copyToClipboard(this.log.public_id).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied'))); + } + + @action openFullPage() { + return this.developerPanels.openFullPage('logs.view', this.log); + } + + @action requestsFromIp() { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.logs.index', { queryParams: { query: this.log.ip_address } }); + } + + @action requestsForKey() { + this.args.close?.(); + return this.hostRouter.transitionTo('console.developers.logs.index', { queryParams: { key: this.log.api_credential_uuid } }); + } +} diff --git a/addon/components/api-request-log/query-params.hbs b/addon/components/api-request-log/query-params.hbs deleted file mode 100644 index 52d2d3f..0000000 --- a/addon/components/api-request-log/query-params.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - {{#if @apiRequest.query_params}} - - {{else}} -
- - {{t "developers.component.api-request-log.query-params.no-parameters"}} - -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/api-request-log/request-body.hbs b/addon/components/api-request-log/request-body.hbs deleted file mode 100644 index a48eb81..0000000 --- a/addon/components/api-request-log/request-body.hbs +++ /dev/null @@ -1,16 +0,0 @@ - - {{#if @apiRequest.request_body}} - - {{else}} -
- - {{t "developers.component.api-request-log.request-body.no-request-body" htmlSafe=true method=@apiRequest.method}} - -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/api-request-log/request-cell.hbs b/addon/components/api-request-log/request-cell.hbs new file mode 100644 index 0000000..9b7c2c2 --- /dev/null +++ b/addon/components/api-request-log/request-cell.hbs @@ -0,0 +1,7 @@ +
+ {{@row.method}} + + {{@row.path}} + {{@row.public_id}}{{#if @row.ip_address}} · {{@row.ip_address}}{{/if}}{{#if @row.content_type}} · {{@row.content_type}}{{/if}} + +
diff --git a/addon/components/api-request-log/request-headers.hbs b/addon/components/api-request-log/request-headers.hbs deleted file mode 100644 index df8f993..0000000 --- a/addon/components/api-request-log/request-headers.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - {{#if @apiRequest.request_headers}} - - {{else}} -
- - {{t "developers.component.api-request-log.request-headers.no-headers"}} - -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/api-request-log/response-body.hbs b/addon/components/api-request-log/response-body.hbs deleted file mode 100644 index a2069b3..0000000 --- a/addon/components/api-request-log/response-body.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - {{#if @apiRequest.response_body}} - - {{else}} -
- - {{t "developers.component.api-request-log.response-body.no-response-body"}} - -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/api-request-log/source-cell.hbs b/addon/components/api-request-log/source-cell.hbs new file mode 100644 index 0000000..887d5ac --- /dev/null +++ b/addon/components/api-request-log/source-cell.hbs @@ -0,0 +1,3 @@ + + + diff --git a/addon/components/api-request-log/status-cell.hbs b/addon/components/api-request-log/status-cell.hbs new file mode 100644 index 0000000..169497d --- /dev/null +++ b/addon/components/api-request-log/status-cell.hbs @@ -0,0 +1,5 @@ +{{#let (status-tone @value) as |tone|}} + + + +{{/let}} diff --git a/addon/components/api-request-log/time-cell.hbs b/addon/components/api-request-log/time-cell.hbs new file mode 100644 index 0000000..2eaaca8 --- /dev/null +++ b/addon/components/api-request-log/time-cell.hbs @@ -0,0 +1 @@ +{{time-stamp @value "HH:mm:ss"}} diff --git a/addon/components/channel/access-cell.hbs b/addon/components/channel/access-cell.hbs new file mode 100644 index 0000000..a152473 --- /dev/null +++ b/addon/components/channel/access-cell.hbs @@ -0,0 +1,9 @@ + + {{#if (eq @value "allowed")}} + + {{else if (eq @value "denied")}} + + {{else}} + {{t "developers.sockets.access.unknown"}} + {{/if}} + diff --git a/addon/components/channel/access-cell.js b/addon/components/channel/access-cell.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/channel/access-cell.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/channel/connection-badge.hbs b/addon/components/channel/connection-badge.hbs new file mode 100644 index 0000000..bef4c46 --- /dev/null +++ b/addon/components/channel/connection-badge.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/channel/connection-badge.js b/addon/components/channel/connection-badge.js new file mode 100644 index 0000000..5dd6310 --- /dev/null +++ b/addon/components/channel/connection-badge.js @@ -0,0 +1,70 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +/** + * Shows whether the console socket is connected, and to which host. + */ +export default class ChannelConnectionBadgeComponent extends Component { + @service socket; + @tracked state = 'connecting'; + + constructor() { + super(...arguments); + this.observe(); + } + + get host() { + try { + return this.socket.instance()?.options?.hostname ?? ''; + } catch { + return ''; + } + } + + get badge() { + return { connected: 'success', connecting: 'warning', disconnected: 'danger' }[this.state] ?? 'info'; + } + + async observe() { + let instance; + + try { + instance = this.socket.instance(); + } catch { + this.state = 'disconnected'; + return; + } + + if (!instance) { + this.state = 'disconnected'; + return; + } + + this.state = instance.state === 'open' ? 'connected' : instance.state === 'closed' ? 'disconnected' : 'connecting'; + + (async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of instance.listener('connect')) { + if (this.isDestroyed) break; + this.state = 'connected'; + } + })(); + + (async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of instance.listener('close')) { + if (this.isDestroyed) break; + this.state = 'disconnected'; + } + })(); + + (async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of instance.listener('connecting')) { + if (this.isDestroyed) break; + this.state = 'connecting'; + } + })(); + } +} diff --git a/addon/components/channel/type-cell.hbs b/addon/components/channel/type-cell.hbs new file mode 100644 index 0000000..9fae858 --- /dev/null +++ b/addon/components/channel/type-cell.hbs @@ -0,0 +1,6 @@ + + + {{#if @row.environment}} + + {{/if}} + diff --git a/addon/components/channel/type-cell.js b/addon/components/channel/type-cell.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/channel/type-cell.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/code-viewer.hbs b/addon/components/code-viewer.hbs new file mode 100644 index 0000000..9dcf034 --- /dev/null +++ b/addon/components/code-viewer.hbs @@ -0,0 +1,34 @@ +
+
+ {{@title}} + {{#if this.size}} + {{this.size}} + {{/if}} + {{#if @meta}} + {{@meta}} + {{/if}} +
+ {{yield}} + {{#if (and this.isJson this.hasContent)}} +
+ + +
+ {{/if}} + {{#if this.hasContent}} +
+
+ {{#if this.hasContent}} +
+ +
+ {{else}} +
{{or @emptyText (t "developers.code-viewer.empty")}}
+ {{/if}} +
diff --git a/addon/components/code-viewer.js b/addon/components/code-viewer.js new file mode 100644 index 0000000..7d137d7 --- /dev/null +++ b/addon/components/code-viewer.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { isBlank } from '@ember/utils'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; +import { byteSize } from '../utils/format'; + +/** + * A code block with a toolbar: Copy, Pretty / Raw, Wrap, and optionally + * "Copy as cURL". Wraps the ember-prism `CodeBlock`. + * + * @param {Object|String} value the JSON value or string to show + * @param {String} language prism language, default `json` + * @param {String} title the toolbar label + * @param {String} curl a cURL command to offer on the toolbar + * @param {String} emptyText shown when there is nothing to display + */ +export default class CodeViewerComponent extends Component { + @service notifications; + @service intl; + @tracked mode = 'pretty'; + @tracked wrap = false; + + get language() { + return this.args.language ?? 'json'; + } + + get isJson() { + return this.language === 'json'; + } + + get isPretty() { + return this.mode === 'pretty'; + } + + get hasContent() { + const { value } = this.args; + + if (isBlank(value)) { + return false; + } + + if (typeof value === 'object' && Object.keys(value).length === 0) { + return false; + } + + return true; + } + + get prettyCode() { + const { value } = this.args; + + if (typeof value === 'string') { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + + return JSON.stringify(value, null, 2); + } + + get rawCode() { + const { value } = this.args; + + return typeof value === 'string' ? value : JSON.stringify(value); + } + + get code() { + return this.isPretty ? this.prettyCode : this.rawCode; + } + + get size() { + return this.hasContent ? byteSize(this.rawCode) : null; + } + + @action setMode(mode) { + this.mode = mode; + } + + @action toggleWrap() { + this.wrap = !this.wrap; + } + + @action copy() { + return copyToClipboard(this.code).then(() => { + this.notifications.info(this.intl.t('developers.code-viewer.copied')); + }); + } + + @action copyCurl() { + return copyToClipboard(this.args.curl).then(() => { + this.notifications.info(this.intl.t('developers.code-viewer.copied-curl')); + }); + } +} diff --git a/addon/components/dashboard/onboarding.hbs b/addon/components/dashboard/onboarding.hbs new file mode 100644 index 0000000..f927192 --- /dev/null +++ b/addon/components/dashboard/onboarding.hbs @@ -0,0 +1,40 @@ + +
+

{{t "developers.dashboard.onboarding.title"}}

+

{{t "developers.dashboard.onboarding.text"}}

+
+
+
+
1{{t "developers.dashboard.onboarding.step-1.title"}}
+

{{t "developers.dashboard.onboarding.step-1.text"}}

+
+
+
+
+
2{{t "developers.dashboard.onboarding.step-2.title"}}
+

{{t "developers.dashboard.onboarding.step-2.text"}}

+
+
+
+
+
3{{t "developers.dashboard.onboarding.step-3.title"}}
+
curl https://api.fleetbase.io/v1/orders \-H "Authorization: Bearer flb_live_…"
+
+
+
+
+
+ + + {{#if this.isSandbox}} + {{t "developers.dashboard.onboarding.env-test"}} + {{else}} + {{t "developers.dashboard.onboarding.env-live"}} + {{/if}} + +
+
diff --git a/addon/components/dashboard/onboarding.js b/addon/components/dashboard/onboarding.js new file mode 100644 index 0000000..f796e5c --- /dev/null +++ b/addon/components/dashboard/onboarding.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * The first-integration state of the dashboard: three ordered steps that + * get data flowing. Shown while the organization has no requests and no keys. + */ +export default class DashboardOnboardingComponent extends Component { + @service apiKeyActions; + @service webhookActions; + @service hostRouter; + @service currentUser; + + get isSandbox() { + return this.currentUser.getOption('sandbox', false) === true; + } + + @action createApiKey() { + return this.apiKeyActions.createApiKey(); + } + + @action createWebhook() { + return this.webhookActions.createWebhook(); + } + + @action go(route) { + return this.hostRouter.transitionTo(`console.developers.${route}`); + } +} diff --git a/addon/components/dashboard/period-selector.hbs b/addon/components/dashboard/period-selector.hbs new file mode 100644 index 0000000..78cb885 --- /dev/null +++ b/addon/components/dashboard/period-selector.hbs @@ -0,0 +1,10 @@ +
+
+ {{#each this.periods as |item|}} + + {{/each}} +
+ {{this.rangeText}} · {{t "developers.dashboard.period.compared" period=this.developerMetrics.period}} +
diff --git a/addon/components/dashboard/period-selector.js b/addon/components/dashboard/period-selector.js new file mode 100644 index 0000000..2ee71fe --- /dev/null +++ b/addon/components/dashboard/period-selector.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { format, subDays } from 'date-fns'; + +/** + * The 7d / 30d / 90d / 180d / 365d switch in the dashboard header. Writes the + * period to the developer-metrics service, which every widget reads. + */ +export default class DashboardPeriodSelectorComponent extends Component { + @service developerMetrics; + + get periods() { + return this.developerMetrics.periods.map((period) => ({ period, isActive: period === this.developerMetrics.period })); + } + + get rangeText() { + const days = this.developerMetrics.periodDays; + const end = new Date(); + const start = subDays(end, days); + + return `${format(start, 'MMM d')} – ${format(end, 'MMM d')}`; + } + + @action select(period) { + this.developerMetrics.setPeriod(period); + } +} diff --git a/addon/components/environment-switch.hbs b/addon/components/environment-switch.hbs new file mode 100644 index 0000000..136eb50 --- /dev/null +++ b/addon/components/environment-switch.hbs @@ -0,0 +1,23 @@ +
+
+ + +
+ {{#unless @hideHint}} + + {{#if this.isSandbox}} + {{t "developers.environment.test-hint"}} + {{else}} + {{t "developers.environment.live-hint"}} + {{/if}} + + {{/unless}} +
diff --git a/addon/components/environment-switch.js b/addon/components/environment-switch.js new file mode 100644 index 0000000..102437f --- /dev/null +++ b/addon/components/environment-switch.js @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * The Live / Test environment switch. Binds to the console's `sandbox` user + * option, which the fetch service turns into the `Access-Console-Sandbox` + * header, so every list under it re-queries against the right database. + * + * Replaces the "View test data" toggle plus key select: switching used to leave + * the list empty until the route was re-entered because the toggle refreshed + * before the option was applied and stored a key string where an id was read. + */ +export default class EnvironmentSwitchComponent extends Component { + @service currentUser; + @service theme; + @service store; + @service hostRouter; + + get isSandbox() { + return this.currentUser.getOption('sandbox', false) === true; + } + + @action setEnvironment(sandbox) { + if (sandbox === this.isSandbox) { + return; + } + + this.currentUser.setOption('sandbox', sandbox); + // The old key-scoped sandbox filter stored a key string where an id was expected; drop it. + this.currentUser.setOption('testKey', null); + this.theme.setEnvironment(); + this.store.unloadAll(); + + if (typeof this.args.onChange === 'function') { + this.args.onChange(sandbox); + } + + return this.hostRouter.refresh(); + } +} diff --git a/addon/components/filter-bar.hbs b/addon/components/filter-bar.hbs new file mode 100644 index 0000000..e03e275 --- /dev/null +++ b/addon/components/filter-bar.hbs @@ -0,0 +1,71 @@ +
+ {{#each this.leadingColumns as |filter|}} + {{#let (component "filter-bar/chip" filter=filter onChange=this.updateFilterValue onClear=this.clearFilterValue onApply=this.applyFilters onClearFilter=this.clearFilter) as |Chip|}} + + {{/let}} + {{/each}} + {{#if this.segments.length}} +
+ {{#each this.segments as |segment|}} + + {{/each}} +
+ {{/if}} + + {{#each this.trailingColumns as |filter|}} + +
+
{{filter.chipLabel}}
+ {{component + (if (is-object filter.filterComponent) (lazy-engine-component filter.filterComponent) filter.filterComponent) + value=filter.filterValue + filter=filter + param=filter.param + options=filter.filterOptions + optionLabel=filter.filterOptionLabel + optionValue=filter.filterOptionValue + optionFormats=filter.filterOptionFormats + fetchUri=filter.fetchUri + fetchParams=(or filter.filterFetchParams filter.fetchParams) + fetchOptions=(or filter.filterFetchOptions filter.fetchOptions) + placeholder=(or filter.filterComponentPlaceholder filter.chipLabel) + onChange=this.updateFilterValue + onClear=this.clearFilterValue + }} + +
+
+ {{/each}} + + {{#if this.hasActiveFilters}} + + {{/each}} + {{/if}} + {{yield}} +
+ {{/if}} + diff --git a/addon/components/filter-bar.js b/addon/components/filter-bar.js new file mode 100644 index 0000000..63eb795 --- /dev/null +++ b/addon/components/filter-bar.js @@ -0,0 +1,199 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action, get, set } from '@ember/object'; +import { isArray } from '@ember/array'; +import { isBlank } from '@ember/utils'; +import { getOwner } from '@ember/application'; +import getUrlParam from '@fleetbase/ember-ui/utils/get-url-param'; + +/** + * A persistent filter bar: one chip per filterable column, each opening that + * column's filter component in a small popover, plus optional segments + * (status classes) and a clear-all link. Reads and writes the same query + * params the ember-ui `FiltersPicker` does, through the `filters` service, so + * the two stay interchangeable and registered extension columns filter too. + */ +export default class FilterBarComponent extends Component { + @service filters; + @service intl; + @tracked filterColumns = []; + + get hostRouter() { + return getOwner(this).lookup('service:host-router'); + } + + constructor() { + super(...arguments); + this.rebuild(); + this._routeHandler = () => this.rebuild(); + this.hostRouter?.on?.('routeDidChange', this._routeHandler); + } + + willDestroy() { + super.willDestroy(...arguments); + this.hostRouter?.off?.('routeDidChange', this._routeHandler); + } + + get leadingColumns() { + return this.filterColumns.filter((filter) => filter.filterLeading); + } + + get trailingColumns() { + return this.filterColumns.filter((filter) => !filter.filterLeading); + } + + get hasActiveFilters() { + return this.filterColumns.some((filter) => filter.isActive); + } + + get segments() { + return (this.args.segments ?? []).map((segment) => { + const params = this.segmentParams(segment); + const isActive = Object.keys(params).every((param) => { + const expected = params[param]; + const current = this.readParam(param); + + return isBlank(expected) ? isBlank(current) : String(current) === String(expected); + }); + + return { ...segment, params, isActive }; + }); + } + + /** + * Segments either carry one `param`/`value` pair or a `params` object; both + * normalize to `{ param: value }` where a blank value means "clear it". + */ + segmentParams(segment) { + if (segment.params && typeof segment.params === 'object') { + return segment.params; + } + + return segment.param ? { [segment.param]: segment.value ?? null } : {}; + } + + get segmentParamNames() { + const names = new Set(); + + (this.args.segments ?? []).forEach((segment) => Object.keys(this.segmentParams(segment)).forEach((name) => names.add(name))); + + return [...names]; + } + + readParam(param) { + const { controller } = this.args; + + if (controller && param in controller) { + const value = get(controller, param); + + return isBlank(value) ? undefined : value; + } + + const raw = getUrlParam(param); + + if (isArray(raw)) { + return raw.length ? raw : undefined; + } + + return raw === '' ? undefined : raw; + } + + /** + * Rebuilds the chip list from the filterable columns and the current URL. + */ + @action rebuild() { + const columns = this.args.columns ?? []; + + this.filterColumns = columns + .filter((column) => column.filterable) + .map((column) => { + const param = column.filterParam ?? column.valuePath; + const value = this.readParam(param); + + return { + ...column, + param, + filterValue: value, + isActive: !isBlank(value), + displayValue: this.displayValue(column, value), + chipLabel: column.filterLabel ?? column.label, + }; + }); + } + + displayValue(column, value) { + if (isBlank(value)) { + return this.intl.t('developers.filter-bar.any'); + } + + const values = isArray(value) ? value : String(value).split(','); + const options = column.filterOptions; + const optionLabel = column.filterOptionLabel ?? 'label'; + const optionValue = column.filterOptionValue ?? 'value'; + + const labels = values.map((single) => { + if (isArray(options)) { + const option = options.find((candidate) => + typeof candidate === 'object' && candidate !== null ? String(get(candidate, optionValue)) === String(single) : String(candidate) === String(single) + ); + + if (option && typeof option === 'object') { + return get(option, optionLabel) ?? single; + } + } + + return single; + }); + + if (column.filterComponent === 'filter/date' && labels.length === 2) { + return `${labels[0]} – ${labels[1]}`; + } + + if (labels.length > 2) { + return `${labels[0]} +${labels.length - 1}`; + } + + return labels.join(', '); + } + + @action updateFilterValue(filter, value) { + this.filters.set(filter.param, value); + } + + @action clearFilterValue(filter) { + this.filters.set(filter.param, null); + } + + @action applyFilters() { + this.filters.apply(this.args.controller); + this.rebuild(); + } + + @action clearFilter(filter) { + this.filters.removeFromController(this.args.controller, filter.param, undefined); + set(this.args.controller, 'page', 1); + this.rebuild(); + } + + @action clearAll() { + this.filters.reset(this.args.controller); + this.segmentParamNames.forEach((param) => set(this.args.controller, param, undefined)); + set(this.args.controller, 'page', 1); + this.rebuild(); + } + + @action applySegment(segment) { + const { controller } = this.args; + const params = this.segmentParams(segment); + + Object.keys(params).forEach((param) => set(controller, param, isBlank(params[param]) ? undefined : params[param])); + + if (segment.clears) { + segment.clears.forEach((param) => set(controller, param, undefined)); + } + + set(controller, 'page', 1); + this.rebuild(); + } +} diff --git a/addon/components/filter-bar/chip.hbs b/addon/components/filter-bar/chip.hbs new file mode 100644 index 0000000..ff89088 --- /dev/null +++ b/addon/components/filter-bar/chip.hbs @@ -0,0 +1,35 @@ + +
+
{{@filter.chipLabel}}
+ {{component + (if (is-object @filter.filterComponent) (lazy-engine-component @filter.filterComponent) @filter.filterComponent) + value=@filter.filterValue + filter=@filter + param=@filter.param + options=@filter.filterOptions + optionLabel=@filter.filterOptionLabel + optionValue=@filter.filterOptionValue + optionFormats=@filter.filterOptionFormats + fetchUri=@filter.fetchUri + fetchParams=(or @filter.filterFetchParams @filter.fetchParams) + fetchOptions=(or @filter.filterFetchOptions @filter.fetchOptions) + placeholder=(or @filter.filterComponentPlaceholder @filter.chipLabel) + onChange=@onChange + onClear=@onClear + }} + +
+
diff --git a/addon/components/modal/steps.hbs b/addon/components/modal/steps.hbs new file mode 100644 index 0000000..6e1371a --- /dev/null +++ b/addon/components/modal/steps.hbs @@ -0,0 +1,14 @@ +
    + {{#each @steps as |step index|}} +
  1. + + {{#if (eq step.state "done")}} + + {{else}} + {{add index 1}} + {{/if}} + + {{step.label}} +
  2. + {{/each}} +
diff --git a/addon/components/modals/api-key-delete.hbs b/addon/components/modals/api-key-delete.hbs new file mode 100644 index 0000000..f62c6cc --- /dev/null +++ b/addon/components/modals/api-key-delete.hbs @@ -0,0 +1,13 @@ + +
+
{{t "developers.api-keys.delete.consequence"}}
+
+
{{t "developers.api-keys.index.last-used"}}
+
{{#if options.apiKey.last_used_at}}{{time-ago options.apiKey.last_used_at}}{{else}}{{t "developers.common.never-used"}}{{/if}}
+
{{t "developers.application.sidebar.items.webhooks"}}
+
{{t "developers.api-keys.delete.webhooks" count=options.dependents.webhooks.length endpoint=options.firstDependent}}
+
{{t "developers.api-keys.delete.safer"}}
+
{{t "developers.api-keys.delete.safer-link"}} {{t "developers.api-keys.delete.safer-text"}}
+
+
+
diff --git a/addon/components/modals/api-key-form-title.hbs b/addon/components/modals/api-key-form-title.hbs new file mode 100644 index 0000000..89290e0 --- /dev/null +++ b/addon/components/modals/api-key-form-title.hbs @@ -0,0 +1,12 @@ + + {{@options.title}} + {{#if @options.isNew}} + + {{/if}} + diff --git a/addon/components/modals/api-key-form.hbs b/addon/components/modals/api-key-form.hbs index 49433a5..c4ee62c 100644 --- a/addon/components/modals/api-key-form.hbs +++ b/addon/components/modals/api-key-form.hbs @@ -1,47 +1,63 @@ -