From 13a3f93e235ed50de1bd59197b2bbd4b9412d3e6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 21 Oct 2026 14:42:59 +0800 Subject: [PATCH 01/30] feat(developers): foundation components, dashboard period and onboarding, API keys list, create/roll reveal flows, key panel (A1, B1, B2, C1-C5) --- addon/components/api-key/expiry-cell.hbs | 7 + addon/components/api-key/expiry-cell.js | 70 + addon/components/api-key/key-cell.hbs | 3 + addon/components/api-key/last-used-cell.hbs | 7 + addon/components/api-key/panel.hbs | 110 ++ addon/components/api-key/panel.js | 115 ++ addon/components/api-key/reveal.hbs | 30 + addon/components/code-viewer.hbs | 34 + addon/components/code-viewer.js | 98 + addon/components/dashboard/onboarding.hbs | 40 + addon/components/dashboard/onboarding.js | 30 + .../components/dashboard/period-selector.hbs | 10 + addon/components/dashboard/period-selector.js | 28 + addon/components/environment-switch.hbs | 23 + addon/components/environment-switch.js | 41 + addon/components/filter-bar.hbs | 60 + addon/components/filter-bar.js | 163 ++ addon/components/modal/steps.hbs | 14 + addon/components/modals/api-key-form.hbs | 115 +- .../components/modals/rename-api-key-form.hbs | 16 +- addon/components/modals/roll-api-key-form.hbs | 146 +- addon/components/resource-header.hbs | 25 + addon/components/section-card.hbs | 24 + addon/components/states/denied.hbs | 9 + addon/components/states/empty.hbs | 22 + addon/components/states/error.hbs | 12 + .../components/widget/developer-activity.hbs | 26 +- addon/components/widget/developer-activity.js | 46 +- addon/components/widget/developers-chart.hbs | 18 +- addon/components/widget/developers-chart.js | 59 +- .../components/widget/developers-kpi-tile.hbs | 24 +- .../components/widget/developers-kpi-tile.js | 64 +- addon/components/widget/endpoint-health.hbs | 46 +- addon/components/widget/endpoint-health.js | 78 +- addon/components/widget/event-stream.hbs | 45 +- addon/components/widget/event-stream.js | 24 +- addon/components/widget/quick-resources.hbs | 8 +- addon/components/widget/quick-resources.js | 36 +- addon/controllers/api-keys/index.js | 571 ++---- addon/controllers/application.js | 46 +- addon/controllers/home/index.js | 14 + addon/extension.js | 25 +- addon/helpers/dev-duration.js | 10 + addon/helpers/mask-key.js | 6 + addon/helpers/short-url.js | 6 + addon/helpers/status-tone.js | 6 + addon/helpers/time-ago.js | 6 + addon/helpers/time-stamp.js | 6 + ...egister-developers-resource-descriptors.js | 29 + addon/routes/api-keys/index.js | 9 +- addon/services/api-key-actions.js | 373 +++- addon/services/developer-metrics.js | 93 + addon/services/developer-panels.js | 101 ++ addon/styles/dev-engine.css | 1590 +++++++++++++++++ addon/templates/api-keys/index.hbs | 50 +- addon/templates/home/index.hbs | 13 +- addon/utils/format.js | 167 ++ addon/utils/resource-descriptors.js | 229 +++ addon/utils/retention.js | 13 + app/components/api-key/expiry-cell.js | 1 + app/components/api-key/key-cell.js | 1 + app/components/api-key/last-used-cell.js | 1 + app/components/api-key/panel.js | 1 + app/components/api-key/reveal.js | 1 + app/components/code-viewer.js | 1 + app/components/dashboard/onboarding.js | 1 + app/components/dashboard/period-selector.js | 1 + app/components/environment-switch.js | 1 + app/components/filter-bar.js | 1 + app/components/modal/steps.js | 1 + app/components/resource-header.js | 1 + app/components/section-card.js | 1 + app/components/states/denied.js | 1 + app/components/states/empty.js | 1 + app/components/states/error.js | 1 + app/controllers/home/index.js | 1 + app/helpers/dev-duration.js | 1 + app/helpers/mask-key.js | 1 + app/helpers/short-url.js | 1 + app/helpers/status-tone.js | 1 + app/helpers/time-ago.js | 1 + app/helpers/time-stamp.js | 1 + app/services/developer-metrics.js | 1 + app/services/developer-panels.js | 1 + design/IMPLEMENTATION_PROMPT.md | 100 ++ design/REDESIGN_PROMPT.md | 237 +++ translations/en-us.yaml | 215 ++- 87 files changed, 4913 insertions(+), 753 deletions(-) create mode 100644 addon/components/api-key/expiry-cell.hbs create mode 100644 addon/components/api-key/expiry-cell.js create mode 100644 addon/components/api-key/key-cell.hbs create mode 100644 addon/components/api-key/last-used-cell.hbs create mode 100644 addon/components/api-key/panel.hbs create mode 100644 addon/components/api-key/panel.js create mode 100644 addon/components/api-key/reveal.hbs create mode 100644 addon/components/code-viewer.hbs create mode 100644 addon/components/code-viewer.js create mode 100644 addon/components/dashboard/onboarding.hbs create mode 100644 addon/components/dashboard/onboarding.js create mode 100644 addon/components/dashboard/period-selector.hbs create mode 100644 addon/components/dashboard/period-selector.js create mode 100644 addon/components/environment-switch.hbs create mode 100644 addon/components/environment-switch.js create mode 100644 addon/components/filter-bar.hbs create mode 100644 addon/components/filter-bar.js create mode 100644 addon/components/modal/steps.hbs create mode 100644 addon/components/resource-header.hbs create mode 100644 addon/components/section-card.hbs create mode 100644 addon/components/states/denied.hbs create mode 100644 addon/components/states/empty.hbs create mode 100644 addon/components/states/error.hbs create mode 100644 addon/controllers/home/index.js create mode 100644 addon/helpers/dev-duration.js create mode 100644 addon/helpers/mask-key.js create mode 100644 addon/helpers/short-url.js create mode 100644 addon/helpers/status-tone.js create mode 100644 addon/helpers/time-ago.js create mode 100644 addon/helpers/time-stamp.js create mode 100644 addon/instance-initializers/register-developers-resource-descriptors.js create mode 100644 addon/services/developer-metrics.js create mode 100644 addon/services/developer-panels.js create mode 100644 addon/utils/format.js create mode 100644 addon/utils/resource-descriptors.js create mode 100644 addon/utils/retention.js create mode 100644 app/components/api-key/expiry-cell.js create mode 100644 app/components/api-key/key-cell.js create mode 100644 app/components/api-key/last-used-cell.js create mode 100644 app/components/api-key/panel.js create mode 100644 app/components/api-key/reveal.js create mode 100644 app/components/code-viewer.js create mode 100644 app/components/dashboard/onboarding.js create mode 100644 app/components/dashboard/period-selector.js create mode 100644 app/components/environment-switch.js create mode 100644 app/components/filter-bar.js create mode 100644 app/components/modal/steps.js create mode 100644 app/components/resource-header.js create mode 100644 app/components/section-card.js create mode 100644 app/components/states/denied.js create mode 100644 app/components/states/empty.js create mode 100644 app/components/states/error.js create mode 100644 app/controllers/home/index.js create mode 100644 app/helpers/dev-duration.js create mode 100644 app/helpers/mask-key.js create mode 100644 app/helpers/short-url.js create mode 100644 app/helpers/status-tone.js create mode 100644 app/helpers/time-ago.js create mode 100644 app/helpers/time-stamp.js create mode 100644 app/services/developer-metrics.js create mode 100644 app/services/developer-panels.js create mode 100644 design/IMPLEMENTATION_PROMPT.md create mode 100644 design/REDESIGN_PROMPT.md 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..089802c --- /dev/null +++ b/addon/components/api-key/reveal.hbs @@ -0,0 +1,30 @@ +{{! The reveal-once step shared by create and roll. }} +
+
+ + {{or @warning (t "developers.api-keys.reveal.warning")}} +
+ + +
+ {{@revealed.secret}} + +
+
+ + +
+ {{@revealed.key}} + +
+
+ + +
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..feacacf --- /dev/null +++ b/addon/components/filter-bar.hbs @@ -0,0 +1,60 @@ +
+ {{#if this.segments.length}} +
+ {{#each this.segments as |segment|}} + + {{/each}} +
+ {{/if}} + + {{#each this.filterColumns 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}} +
diff --git a/addon/components/filter-bar.js b/addon/components/filter-bar.js new file mode 100644 index 0000000..8bcfbcb --- /dev/null +++ b/addon/components/filter-bar.js @@ -0,0 +1,163 @@ +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 hasActiveFilters() { + return this.filterColumns.some((filter) => filter.isActive); + } + + get segments() { + return (this.args.segments ?? []).map((segment) => ({ + ...segment, + isActive: this.readParam(segment.param) === (segment.value ?? undefined) || (isBlank(segment.value) && isBlank(this.readParam(segment.param))), + })); + } + + 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.args.segments ?? []).forEach((segment) => set(this.args.controller, segment.param, undefined)); + set(this.args.controller, 'page', 1); + this.rebuild(); + } + + @action applySegment(segment) { + const { controller } = this.args; + + set(controller, segment.param, isBlank(segment.value) ? undefined : segment.value); + + if (segment.clears) { + segment.clears.forEach((param) => set(controller, param, undefined)); + } + + set(controller, 'page', 1); + this.rebuild(); + } +} 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-form.hbs b/addon/components/modals/api-key-form.hbs index 49433a5..f39faad 100644 --- a/addon/components/modals/api-key-form.hbs +++ b/addon/components/modals/api-key-form.hbs @@ -1,47 +1,76 @@ - diff --git a/addon/components/widget/developer-activity.js b/addon/components/widget/developer-activity.js index 6a9cced..d1c1c77 100644 --- a/addon/components/widget/developer-activity.js +++ b/addon/components/widget/developer-activity.js @@ -3,9 +3,29 @@ import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +import { timeAgo, formatMs } from '../../utils/format'; + +const ICONS = { webhook: 'paper-plane', event: 'bolt', api_request: 'terminal' }; + +function toneFor(item) { + const status = String(item.status ?? '').toLowerCase(); + const code = parseInt(status, 10); + + if (status === 'failed' || status === 'error' || code >= 400) { + return 'bad'; + } + + if (status === 'pending' || status === 'retrying') { + return 'wait'; + } + + return 'ok'; +} export default class WidgetDeveloperActivityComponent extends Component { - @service fetch; + @service developerMetrics; + @service developerPanels; + @service store; @tracked data = null; @tracked error = null; @@ -16,24 +36,18 @@ export default class WidgetDeveloperActivityComponent extends Component { } get items() { - return this.data?.items ?? []; - } - - iconFor(type) { - if (type === 'webhook') { - return 'webhook'; - } - - if (type === 'event') { - return 'bolt'; - } - - return 'terminal'; + return (this.data?.items ?? []).map((item) => ({ + ...item, + icon: ICONS[item.type] ?? 'circle', + tone: toneFor(item), + when: timeAgo(item.created_at), + duration: item.duration_ms ? formatMs(item.duration_ms) : null, + })); } - @task *load() { + @task({ restartable: true }) *load() { try { - this.data = yield this.fetch.get('metrics/dev/activity', { limit: 14 }); + this.data = yield this.developerMetrics.fetch.get('metrics/dev/activity', { limit: 12 }); this.error = null; } catch (error) { this.error = error?.message ?? 'Unable to load activity'; diff --git a/addon/components/widget/developers-chart.hbs b/addon/components/widget/developers-chart.hbs index 4194167..2adc58d 100644 --- a/addon/components/widget/developers-chart.hbs +++ b/addon/components/widget/developers-chart.hbs @@ -1,8 +1,8 @@ -
+
-
{{@title}}
-
{{@subtitle}}
+
{{@title}}
+
{{@subtitle}}
{{#if this.load.isRunning}} -
+
+ + +
{{else if this.error}} -
{{this.error}}
+ + {{else if this.isEmpty}} + {{else}}
{{/if}}
+ {{#if this.summaryText}} + + {{/if}}
diff --git a/addon/components/widget/developers-chart.js b/addon/components/widget/developers-chart.js index 1f0891b..421f2de 100644 --- a/addon/components/widget/developers-chart.js +++ b/addon/components/widget/developers-chart.js @@ -5,14 +5,15 @@ import { action } from '@ember/object'; import { task } from 'ember-concurrency'; const COLORS = [ - ['#2563eb', 'rgba(37, 99, 235, 0.15)'], + ['#3485e2', 'rgba(52, 133, 226, 0.15)'], ['#10b981', 'rgba(16, 185, 129, 0.15)'], ['#ef4444', 'rgba(239, 68, 68, 0.15)'], ['#f59e0b', 'rgba(245, 158, 11, 0.15)'], ]; export default class WidgetDevelopersChartComponent extends Component { - @service fetch; + @service developerMetrics; + @service intl; @tracked data = null; @tracked error = null; @@ -22,6 +23,10 @@ export default class WidgetDevelopersChartComponent extends Component { this.load.perform(); } + get period() { + return this.developerMetrics.period; + } + get labels() { return this.data?.labels ?? []; } @@ -43,6 +48,45 @@ export default class WidgetDevelopersChartComponent extends Component { }); } + get isEmpty() { + return !this.datasets.some((dataset) => (dataset.data ?? []).some((value) => Number(value) > 0)); + } + + /** + * The summary line under the chart: top methods for api-traffic, the + * delivery summary for webhook-delivery. + */ + get summaryText() { + const { data } = this; + + if (!data) { + return null; + } + + if (Array.isArray(data.methods) && data.methods.length) { + const total = data.methods.reduce((sum, item) => sum + Number(item.value ?? 0), 0) || 1; + + return data.methods + .slice(0, 4) + .map((item) => `${item.label} ${Math.round((Number(item.value ?? 0) / total) * 100)}%`) + .join(' · '); + } + + if (data.summary) { + const { sent, succeeded, failed, average_attempts, average_duration_ms } = data.summary; + + return this.intl.t('developers.dashboard.webhook-summary', { + sent: Number(sent ?? 0).toLocaleString(), + succeeded: Number(succeeded ?? 0).toLocaleString(), + failed: Number(failed ?? 0).toLocaleString(), + attempts: average_attempts ?? 0, + duration: Math.round(average_duration_ms ?? 0), + }); + } + + return null; + } + get chartOptions() { return { responsive: true, @@ -52,19 +96,20 @@ export default class WidgetDevelopersChartComponent extends Component { legend: { display: true, position: 'top', - labels: { usePointStyle: true, boxWidth: 8, boxHeight: 8 }, + align: 'end', + labels: { usePointStyle: true, boxWidth: 6, boxHeight: 6, font: { size: 10 } }, }, }, scales: { - x: { grid: { display: false }, ticks: { maxRotation: 0 } }, - y: { beginAtZero: true, grid: { color: 'rgba(148, 163, 184, 0.16)' } }, + x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 8, font: { size: 10 } } }, + y: { beginAtZero: true, grid: { color: 'rgba(148, 163, 184, 0.16)' }, ticks: { font: { size: 10 } } }, }, }; } - @task *load() { + @task({ restartable: true }) *load() { try { - this.data = yield this.fetch.get(`metrics/dev/${this.args.endpoint}`, { period: this.args.period ?? '30d' }); + this.data = yield this.developerMetrics.fetchForPeriod(this.args.endpoint); this.error = null; } catch (error) { this.error = error?.message ?? 'Unable to load chart'; diff --git a/addon/components/widget/developers-kpi-tile.hbs b/addon/components/widget/developers-kpi-tile.hbs index b77d8fd..5b7b826 100644 --- a/addon/components/widget/developers-kpi-tile.hbs +++ b/addon/components/widget/developers-kpi-tile.hbs @@ -1,24 +1,28 @@ -
+
-
-
{{this.title}}
+
+
{{this.title}}
{{#if this.error}} -
{{this.error}}
- {{else if this.load.isRunning}} -
+
{{this.error}}
+ {{else if this.isLoading}} +
{{else}} -
{{this.value}}
+
{{this.value}}
{{/if}}
- {{or @footnote (t "developers.component.widget.dashboard.period")}} - + {{#if this.isLoading}} + + {{else}} + + {{this.footnote}} + {{/if}}
diff --git a/addon/components/widget/developers-kpi-tile.js b/addon/components/widget/developers-kpi-tile.js index 1e6628b..671c828 100644 --- a/addon/components/widget/developers-kpi-tile.js +++ b/addon/components/widget/developers-kpi-tile.js @@ -1,22 +1,33 @@ import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; +/** + * One KPI tile. Reads its metric from the shared developer-metrics service, + * which fetches `metrics/dev/kpis` once per period for all tiles. + */ export default class WidgetDevelopersKpiTileComponent extends Component { - @service fetch; - - @tracked data = null; - @tracked error = null; + @service developerMetrics; + @service intl; constructor() { super(...arguments); - this.load.perform(); + + if (!this.developerMetrics.kpis && !this.developerMetrics.loadKpis.isRunning) { + this.developerMetrics.loadKpis.perform(); + } + } + + get isLoading() { + return this.developerMetrics.loadKpis.isRunning && !this.developerMetrics.kpis; + } + + get error() { + return this.developerMetrics.kpisError; } get metric() { - return this.data?.metrics?.[this.args.metric] ?? {}; + return this.developerMetrics.metrics[this.args.metric] ?? {}; } get title() { @@ -27,30 +38,40 @@ export default class WidgetDevelopersKpiTileComponent extends Component { const value = this.metric.value ?? 0; if (this.metric.format === 'percent') { - return `${value}%`; + return `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 })}%`; } if (this.metric.format === 'duration') { - return `${Number(value).toLocaleString()}ms`; + return `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 0 })} ms`; } return Number(value).toLocaleString(); } + get hasDelta() { + return typeof this.metric.delta_percent === 'number'; + } + get deltaText() { const delta = this.metric.delta_percent; - if (typeof delta !== 'number') { - return 'Current'; + if (!this.hasDelta) { + return this.intl.t('developers.dashboard.kpi.no-previous'); + } + + const sign = delta > 0 ? '+' : ''; + + if (this.metric.format === 'percent') { + return `${sign}${delta} pt`; } - return `${delta > 0 ? '+' : ''}${delta}%`; + return `${sign}${delta}%`; } get deltaStatus() { const delta = this.metric.delta_percent; - if (typeof delta !== 'number' || delta === 0) { + if (!this.hasDelta || delta === 0) { return 'info'; } @@ -60,20 +81,11 @@ export default class WidgetDevelopersKpiTileComponent extends Component { return isGood ? 'success' : 'danger'; } - get accentClass() { - return `developers-kpi-accent-${this.args.accent ?? 'blue'}`; - } - - @task *load() { - try { - this.data = yield this.fetch.get('metrics/dev/kpis', { period: this.args.period ?? '30d' }); - this.error = null; - } catch (error) { - this.error = error?.message ?? 'Unable to load metric'; - } + get footnote() { + return this.args.footnote ?? this.intl.t('developers.dashboard.kpi.vs-previous', { period: this.developerMetrics.period }); } @action refresh() { - this.load.perform(); + this.developerMetrics.refreshKpis(); } } diff --git a/addon/components/widget/endpoint-health.hbs b/addon/components/widget/endpoint-health.hbs index 4db9ccd..97957b7 100644 --- a/addon/components/widget/endpoint-health.hbs +++ b/addon/components/widget/endpoint-health.hbs @@ -1,4 +1,5 @@ -
+{{! template-lint-disable require-presentational-children no-invalid-interactive }} +
{{t "developers.component.widget.dashboard.endpoint-health.title"}}
@@ -10,21 +11,40 @@
{{#if this.load.isRunning}} -
+
{{else if this.error}} -
{{this.error}}
+ {{else if this.items.length}} - {{#each this.items as |item|}} -
-
-
{{item.url}}
-
{{item.status}} / {{item.mode}} / {{item.deliveries}} deliveries
-
-
{{item.success_rate}}%
-
- {{/each}} + + + + + + + + + + + {{#each this.items as |item|}} + + + + + + + {{/each}} + +
{{t "developers.component.widget.dashboard.endpoint-health.endpoint"}}{{t "developers.component.widget.dashboard.endpoint-health.success"}}{{t "developers.component.widget.dashboard.endpoint-health.failures"}}{{t "developers.component.widget.dashboard.endpoint-health.last"}}
+
+ + + {{item.shortUrl}} + {{item.mode}} · {{item.deliveries}} {{t "developers.component.widget.dashboard.endpoint-health.deliveries"}} + +
+
{{item.rateText}}{{item.failures}}{{or item.lastDelivery "—"}}
{{else}} -
{{t "developers.component.widget.dashboard.empty"}}
+ {{/if}}
diff --git a/addon/components/widget/endpoint-health.js b/addon/components/widget/endpoint-health.js index 8f65eac..bf89dcf 100644 --- a/addon/components/widget/endpoint-health.js +++ b/addon/components/widget/endpoint-health.js @@ -3,9 +3,55 @@ import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +import { shortUrl, timeAgo } from '../../utils/format'; + +/** + * The health vocabulary every webhook surface shares: healthy ≥ 99.5%, + * degraded ≥ 95%, failing below, with a 10-consecutive-failure guard. + */ +export function endpointHealth(item = {}) { + if (item.status === 'disabled') { + return 'disabled'; + } + + const rate = Number(item.success_rate); + const failures = Number(item.failures ?? 0); + const deliveries = Number(item.deliveries ?? 0); + + if (deliveries === 0) { + return 'idle'; + } + + if (rate < 95 || failures >= 10) { + return 'failing'; + } + + if (rate < 99.5) { + return 'degraded'; + } + + return 'healthy'; +} + +export const HEALTH_BADGE = { + healthy: 'success', + degraded: 'warning', + failing: 'danger', + disabled: 'inactive', + idle: 'info', +}; + +export const HEALTH_TONE = { + healthy: 'is-green', + degraded: 'is-amber', + failing: 'is-red', + disabled: 'is-gray', + idle: 'is-gray', +}; export default class WidgetEndpointHealthComponent extends Component { - @service fetch; + @service developerMetrics; + @service hostRouter; @tracked data = null; @tracked error = null; @@ -15,21 +61,29 @@ export default class WidgetEndpointHealthComponent extends Component { this.load.perform(); } - get items() { - return this.data?.items ?? []; + get period() { + return this.developerMetrics.period; } - statusClass(item) { - if (item.failures > 0 || item.success_rate < 95) { - return 'text-rose-600 dark:text-rose-300'; - } + get items() { + return (this.data?.items ?? []).map((item) => { + const health = endpointHealth(item); - return 'text-emerald-600 dark:text-emerald-300'; + return { + ...item, + health, + badge: HEALTH_BADGE[health], + tone: HEALTH_TONE[health], + shortUrl: shortUrl(item.url), + lastDelivery: timeAgo(item.last_delivery_at), + rateText: item.success_rate === null || item.success_rate === undefined ? '—' : `${Number(item.success_rate).toFixed(1)}%`, + }; + }); } - @task *load() { + @task({ restartable: true }) *load() { try { - this.data = yield this.fetch.get('metrics/dev/endpoint-health', { period: this.args.period ?? '30d' }); + this.data = yield this.developerMetrics.fetchForPeriod('endpoint-health'); this.error = null; } catch (error) { this.error = error?.message ?? 'Unable to load endpoints'; @@ -39,4 +93,8 @@ export default class WidgetEndpointHealthComponent extends Component { @action refresh() { this.load.perform(); } + + @action open(item) { + return this.hostRouter.transitionTo('console.developers.webhooks.view', item.id); + } } diff --git a/addon/components/widget/event-stream.hbs b/addon/components/widget/event-stream.hbs index b7207be..17ed008 100644 --- a/addon/components/widget/event-stream.hbs +++ b/addon/components/widget/event-stream.hbs @@ -1,35 +1,44 @@ -
+
{{t "developers.component.widget.dashboard.event-stream.title"}}
{{t "developers.component.widget.dashboard.event-stream.subtitle"}}
+ {{#if this.data}} + {{this.total}} + {{/if}}
{{#if this.load.isRunning}} -
+
{{else if this.error}} -
{{this.error}}
- {{else}} -
{{t "developers.component.widget.dashboard.event-stream.types"}}
- {{#each this.types as |item|}} -
-
{{item.label}}
-
{{item.value}}
+ + {{else if (or this.types.length this.sources.length)}} +
+
+
{{t "developers.component.widget.dashboard.event-stream.types"}}
+ {{#each this.types as |item|}} +
+
{{item.label}}{{item.valueText}}
+
+
+ {{/each}}
- {{else}} -
{{t "developers.component.widget.dashboard.empty"}}
- {{/each}} -
{{t "developers.component.widget.dashboard.event-stream.sources"}}
- {{#each this.sources as |item|}} -
-
{{item.label}}
-
{{item.value}}
+
+
{{t "developers.component.widget.dashboard.event-stream.sources"}}
+ {{#each this.sources as |item|}} +
+
{{item.label}}{{item.valueText}}
+
+
+ {{/each}}
- {{/each}} +
+ {{else}} + {{/if}}
diff --git a/addon/components/widget/event-stream.js b/addon/components/widget/event-stream.js index 360cea6..408259d 100644 --- a/addon/components/widget/event-stream.js +++ b/addon/components/widget/event-stream.js @@ -4,8 +4,14 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +function withShare(items = []) { + const max = Math.max(...items.map((item) => Number(item.value ?? 0)), 1); + + return items.map((item) => ({ ...item, share: Math.max(2, Math.round((Number(item.value ?? 0) / max) * 100)), valueText: Number(item.value ?? 0).toLocaleString() })); +} + export default class WidgetEventStreamComponent extends Component { - @service fetch; + @service developerMetrics; @tracked data = null; @tracked error = null; @@ -15,17 +21,25 @@ export default class WidgetEventStreamComponent extends Component { this.load.perform(); } + get period() { + return this.developerMetrics.period; + } + get types() { - return this.data?.types ?? []; + return withShare((this.data?.types ?? []).slice(0, 6)); } get sources() { - return this.data?.sources ?? []; + return withShare(this.data?.sources ?? []); + } + + get total() { + return Number(this.data?.total ?? 0).toLocaleString(); } - @task *load() { + @task({ restartable: true }) *load() { try { - this.data = yield this.fetch.get('metrics/dev/events', { period: this.args.period ?? '30d' }); + this.data = yield this.developerMetrics.fetchForPeriod('events'); this.error = null; } catch (error) { this.error = error?.message ?? 'Unable to load events'; diff --git a/addon/components/widget/quick-resources.hbs b/addon/components/widget/quick-resources.hbs index dfac2ea..e06f40b 100644 --- a/addon/components/widget/quick-resources.hbs +++ b/addon/components/widget/quick-resources.hbs @@ -5,13 +5,9 @@
{{t "developers.component.widget.dashboard.quick-resources.subtitle"}}
-
+
{{#each this.resources as |resource|}} - - - {{resource.label}} - - +
diff --git a/addon/components/widget/quick-resources.js b/addon/components/widget/quick-resources.js index 6d75b8b..ba0b6ac 100644 --- a/addon/components/widget/quick-resources.js +++ b/addon/components/widget/quick-resources.js @@ -1,11 +1,33 @@ import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; export default class WidgetQuickResourcesComponent extends Component { - resources = [ - { label: 'API Keys', route: 'api-keys.index', icon: 'key' }, - { label: 'Webhooks', route: 'webhooks.index', icon: 'globe' }, - { label: 'Logs', route: 'logs.index', icon: 'terminal' }, - { label: 'Events', route: 'events.index', icon: 'bolt' }, - { label: 'Sockets', route: 'sockets.index', icon: 'plug' }, - ]; + @service intl; + @service hostRouter; + @service apiKeyActions; + @service webhookActions; + + get resources() { + return [ + { label: this.intl.t('developers.dashboard.quick.create-key'), icon: 'key', onClick: () => this.apiKeyActions.createApiKey(), permission: 'developers create api-key' }, + { label: this.intl.t('developers.dashboard.quick.add-endpoint'), icon: 'globe', onClick: () => this.webhookActions.createWebhook(), permission: 'developers create webhook' }, + { label: this.intl.t('developers.application.sidebar.items.events'), icon: 'bolt', route: 'console.developers.events.index' }, + { label: this.intl.t('developers.application.sidebar.items.requests'), icon: 'terminal', route: 'console.developers.logs.index' }, + { label: this.intl.t('developers.application.sidebar.items.channels'), icon: 'tower-broadcast', route: 'console.developers.sockets.index' }, + { label: this.intl.t('developers.dashboard.quick.api-reference'), icon: 'book', url: 'https://docs.fleetbase.io' }, + ]; + } + + @action open(resource) { + if (typeof resource.onClick === 'function') { + return resource.onClick(); + } + + if (resource.url) { + return window.open(resource.url, '_blank', 'noopener'); + } + + return this.hostRouter.transitionTo(resource.route); + } } diff --git a/addon/controllers/api-keys/index.js b/addon/controllers/api-keys/index.js index c8e6cf2..58cfc4d 100644 --- a/addon/controllers/api-keys/index.js +++ b/addon/controllers/api-keys/index.js @@ -1,14 +1,12 @@ import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { action, computed } from '@ember/object'; +import { action } from '@ember/object'; import { isBlank } from '@ember/utils'; -import { not } from '@ember/object/computed'; import { timeout } from 'ember-concurrency'; import { later } from '@ember/runloop'; import { task } from 'ember-concurrency-decorators'; -import { format as formatDate } from 'date-fns'; -import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; +import { maskKey, timeAgo } from '../../utils/format'; export default class ApiKeysIndexController extends Controller { @service apiKeyActions; @@ -18,10 +16,7 @@ export default class ApiKeysIndexController extends Controller { @service notifications; @service store; @service crud; - @service fetch; - @service theme; @service hostRouter; - @service universe; @service abilities; /** @@ -29,80 +24,19 @@ export default class ApiKeysIndexController extends Controller { * * @var {Array} */ - queryParams = this.apiKeyActions.queryParamsFor(['page', 'limit', 'sort', 'query', 'view_api_key']); + queryParams = this.apiKeyActions.queryParamsFor(['page', 'limit', 'sort', 'query', 'view_api_key', 'expires_at', 'last_used_at']); - /** - * Expiration options for api keys - * - * @var {Array} - */ - expirationOptions = ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']; - - /** - * Tracks the current console environment mode - * - * @memberof ApiKeysIndexController - */ - @tracked testMode = false; - - /** - * The current page of data being viewed - * - * @var {Integer} - */ @tracked page = 1; - - /** - * The maximum number of items to show per page - * - * @var {Integer} - */ @tracked limit; - - /** - * The param to sort the data on, the param with prepended `-` is descending - * - * @var {String} - */ @tracked sort; - - /** - * The param to query the data by - * - * @var {String} - */ @tracked query; - - /** - * Deep-linked API key to open in the edit modal. - * - * @var {String} - */ @tracked view_api_key; + @tracked expires_at; + @tracked last_used_at; + @tracked table; - /** - * Checks if console environment is in live mode - * - * @memberof ApiKeysIndexController - */ - @not('isTestMode') isLiveMode; - - /** - * Checks if console environment is in test mode - * - * @memberof ApiKeysIndexController - */ - @computed('testMode') get isTestMode() { - return this.testMode === true; - } - - /** - * gets the user selected key to view data from - * - * @memberof ApiKeysIndexController - */ - @computed('currentUser.options.testKey') get testKey() { - return this.currentUser.getOption('testKey'); + get isSandbox() { + return this.currentUser.getOption('sandbox', false) === true; } /** @@ -112,15 +46,6 @@ export default class ApiKeysIndexController extends Controller { */ get actionButtons() { return [ - { - id: 'create', - type: 'primary', - icon: 'plus', - iconPrefix: 'fas', - text: this.intl.t('developers.common.new'), - onClick: this.createApiKey, - permission: 'developers create api-key', - }, { id: 'export', icon: 'long-arrow-up', @@ -129,6 +54,15 @@ export default class ApiKeysIndexController extends Controller { onClick: this.exportApiKeys, permission: 'developers export api-key', }, + { + id: 'create', + type: 'primary', + icon: 'plus', + iconPrefix: 'fas', + text: this.intl.t('developers.api-keys.index.create'), + onClick: this.createApiKey, + permission: 'developers create api-key', + }, ]; } @@ -142,6 +76,7 @@ export default class ApiKeysIndexController extends Controller { { id: 'bulk-delete', label: this.intl.t('developers.api-keys.index.delete-api'), + icon: 'trash', class: 'text-red-500', fn: this.bulkDeleteApiCredentials, permission: 'developers delete api-key', @@ -154,171 +89,171 @@ export default class ApiKeysIndexController extends Controller { * * @var {Array} */ - @tracked columns = [ - { - id: 'name', - sticky: true, - label: this.intl.t('developers.common.name'), - valuePath: 'name', - cellComponent: 'table/cell/anchor', - permission: 'developers view api-key', - action: this.editApiKey, - resizable: true, - sortable: false, - }, - { - id: 'key', - label: this.intl.t('developers.api-keys.index.public-key'), - valuePath: 'key', - sortable: false, - resizable: true, - cellComponent: 'click-to-copy', - }, - { - id: 'secret', - label: this.intl.t('developers.api-keys.index.secret-key'), - valuePath: 'secret', - sortable: false, - resizable: true, - width: 100, - cellComponent: 'click-to-reveal', - cellComponentArgs: { - clickToCopy: true, - wrapperClass: 'w-72', + get columns() { + return [ + { + id: 'name', + label: this.intl.t('developers.api-keys.index.key'), + valuePath: 'name', + width: 320, + sortable: false, + resizable: true, + cellComponent: 'table/cell/identity', + resourceType: 'api-credential', + permission: 'developers view api-key', + action: this.viewApiKey, + wrapperClass: 'max-w-full', + badgeLimit: 2, + badges: (apiKey) => this.badgesFor(apiKey), + showStatusDot: true, + }, + { + id: 'key', + label: this.intl.t('developers.api-keys.index.public-key'), + valuePath: 'key', + width: 200, + sortable: false, + resizable: true, + cellComponent: 'api-key/key-cell', + }, + { + id: 'expires-at', + label: this.intl.t('developers.api-keys.index.expiry'), + valuePath: 'expires_at', + width: 150, + sortable: false, + filterable: true, + filterParam: 'expires_at', + filterLabel: this.intl.t('developers.api-keys.index.expiry'), + filterComponent: 'filter/date', + cellComponent: 'api-key/expiry-cell', + }, + { + id: 'last-used', + label: this.intl.t('developers.api-keys.index.last-used'), + valuePath: 'last_used_at', + width: 140, + sortable: false, + filterable: true, + filterParam: 'last_used_at', + filterLabel: this.intl.t('developers.api-keys.index.last-used'), + filterComponent: 'filter/date', + cellComponent: 'api-key/last-used-cell', + }, + { + id: 'created-at', + label: this.intl.t('developers.common.created'), + valuePath: 'createdAtShort', + width: 120, + sortable: false, + }, + { + id: 'row-actions', + label: '', + cellComponent: 'table/cell/dropdown', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + ddMenuLabel: null, + cellClassNames: 'overflow-visible', + wrapperClass: 'flex items-center justify-end mx-2', + align: 'right', + sticky: 'right', + width: 50, + actions: [ + { id: 'view-api-key', label: this.intl.t('developers.api-keys.actions.view'), icon: 'eye', fn: this.viewApiKey, permission: 'developers view api-key' }, + { + id: 'view-request-logs', + label: this.intl.t('developers.api-keys.actions.view-requests'), + icon: 'terminal', + fn: this.viewRequestLogs, + permission: 'developers list log', + }, + { + id: 'watch-channel', + label: this.intl.t('developers.api-keys.actions.watch-channel'), + icon: 'tower-broadcast', + fn: this.watchChannel, + permission: 'developers view socket', + }, + { separator: true }, + { id: 'edit-api-key', label: this.intl.t('developers.api-keys.actions.edit'), icon: 'pencil', fn: this.editApiKey, permission: 'developers update api-key' }, + { id: 'rename-api-key', label: this.intl.t('developers.api-keys.actions.rename'), icon: 'i-cursor', fn: this.renameApiKey, permission: 'developers update api-key' }, + { id: 'roll-api-key', label: this.intl.t('developers.api-keys.actions.roll'), icon: 'rotate', fn: this.rollApiKey, permission: 'developers roll api-key' }, + { separator: true }, + { + id: 'delete-api-key', + label: this.intl.t('developers.api-keys.actions.delete'), + icon: 'trash', + fn: this.deleteApiKey, + class: 'text-red-700 hover:text-red-800', + permission: 'developers delete api-key', + }, + ], }, - }, - { - id: 'environment', - label: this.intl.t('developers.api-keys.index.enviroment'), - valuePath: 'environment', - sortable: false, - resizable: true, - cellComponent: 'table/cell/status', - }, - { - id: 'expires-at', - label: this.intl.t('developers.api-keys.index.expiry'), - valuePath: 'expiresAt', - sortable: false, - tooltip: true, - resizable: true, - cellClassNames: 'overflow-visible', - }, - { - id: 'last-used', - label: this.intl.t('developers.api-keys.index.last-used'), - valuePath: 'lastUsed', - sortable: false, - tooltip: true, - resizable: true, - cellClassNames: 'overflow-visible', - }, - { - id: 'created-at', - label: this.intl.t('developers.common.created'), - valuePath: 'createdAt', - sortable: false, - tooltip: true, - resizable: true, - cellClassNames: 'overflow-visible', - }, - { - id: 'row-actions', - label: '', - cellComponent: 'table/cell/dropdown', - ddButtonText: false, - ddButtonIcon: 'ellipsis-h', - ddButtonIconPrefix: 'fas', - ddMenuLabel: 'API Key Actions', - cellClassNames: 'overflow-visible', - wrapperClass: 'flex items-center justify-end mx-2', - align: 'right', - sticky: 'right', - width: 60, - actions: [ - { - id: 'edit-api-key', - label: this.intl.t('developers.api-keys.index.edit-key'), - fn: this.editApiKey, - permission: 'developers view api-key', - }, - { - id: 'roll-api-key', - label: this.intl.t('developers.api-keys.index.roll-key'), - fn: this.rollApiKey, - permission: 'developers roll api-key', - }, - { - id: 'view-request-logs', - label: this.intl.t('developers.api-keys.index.view-logs'), - fn: this.viewRequestLogs, - permission: 'developers view log', - }, - { - id: 'delete-api-key', - label: this.intl.t('developers.api-keys.index.delete-key'), - fn: this.deleteApiKey, - className: 'text-red-700 hover:text-red-800', - permission: 'developers delete api-key', - }, - ], - }, - ]; + ]; + } /** - * The search task. - * - * @void + * Identity badges: environment and expiry state. */ - @task({ restartable: true }) *search({ target: { value } }) { - // if no query don't search - if (isBlank(value)) { - this.query = null; - return; + badgesFor(apiKey) { + const badges = []; + const expiresAt = apiKey.expires_at ? new Date(apiKey.expires_at) : null; + const now = Date.now(); + + if (expiresAt && expiresAt.getTime() < now) { + badges.push({ key: 'expired', label: this.intl.t('developers.api-keys.badges.expired'), class: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200' }); + } else if (expiresAt && expiresAt.getTime() - now < 30 * 86400000) { + badges.push({ + key: 'expiring', + label: this.intl.t('developers.api-keys.badges.expiring', { when: timeAgo(expiresAt)?.replace(' ago', '') ?? '' }), + class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + }); } - // timeout for typing - yield timeout(250); - - // reset page for results - if (this.page > 1) { - this.page = 1; + if (apiKey.test_mode) { + badges.push({ key: 'test', label: this.intl.t('developers.common.test'), class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }); + } else { + badges.push({ key: 'live', label: this.intl.t('developers.common.live'), class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' }); } - // update the query param - this.query = value; + return badges; } /** - * Toggle test mode - * - * @void + * The empty-state copy for the table. */ - @action toggleTestMode(testMode = false) { - this.currentUser.setOption('sandbox', testMode); - this.testMode = testMode; - this.theme.setEnvironment(); - this.store.unloadAll(); - this.hostRouter.refresh().then(() => { - this.currentUser.setOption('testKey', this.model?.firstObject?.key); - }); + get emptyState() { + return { + icon: 'key', + title: this.isSandbox ? this.intl.t('developers.api-keys.empty.title-test') : this.intl.t('developers.api-keys.empty.title-live'), + message: this.intl.t('developers.api-keys.empty.message'), + action: { label: this.intl.t('developers.api-keys.index.create'), icon: 'plus', onClick: this.createApiKey, permission: 'developers create api-key' }, + }; } /** - * Toggle test key + * The search task. * * @void */ - @action toggleTestKey({ target: { value } }) { + @task({ restartable: true }) *search({ target: { value } }) { if (isBlank(value)) { - this.currentUser.setOption('testKey', null); + this.query = null; return; } - this.currentUser.setOption('testKey', value); + yield timeout(250); + + if (this.page > 1) { + this.page = 1; + } + + this.query = value; } - // Creating and editing keys lives in the api-key-actions service so other engines can open these dialogs too. + // Dialogs and navigation live in the api-key-actions service so other engines can open them too. @action createApiKey(...args) { return this.apiKeyActions.createApiKey(...args); } @@ -327,172 +262,47 @@ export default class ApiKeysIndexController extends Controller { return this.apiKeyActions.editApiKey(...args); } - /** - * Toggles modal to create a new API key - * - * @void - */ - @action renameApiKey(apiKey) { - const formPermission = 'developers update api-key'; - const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); - - this.modalsManager.show('modals/rename-api-key-form', { - title: this.intl.t('developers.api-keys.index.rename-api-key-title', { apiKeyName }), - acceptButtonDisabled: this.abilities.cannot(formPermission), - acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null, - apiKey, - formPermission, - confirm: async (modal) => { - modal.startLoading(); - - try { - await apiKey.save(); - this.notifications.success(this.intl.t('developers.api-keys.index.rename-api-key-success-message', { apiKeyName })); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); + @action viewApiKey(...args) { + return this.apiKeyActions.viewApiKey(...args); } - /** - * Toggles dialog to delete API key - * - * @void - */ - @action deleteApiKey(apiKey) { - const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); - this.modalsManager.confirm({ - title: this.intl.t('developers.api-keys.index.delete-api-key-title', { apiKeyName }), - body: this.intl.t('developers.api-keys.index.delete-api-key-body'), - confirm: async (modal) => { - modal.startLoading(); - - try { - await apiKey.destroyRecord(); - this.notifications.success(this.intl.t('developers.api-keys.index.delete-api-key-title', { apiKeyName })); - return this.hostRouter.refresh(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); + @action renameApiKey(...args) { + return this.apiKeyActions.renameApiKey(...args); } - /** - * Bulk deletes selected `api-credential` via confirm prompt - * - * @param {Array} selected an array of selected models - * @void - */ - @action bulkDeleteApiCredentials() { - const selected = this.table.selectedRows; + @action rollApiKey(...args) { + return this.apiKeyActions.rollApiKey(...args); + } - this.crud.bulkDelete(selected, { - acceptButtonText: this.intl.t('developers.api-keys.index.delete-accept-button-text'), - onSuccess: () => { - this.hostRouter.refresh(); - }, - }); + @action deleteApiKey(...args) { + return this.apiKeyActions.deleteApiKey(...args); } - /** - * Toggles dialog to roll API key - * - * @void - */ - @action rollApiKey(apiKey) { - const formPermission = 'developers roll api-key'; - const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); - - this.modalsManager.show('modals/roll-api-key-form', { - title: this.intl.t('developers.api-keys.index.roll-api-key', { apiKeyName }), - modalClass: 'roll-key-modal', - acceptButtonText: this.intl.t('developers.api-keys.index.roll-api-key-button-text'), - acceptButtonDisabled: this.abilities.cannot(formPermission), - acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null, - user: this.currentUser.user, - expirationOptions: this.expirationOptions, - setExpiration: ({ target }) => { - apiKey.expires_at = target.value || null; - }, - viewRequestLogs: this.viewRequestLogs, - password: null, - formPermission, - apiKey, - confirm: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.patch( - `api-credentials/roll/${apiKey.id}`, - { password: modal.getOption('password'), expiration: apiKey.get('expires_at') }, - { normalizeToEmberData: true } - ); - this.notifications.success(this.intl.t('developers.api-keys.index.roll-api-key-success-message', { apiKeyName })); - modal.done(); - } catch (error) { - this.notifications.serverError(error, this.intl.t('developers.api-keys.index.roll-api-key-error-message')); - modal.stopLoading(); - } - }, - }); + @action viewRequestLogs(...args) { + return this.apiKeyActions.viewRequestLogs(...args); } - /** - * Redirects user to request logs for this api key - * - * @void - */ - @action viewRequestLogs(apiKey) { - return this.hostRouter.transitionTo('console.developers.logs.index', { - queryParams: { key: apiKey.id }, - }); + @action watchChannel(...args) { + return this.apiKeyActions.watchChannel(...args); + } + + @action exportApiKeys() { + return this.apiKeyActions.exportApiKeys(); } /** - * Toggles dialog to export API credentials + * Bulk deletes selected `api-credential` via confirm prompt * * @void */ - @action exportApiKeys() { - this.modalsManager.show('modals/export-form', { - title: this.intl.t('developers.api-keys.index.export-api'), - acceptButtonText: this.intl.t('developers.api-keys.index.export-api-accept-button-text'), - formatOptions: ['csv', 'xlsx', 'xls', 'html', 'pdf'], - format: 'xlsx', - setFormat: ({ target }) => { - this.modalsManager.setOption('format', target.value || null); - }, - confirm: async (modal) => { - modal.startLoading(); - - const format = modal.getOption('format', 'xlsx'); - - try { - await this.fetch.download( - `api-credentials/export`, - { - format, - }, - { - fileName: `api-credentials-${formatDate(new Date(), 'yyyy-MM-dd-HH:mm')}.${format}`, - } - ); - later( - this, - () => { - return modal.done(); - }, - 600 - ); - } catch (error) { - this.notifications.serverError(error, this.intl.t('developers.api-keys.index.export-api-error-message')); - modal.stopLoading(); - } + @action bulkDeleteApiCredentials() { + const selected = this.table?.selectedRows ?? []; + + this.crud.bulkDelete(selected, { + modelNamePath: 'name', + acceptButtonText: this.intl.t('developers.api-keys.index.delete-accept-button-text'), + onSuccess: () => { + this.hostRouter.refresh(); }, }); } @@ -513,12 +323,9 @@ export default class ApiKeysIndexController extends Controller { try { const apiKey = this.store.peekRecord('api-credential', apiKeyId) ?? (await this.store.findRecord('api-credential', apiKeyId)); - this.editApiKey(apiKey, { - onDecline: this.clearDeepLinkedApiKey, - onFinish: this.clearDeepLinkedApiKey, - }); + later(this, () => this.editApiKey(apiKey, { onDecline: this.clearDeepLinkedApiKey, onFinish: this.clearDeepLinkedApiKey }), 10); } catch (_) { - this.notifications.warning('Unable to open the selected API key.'); + this.notifications.warning(this.intl.t('developers.api-keys.index.deep-link-error')); this.clearDeepLinkedApiKey(); } } @@ -531,4 +338,8 @@ export default class ApiKeysIndexController extends Controller { this.view_api_key = null; this.hostRouter.transitionTo({ queryParams: { view_api_key: null } }); } + + maskKey(key) { + return maskKey(key); + } } diff --git a/addon/controllers/application.js b/addon/controllers/application.js index b4a16cc..beff9fc 100644 --- a/addon/controllers/application.js +++ b/addon/controllers/application.js @@ -11,14 +11,14 @@ export default class ApplicationController extends Controller { return [ { label: this.intl.t('developers.application.sidebar.items.home'), - description: 'Developer dashboard and API health overview.', + description: this.intl.t('developers.application.sidebar.descriptions.home'), icon: 'home', route: 'console.developers.home', keywords: ['dashboard', 'overview', 'metrics'], }, { label: this.intl.t('developers.application.sidebar.items.api-keys'), - description: 'Create and manage API credentials.', + description: this.intl.t('developers.application.sidebar.descriptions.api-keys'), icon: 'key', route: 'console.developers.api-keys', permission: 'developers list api-key', @@ -27,39 +27,39 @@ export default class ApplicationController extends Controller { }, { label: this.intl.t('developers.application.sidebar.items.webhooks'), - description: 'Configure webhook endpoints.', - icon: 'globe-asia', + description: this.intl.t('developers.application.sidebar.descriptions.webhooks'), + icon: 'globe', route: 'console.developers.webhooks', permission: 'developers list webhook', visible: this.can('developers see webhook'), - keywords: ['endpoints', 'callbacks', 'notifications'], + keywords: ['endpoints', 'callbacks', 'deliveries', 'notifications'], }, { - label: this.intl.t('developers.application.sidebar.items.websockets'), - description: 'Inspect websocket channels.', - icon: 'plug', - route: 'console.developers.sockets', - permission: 'developers list socket', - visible: this.can('developers see socket'), - keywords: ['sockets', 'channels', 'realtime'], + label: this.intl.t('developers.application.sidebar.items.events'), + description: this.intl.t('developers.application.sidebar.descriptions.events'), + icon: 'bolt', + route: 'console.developers.events', + permission: 'developers list event', + visible: this.can('developers see event'), + keywords: ['event stream', 'activity', 'webhook events'], }, { - label: this.intl.t('developers.application.sidebar.items.logs'), - description: 'Review API request logs.', - icon: 'file-lines', + label: this.intl.t('developers.application.sidebar.items.requests'), + description: this.intl.t('developers.application.sidebar.descriptions.requests'), + icon: 'terminal', route: 'console.developers.logs', permission: 'developers list log', visible: this.can('developers see log'), - keywords: ['requests', 'traffic', 'debugging'], + keywords: ['logs', 'requests', 'traffic', 'debugging'], }, { - label: this.intl.t('developers.application.sidebar.items.events'), - description: 'Browse platform events.', - icon: 'calendar-day', - route: 'console.developers.events', - permission: 'developers list event', - visible: this.can('developers see event'), - keywords: ['event stream', 'activity', 'webhook events'], + label: this.intl.t('developers.application.sidebar.items.channels'), + description: this.intl.t('developers.application.sidebar.descriptions.channels'), + icon: 'tower-broadcast', + route: 'console.developers.sockets', + permission: 'developers list socket', + visible: this.can('developers see socket'), + keywords: ['sockets', 'websockets', 'channels', 'realtime'], }, ]; } diff --git a/addon/controllers/home/index.js b/addon/controllers/home/index.js new file mode 100644 index 0000000..0402489 --- /dev/null +++ b/addon/controllers/home/index.js @@ -0,0 +1,14 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class HomeIndexController extends Controller { + @service developerMetrics; + + constructor() { + super(...arguments); + + if (!this.developerMetrics.kpis) { + this.developerMetrics.loadKpis.perform(); + } + } +} diff --git a/addon/extension.js b/addon/extension.js index 86d852b..c961ad7 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -9,38 +9,38 @@ export default { menuService.registerHeaderMenuItem('Developers', 'console.developers', { icon: 'code', priority: 2, - description: 'API keys, webhooks, socket channels, event logs, and developer tooling.', + description: 'API keys, webhooks, events, request logs and live channels.', shortcuts: [ { title: 'API Keys', - description: 'Create and manage API keys for authenticating your integrations.', + description: 'Create and manage keys for your integrations.', icon: 'key', route: 'console.developers.api-keys', }, { title: 'Webhooks', - description: 'Configure webhook endpoints to receive real-time event notifications.', + description: 'Endpoints that receive platform events.', icon: 'globe', route: 'console.developers.webhooks', }, - { - title: 'Sockets', - description: 'Monitor active WebSocket channels and connected clients.', - icon: 'plug', - route: 'console.developers.sockets', - }, { title: 'Events', - description: 'Browse the full history of platform events and payloads.', + description: 'Every platform event and its payload.', icon: 'bolt', route: 'console.developers.events', }, { - title: 'Logs', - description: 'Inspect API request and response logs for debugging.', + title: 'Requests', + description: 'API request and response logs.', icon: 'terminal', route: 'console.developers.logs', }, + { + title: 'Channels', + description: 'Watch live socket channels.', + icon: 'tower-broadcast', + route: 'console.developers.sockets', + }, ], }); @@ -199,6 +199,7 @@ export default { }), ]; + // eslint-disable-next-line no-unused-vars const getWidgetById = (id = null, mutate = null) => { if (!id) return null; const widget = widgets.find((w) => w.id === id); diff --git a/addon/helpers/dev-duration.js b/addon/helpers/dev-duration.js new file mode 100644 index 0000000..d19afd5 --- /dev/null +++ b/addon/helpers/dev-duration.js @@ -0,0 +1,10 @@ +import { helper } from '@ember/component/helper'; +import { formatMs, formatSeconds } from '../utils/format'; + +/** + * `{{dev-duration value unit="s"}}` for core-api durations stored in + * seconds, `{{dev-duration value}}` for milliseconds. + */ +export default helper(function devDurationHelper([value], { unit = 'ms' } = {}) { + return unit === 's' ? formatSeconds(value) : formatMs(value); +}); diff --git a/addon/helpers/mask-key.js b/addon/helpers/mask-key.js new file mode 100644 index 0000000..ecb374e --- /dev/null +++ b/addon/helpers/mask-key.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import { maskKey } from '../utils/format'; + +export default helper(function maskKeyHelper([key, head, tail]) { + return maskKey(key, head, tail); +}); diff --git a/addon/helpers/short-url.js b/addon/helpers/short-url.js new file mode 100644 index 0000000..e59cdf9 --- /dev/null +++ b/addon/helpers/short-url.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import { shortUrl } from '../utils/format'; + +export default helper(function shortUrlHelper([value]) { + return shortUrl(value); +}); diff --git a/addon/helpers/status-tone.js b/addon/helpers/status-tone.js new file mode 100644 index 0000000..05eea45 --- /dev/null +++ b/addon/helpers/status-tone.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import { statusTone } from '../utils/format'; + +export default helper(function statusToneHelper([code]) { + return statusTone(code); +}); diff --git a/addon/helpers/time-ago.js b/addon/helpers/time-ago.js new file mode 100644 index 0000000..1321059 --- /dev/null +++ b/addon/helpers/time-ago.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import { timeAgo } from '../utils/format'; + +export default helper(function timeAgoHelper([value]) { + return timeAgo(value); +}); diff --git a/addon/helpers/time-stamp.js b/addon/helpers/time-stamp.js new file mode 100644 index 0000000..053ce06 --- /dev/null +++ b/addon/helpers/time-stamp.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import { timeStamp } from '../utils/format'; + +export default helper(function timeStampHelper([value, pattern]) { + return timeStamp(value, pattern); +}); diff --git a/addon/instance-initializers/register-developers-resource-descriptors.js b/addon/instance-initializers/register-developers-resource-descriptors.js new file mode 100644 index 0000000..c392f08 --- /dev/null +++ b/addon/instance-initializers/register-developers-resource-descriptors.js @@ -0,0 +1,29 @@ +import buildDevelopersResourceDescriptors from '../utils/resource-descriptors'; + +/** + * Registers the Developers resource descriptors (API keys, webhook endpoints, + * events, request logs, delivery attempts, socket channels) with the resource + * registry ember-ui provides, so identity cells, select options and hover + * summaries render them. Runs with the engine instance so the openers reach + * the engine's panel service and router. + */ +export function initialize(engineInstance) { + let registry; + + try { + registry = engineInstance.lookup('service:resource-registry'); + } catch { + registry = null; + } + + if (!registry || typeof registry.registerDescriptors !== 'function') { + return; + } + + registry.registerDescriptors(buildDevelopersResourceDescriptors(engineInstance)); +} + +export default { + name: 'register-developers-resource-descriptors', + initialize, +}; diff --git a/addon/routes/api-keys/index.js b/addon/routes/api-keys/index.js index 91dacb8..752502f 100644 --- a/addon/routes/api-keys/index.js +++ b/addon/routes/api-keys/index.js @@ -28,10 +28,16 @@ export default class ApiKeysIndexRoute extends Route { sort: { refreshModel: true, }, + expires_at: { + refreshModel: true, + }, + last_used_at: { + refreshModel: true, + }, }; @action loading(transition) { - this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: 'Loading api keys...' }); + this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: this.intl.t('developers.api-keys.index.loading') }); } beforeModel() { @@ -50,7 +56,6 @@ export default class ApiKeysIndexRoute extends Route { setupController(controller) { super.setupController(...arguments); - controller.testMode = this.currentUser.getOption('sandbox', false); later(controller, controller.openDeepLinkedApiKey); } } diff --git a/addon/services/api-key-actions.js b/addon/services/api-key-actions.js index 90f7cb6..12e81ef 100644 --- a/addon/services/api-key-actions.js +++ b/addon/services/api-key-actions.js @@ -1,8 +1,20 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; import { action } from '@ember/object'; +import { later } from '@ember/runloop'; +import { format as formatDate } from 'date-fns'; +import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; +import { maskKey } from '../utils/format'; /** * API key actions shared by the Developers console and other engines (for example Fleetbase AI). + * + * Create and roll are two-step dialogs: the form, then a reveal step that shows + * the credential once with copy and an acknowledgement gate. The list never + * shows the secret again. + * + * TODO(v1.1): the backend returns `secret` as a hash of the key on every + * response today. Once it generates an independent one-time secret and hides + * it from list responses, the reveal step shows the real secret with no other change here. */ export default class ApiKeyActionsService extends ResourceActionService { expirationOptions = ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']; @@ -12,6 +24,10 @@ export default class ApiKeyActionsService extends ResourceActionService { this.initialize('api-credential', { permissionPrefix: 'developers', mountPrefix: 'console.developers', registryResource: 'api-key' }); } + get developerPanels() { + return this.universe.getService?.('developer-panels') ?? this.hostRouter?.owner?.lookup?.('service:developer-panels') ?? null; + } + transition = { list: () => this.transitionTo('api-keys.index'), }; @@ -19,77 +35,330 @@ export default class ApiKeyActionsService extends ResourceActionService { modal = { create: (...args) => this.createApiKey(...args), edit: (...args) => this.editApiKey(...args), + roll: (...args) => this.rollApiKey(...args), + rename: (...args) => this.renameApiKey(...args), + delete: (...args) => this.deleteApiKey(...args), }; + panel = { + view: (...args) => this.viewApiKey(...args), + }; + + /** + * The expiration options with translated labels for a Select. + */ + get expirationChoices() { + return this.expirationOptions.map((value) => ({ value, label: this.intl.t(`developers.api-keys.expiration.${value.replace(/\s+/g, '-')}`) })); + } + /** - * Toggles modal to create a new API key + * Opens the key detail drawer. + */ + @action viewApiKey(apiKey) { + const panels = this.hostRouter.owner?.lookup?.('service:developer-panels') ?? null; + + if (panels) { + return panels.openApiKey(apiKey, { actionButtons: this.panelActionButtons(apiKey) }); + } + + return this.editApiKey(apiKey); + } + + panelActionButtons(apiKey) { + return [ + { id: 'edit', icon: 'pencil', size: 'sm', text: this.intl.t('developers.common.edit'), onClick: () => this.editApiKey(apiKey), permission: 'developers update api-key' }, + { id: 'roll', icon: 'rotate', size: 'sm', text: this.intl.t('developers.api-keys.actions.roll'), onClick: () => this.rollApiKey(apiKey), permission: 'developers roll api-key' }, + { + id: 'more', + icon: 'ellipsis', + size: 'sm', + items: [ + { id: 'requests', icon: 'terminal', label: this.intl.t('developers.api-keys.actions.view-requests'), fn: () => this.viewRequestLogs(apiKey) }, + { id: 'channel', icon: 'tower-broadcast', label: this.intl.t('developers.api-keys.actions.watch-channel'), fn: () => this.watchChannel(apiKey) }, + { id: 'rename', icon: 'i-cursor', label: this.intl.t('developers.api-keys.actions.rename'), fn: () => this.renameApiKey(apiKey) }, + { separator: true }, + { id: 'delete', icon: 'trash', label: this.intl.t('developers.api-keys.actions.delete'), class: 'text-red-500', fn: () => this.deleteApiKey(apiKey) }, + ], + }, + ]; + } + + /** + * Step 1: the form. Step 2: the credential shown once. * * @void */ @action createApiKey() { const formPermission = 'developers create api-key'; + const isSandbox = this.currentUser.getOption('sandbox') || false; const apiKey = this.store.createRecord('api-credential', { - test_mode: this.currentUser.getOption('sandbox') || false, + test_mode: isSandbox, + browser_origins: [], }); - this.editApiKey(apiKey, { - title: this.intl.t('developers.api-keys.index.new-api-key-title'), + return this.modalsManager.show('modals/api-key-form', { + title: this.intl.t('developers.api-keys.create.title'), + modalClass: 'dev-modal modal-lg', + acceptButtonText: this.intl.t('developers.api-keys.create.accept'), acceptButtonIcon: 'check', acceptButtonIconPrefix: 'fas', acceptButtonDisabled: this.abilities.cannot(formPermission), - acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null, - successMessage: this.intl.t('developers.api-keys.index.new-api-key-message'), + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + keepOpen: true, + step: 'form', + isNew: true, + isSandbox, + acknowledged: false, + revealed: null, + expirationChoices: this.expirationChoices, formPermission, apiKey, + setExpiration: (value) => { + apiKey.expires_at = value || null; + }, + setOrigins: (origins) => { + apiKey.browser_origins = origins; + }, + toggleAck: (checked) => { + this.modalsManager.setOption('acknowledged', checked); + this.modalsManager.setOption('acceptButtonDisabled', !checked); + }, confirm: async (modal) => { - modal.startLoading(); + if (modal.getOption('step') === 'reveal') { + modal.done(); + return this.hostRouter.refresh(); + } if (this.abilities.cannot(formPermission)) { return this.notifications.warning(this.intl.t('common.permissions-required-for-changes')); } + modal.startLoading(); + try { await apiKey.save(); - this.notifications.success(modal.getOption('successMessage')); - return this.hostRouter.refresh(); + modal.setOption('revealed', { key: apiKey.key, secret: apiKey.secret, name: apiKey.name }); + modal.setOption('step', 'reveal'); + modal.setOption('title', this.intl.t('developers.api-keys.create.reveal-title')); + modal.setOption('acceptButtonText', this.intl.t('developers.api-keys.create.done')); + modal.setOption('acceptButtonIcon', 'check'); + modal.setOption('hideDeclineButton', true); + modal.setOption('closeButton', false); + modal.setOption('keyboard', false); + modal.setOption('acceptButtonDisabled', true); + modal.stopLoading(); + this.notifications.success(this.intl.t('developers.api-keys.index.new-api-key-message')); } catch (error) { this.notifications.serverError(error); modal.stopLoading(); } }, + decline: (modal) => { + if (apiKey.isNew) { + apiKey.unloadRecord(); + } + + modal.done(); + }, }); } /** - * Opens the dialog to edit an API key + * Opens the dialog to edit an API key: name, expiration and browser origins. * * @void */ @action editApiKey(apiKey, options = {}) { const formPermission = 'developers update api-key'; - this.modalsManager.show('modals/api-key-form', { - title: this.intl.t('developers.api-keys.index.edit-api-key-title'), + const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); + + return this.modalsManager.show('modals/api-key-form', { + title: this.intl.t('developers.api-keys.edit.title', { apiKeyName }), + modalClass: 'dev-modal modal-lg', + acceptButtonText: this.intl.t('developers.api-keys.edit.accept'), acceptButtonIcon: 'save', acceptButtonDisabled: this.abilities.cannot(formPermission), - acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null, - successMessage: this.intl.t('developers.api-keys.index.edit-api-key-message'), - expirationOptions: this.expirationOptions, - testMode: this.currentUser.getOption('sandbox') || false, - apiKey, + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + step: 'form', + isNew: false, + isSandbox: Boolean(apiKey.test_mode), + readOnly: this.abilities.cannot(formPermission), + expirationChoices: this.expirationChoices, formPermission, - setExpiration: ({ target }) => { - apiKey.expires_at = target.value || null; + apiKey, + setExpiration: (value) => { + apiKey.expires_at = value || null; + }, + setOrigins: (origins) => { + apiKey.browser_origins = origins; + }, + confirm: async (modal) => { + if (this.abilities.cannot(formPermission)) { + return this.notifications.warning(this.intl.t('common.permissions-required-for-changes')); + } + + modal.startLoading(); + + try { + await apiKey.save(); + this.notifications.success(this.intl.t('developers.api-keys.index.edit-api-key-message')); + return this.hostRouter.refresh(); + } catch (error) { + this.notifications.serverError(error); + modal.stopLoading(); + } + }, + decline: (modal) => { + if (apiKey.hasDirtyAttributes) { + apiKey.rollbackAttributes(); + } + + modal.done(); }, + ...options, + }); + } + + /** + * Rename only. + * + * @void + */ + @action renameApiKey(apiKey, options = {}) { + const formPermission = 'developers update api-key'; + const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); + + return this.modalsManager.show('modals/rename-api-key-form', { + title: this.intl.t('developers.api-keys.rename.title', { apiKeyName }), + modalClass: 'dev-modal', + acceptButtonText: this.intl.t('developers.api-keys.rename.accept'), + acceptButtonIcon: 'check', + acceptButtonDisabled: this.abilities.cannot(formPermission), + apiKey, + formPermission, confirm: async (modal) => { modal.startLoading(); + try { + await apiKey.save(); + this.notifications.success(this.intl.t('developers.api-keys.index.rename-api-key-success-message', { apiKeyName: apiKey.name })); + modal.done(); + } catch (error) { + this.notifications.serverError(error); + modal.stopLoading(); + } + }, + decline: (modal) => { + if (apiKey.hasDirtyAttributes) { + apiKey.rollbackAttributes(); + } + + modal.done(); + }, + ...options, + }); + } + + /** + * Roll a key in two steps: choose when the old key expires and confirm the + * password, then save the new credential shown once. + * + * @void + */ + @action async rollApiKey(apiKey, options = {}) { + const formPermission = 'developers roll api-key'; + const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); + const dependents = await this.loadDependents(apiKey); + + return this.modalsManager.show('modals/roll-api-key-form', { + title: this.intl.t('developers.api-keys.roll.title', { apiKeyName }), + modalClass: 'dev-modal modal-lg', + acceptButtonText: this.intl.t('developers.api-keys.roll.accept'), + acceptButtonIcon: 'rotate', + acceptButtonDisabled: this.abilities.cannot(formPermission), + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + keepOpen: true, + step: 'form', + expiration: 'in 24 hours', + expirationChoices: this.expirationOptions.map((value) => ({ value, label: this.intl.t(`developers.api-keys.roll.expire.${value.replace(/\s+/g, '-')}`) })), + password: null, + acknowledged: false, + revealed: null, + dependents, + user: this.currentUser.user, + formPermission, + apiKey, + setExpiration: (value) => { + this.modalsManager.setOption('expiration', value); + }, + toggleAck: (checked) => { + this.modalsManager.setOption('acknowledged', checked); + this.modalsManager.setOption('acceptButtonDisabled', !checked); + }, + viewRequestLogs: () => this.viewRequestLogs(apiKey), + confirm: async (modal) => { + if (modal.getOption('step') === 'reveal') { + modal.done(); + return this.hostRouter.refresh(); + } + if (this.abilities.cannot(formPermission)) { return this.notifications.warning(this.intl.t('common.permissions-required-for-changes')); } + modal.startLoading(); + try { - await apiKey.save(); - this.notifications.success(modal.getOption('successMessage')); + const response = await this.fetch.patch( + `api-credentials/roll/${apiKey.id}`, + { password: modal.getOption('password'), expiration: modal.getOption('expiration') }, + { normalizeToEmberData: true } + ); + const rolled = response?.apiCredential ?? response ?? apiKey; + modal.setOption('revealed', { key: rolled.key ?? apiKey.key, secret: rolled.secret ?? apiKey.secret, name: apiKeyName }); + modal.setOption('step', 'reveal'); + modal.setOption('title', this.intl.t('developers.api-keys.roll.reveal-title')); + modal.setOption('acceptButtonText', this.intl.t('developers.api-keys.create.done')); + modal.setOption('acceptButtonIcon', 'check'); + modal.setOption('hideDeclineButton', true); + modal.setOption('closeButton', false); + modal.setOption('keyboard', false); + modal.setOption('acceptButtonDisabled', true); + modal.stopLoading(); + this.notifications.success(this.intl.t('developers.api-keys.index.roll-api-key-success-message', { apiKeyName })); + } catch (error) { + modal.setOption('error', error?.message ?? this.intl.t('developers.api-keys.index.roll-api-key-error-message')); + modal.stopLoading(); + } + }, + ...options, + }); + } + + /** + * Delete with the dependents named. + * + * @void + */ + @action async deleteApiKey(apiKey, options = {}) { + const apiKeyName = getWithDefault(apiKey, 'name', this.intl.t('developers.api-keys.index.untitled')); + const dependents = await this.loadDependents(apiKey); + + return this.modalsManager.confirm({ + title: this.intl.t('developers.api-keys.delete.title', { apiKeyName }), + body: this.intl.t('developers.api-keys.delete.body', { count: dependents.webhooks.length }), + acceptButtonText: this.intl.t('developers.api-keys.delete.accept'), + acceptButtonScheme: 'danger', + acceptButtonIcon: 'trash', + confirm: async (modal) => { + modal.startLoading(); + + try { + await apiKey.destroyRecord(); + this.notifications.success(this.intl.t('developers.api-keys.index.delete-api-key-success-message', { apiKeyName })); return this.hostRouter.refresh(); } catch (error) { this.notifications.serverError(error); @@ -99,4 +368,66 @@ export default class ApiKeyActionsService extends ResourceActionService { ...options, }); } + + /** + * Requests pre-filtered by this key. + */ + @action viewRequestLogs(apiKey) { + return this.hostRouter.transitionTo('console.developers.logs.index', { queryParams: { key: apiKey.id } }); + } + + /** + * The key's own socket channel. + */ + @action watchChannel(apiKey) { + return this.hostRouter.transitionTo('console.developers.sockets.view', `api.${apiKey.id}`); + } + + /** + * Export the list. + */ + @action exportApiKeys() { + return this.modalsManager.show('modals/export-form', { + title: this.intl.t('developers.api-keys.index.export-api'), + acceptButtonText: this.intl.t('developers.api-keys.index.export-api-accept-button-text'), + formatOptions: ['csv', 'xlsx', 'xls', 'html', 'pdf'], + format: 'xlsx', + setFormat: ({ target }) => { + this.modalsManager.setOption('format', target.value || null); + }, + confirm: async (modal) => { + modal.startLoading(); + + const format = modal.getOption('format', 'xlsx'); + + try { + await this.fetch.download('api-credentials/export', { format }, { fileName: `api-credentials-${formatDate(new Date(), 'yyyy-MM-dd-HH:mm')}.${format}` }); + later(this, () => modal.done(), 600); + } catch (error) { + this.notifications.serverError(error, this.intl.t('developers.api-keys.index.export-api-error-message')); + modal.stopLoading(); + } + }, + }); + } + + /** + * Webhook endpoints that receive from this key. + */ + async loadDependents(apiKey) { + try { + const webhooks = await this.store.query('webhook-endpoint', { api_credential_uuid: apiKey.id, limit: 25 }); + + return { webhooks: webhooks.toArray ? webhooks.toArray() : Array.from(webhooks), channel: `api.${apiKey.id}` }; + } catch { + return { webhooks: [], channel: `api.${apiKey.id}` }; + } + } + + /** + * A display label for a key. + */ + labelFor(apiKey) { + return apiKey?.name ?? maskKey(apiKey?.key); + } } diff --git a/addon/services/developer-metrics.js b/addon/services/developer-metrics.js new file mode 100644 index 0000000..fca72e9 --- /dev/null +++ b/addon/services/developer-metrics.js @@ -0,0 +1,93 @@ +import Service, { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +export const PERIODS = ['7d', '30d', '90d', '180d', '365d']; + +/** + * The dashboard's period and the KPI payload for it. Widgets only receive + * `@widget` and `@options` from the dashboard grid, so the period lives here + * and every widget reads it. KPIs are fetched once per period and shared by + * the eight tiles instead of eight identical requests. + */ +export default class DeveloperMetricsService extends Service { + @service fetch; + @tracked period = '30d'; + @tracked kpis = null; + @tracked kpisError = null; + #kpisByPeriod = new Map(); + + get periods() { + return PERIODS; + } + + get periodDays() { + return parseInt(this.period, 10) || 30; + } + + /** + * The metric objects keyed by name: `{label, value, format, inverse, delta_percent}`. + */ + get metrics() { + return this.kpis?.metrics ?? {}; + } + + /** + * True when the organization has no API traffic and no keys yet. + */ + get isFirstIntegration() { + const { metrics } = this; + + if (!this.kpis) { + return false; + } + + return Number(metrics.api_requests?.value ?? 0) === 0 && Number(metrics.active_api_keys?.value ?? 0) === 0; + } + + @action setPeriod(period) { + if (!PERIODS.includes(period) || period === this.period) { + return; + } + + this.period = period; + this.loadKpis.perform(); + } + + /** + * Loads the KPIs for the current period, reusing a response already seen + * unless `force` is set. + */ + @task({ restartable: true }) *loadKpis(force = false) { + const { period } = this; + + if (!force && this.#kpisByPeriod.has(period)) { + this.kpis = this.#kpisByPeriod.get(period); + this.kpisError = null; + return this.kpis; + } + + try { + const kpis = yield this.fetch.get('metrics/dev/kpis', { period }); + this.#kpisByPeriod.set(period, kpis); + this.kpis = kpis; + this.kpisError = null; + return kpis; + } catch (error) { + this.kpisError = error?.message ?? 'Unable to load metrics'; + return null; + } + } + + @action refreshKpis() { + return this.loadKpis.perform(true); + } + + /** + * Fetches one of the `metrics/dev/*` endpoints for the current period. + */ + fetchForPeriod(endpoint, params = {}) { + return this.fetch.get(`metrics/dev/${endpoint}`, { period: this.period, ...params }); + } +} diff --git a/addon/services/developer-panels.js b/addon/services/developer-panels.js new file mode 100644 index 0000000..f9138ee --- /dev/null +++ b/addon/services/developer-panels.js @@ -0,0 +1,101 @@ +import Service, { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { maskKey } from '../utils/format'; + +/** + * Opens Developers records in the shared right-hand context panel (the + * drawer) so a list keeps its filters and scroll position. Every panel offers + * "Open full page", which transitions to the existing view route for deep links. + */ +export default class DeveloperPanelsService extends Service { + @service resourceContextPanel; + @service hostRouter; + @service intl; + + /** + * Opens a panel with the engine's defaults. + * + * @param {Object} definition a resource-context-panel definition + * @returns {String} the overlay id + */ + @action open(definition) { + return this.resourceContextPanel.open({ + size: 'md', + closeOnTransition: true, + dismissible: true, + panelContentClass: 'dev-panel-content', + headerClass: 'dev-panel-header', + bodyClass: 'dev-panel-body', + ...definition, + }); + } + + /** + * Closes the active panel. + */ + @action close(id) { + return this.resourceContextPanel.close(id); + } + + /** + * API key detail drawer. + */ + @action openApiKey(apiKey, options = {}) { + return this.open({ + resource: apiKey, + title: apiKey.name ?? maskKey(apiKey.key), + content: 'api-key/panel', + registry: 'developers:api-key:details', + ...options, + }); + } + + /** + * Event detail drawer. + */ + @action openEvent(event, options = {}) { + return this.open({ + resource: event, + title: event.event ?? event.public_id, + content: 'api-event/panel', + registry: 'developers:event:details', + ...options, + }); + } + + /** + * Request log detail drawer. + */ + @action openRequest(log, options = {}) { + return this.open({ + resource: log, + title: `${log.method ?? ''} ${log.path ?? log.public_id ?? ''}`.trim(), + content: 'api-request-log/panel', + registry: 'developers:log:details', + size: 'lg', + ...options, + }); + } + + /** + * Webhook delivery attempt inspector drawer. + */ + @action openAttempt(attempt, options = {}) { + return this.open({ + resource: attempt, + title: this.intl.t('developers.component.webhook.attempt-panel.title', { attempt: attempt.attempt ?? 1 }), + content: 'webhook/attempt-panel', + size: 'lg', + ...options, + }); + } + + /** + * Transitions to the full page for a record and closes the panels. + */ + @action openFullPage(routeName, record) { + this.resourceContextPanel.closeAll(); + + return this.hostRouter.transitionTo(`console.developers.${routeName}`, record); + } +} diff --git a/addon/styles/dev-engine.css b/addon/styles/dev-engine.css index 4a66599..0cb5c50 100644 --- a/addon/styles/dev-engine.css +++ b/addon/styles/dev-engine.css @@ -370,3 +370,1593 @@ body[data-theme='dark'] .developers-activity-icon { font-size: 0.75rem; font-weight: 600; } + +/* ------------------------------------------------------------------ */ +/* Developers redesign: shared primitives (dev-* namespace) */ +/* Tokens follow ember-ui: sky-500 primary, gray/night palettes, */ +/* 4/8/12/16 spacing, 28px controls, radius 6 controls / 8 panels. */ +/* ------------------------------------------------------------------ */ + +.dev-nowrap, +.dev-segment-label, +.dev-card-title, +.dev-card-subtitle, +.dev-resource-header-title, +.dev-kv dt, +.dev-stat-label, +.dev-state-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Segmented control */ +.dev-segmented { + display: inline-flex; + height: 28px; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 6px; + background-color: #fff; +} + +body[data-theme='dark'] .dev-segmented { + border-color: #4b5563; + background-color: #1f2937; +} + +.dev-segment { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; + border-right: 1px solid #e5e7eb; + background: transparent; + color: #4b5563; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; +} + +.dev-segment:last-child { + border-right: 0; +} + +.dev-segment:hover { + color: #111827; + background-color: #f3f4f6; +} + +.dev-segment.is-active { + background-color: #e5e7eb; + color: #111827; + font-weight: 600; +} + +body[data-theme='dark'] .dev-segment { + border-right-color: #374151; + color: #d1d5db; +} + +body[data-theme='dark'] .dev-segment:hover { + color: #f9fafb; + background-color: #283345; +} + +body[data-theme='dark'] .dev-segment.is-active { + background-color: #374151; + color: #f9fafb; +} + +.dev-segmented-xs { + height: 22px; +} + +.dev-segmented-xs .dev-segment { + padding: 0 8px; + font-size: 11px; +} + +.dev-segment-count { + display: inline-flex; + align-items: center; + min-width: 18px; + height: 16px; + justify-content: center; + padding: 0 5px; + border-radius: 999px; + background-color: #e5e7eb; + color: #374151; + font-size: 10px; + font-weight: 700; +} + +.dev-segment.is-active .dev-segment-count { + background-color: #fff; +} + +body[data-theme='dark'] .dev-segment-count { + background-color: #4b5563; + color: #e5e7eb; +} + +body[data-theme='dark'] .dev-segment.is-active .dev-segment-count { + background-color: #1f2937; +} + +/* Status dots */ +.dev-dot { + display: inline-block; + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 999px; + background-color: #9ca3af; +} + +.dev-dot-green { + background-color: #10b981; +} + +.dev-dot-amber { + background-color: #f59e0b; +} + +.dev-dot-red { + background-color: #ef4444; +} + +.dev-dot-blue { + background-color: #3485e2; +} + +/* Environment switch */ +.dev-env-switch-wrapper { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.dev-env-hint { + overflow: hidden; + color: #6b7280; + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-env-hint { + color: #9ca3af; +} + +/* Filter bar */ +.dev-filter-bar { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid #e5e7eb; + background-color: #fff; +} + +body[data-theme='dark'] .dev-filter-bar { + border-bottom-color: #374151; + background-color: #1f2937; +} + +.dev-filter-bar-end { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.btn.dev-filter-chip { + height: 28px; + border-radius: 999px; + white-space: nowrap; +} + +.btn.dev-filter-chip.is-active { + border-color: #3485e2; + background-color: rgb(52 133 226 / 10%); + color: #1c6cc7; +} + +body[data-theme='dark'] .btn.dev-filter-chip.is-active { + color: #8dbbef; +} + +.dev-filter-popover-content { + z-index: 60; +} + +.dev-filter-popover { + width: 300px; + padding: 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background-color: #fff; + box-shadow: 0 12px 30px rgb(0 0 0 / 15%); +} + +body[data-theme='dark'] .dev-filter-popover { + border-color: #374151; + background-color: #1f2937; + box-shadow: 0 12px 30px rgb(0 0 0 / 45%); +} + +.dev-filter-popover-label { + margin-bottom: 6px; + color: #6b7280; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.dev-filter-popover-footer { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 10px; +} + +/* Steps strip (two-step dialogs) */ +.dev-steps { + display: flex; + align-items: center; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.dev-step { + display: flex; + align-items: center; + gap: 6px; + color: #9ca3af; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + +.dev-step.is-active { + color: #111827; + font-weight: 600; +} + +body[data-theme='dark'] .dev-step.is-active { + color: #f9fafb; +} + +.dev-step-num { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border: 1px solid #d1d5db; + border-radius: 999px; + font-size: 10px; + line-height: 1; +} + +.dev-step.is-active .dev-step-num { + border-color: #3485e2; + background-color: #3485e2; + color: #fff; +} + +.dev-step.is-done .dev-step-num { + border-color: transparent; + background-color: #d1fae5; + color: #047857; +} + +body[data-theme='dark'] .dev-step.is-done .dev-step-num { + background-color: #064e3b; + color: #6ee7b7; +} + +/* Code viewer */ +.dev-code-viewer { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 6px; +} + +body[data-theme='dark'] .dev-code-viewer { + border-color: #374151; +} + +.dev-code-toolbar { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + color: #6b7280; + font-size: 11px; +} + +body[data-theme='dark'] .dev-code-toolbar { + border-bottom-color: #374151; + background-color: #111827; + color: #9ca3af; +} + +.dev-code-title { + overflow: hidden; + color: #111827; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-code-title { + color: #f3f4f6; +} + +.dev-code-meta { + white-space: nowrap; +} + +.dev-code-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.dev-code-body pre[class*='language-'] { + margin: 0; + border-radius: 0; + font-size: 12px; + line-height: 18px; +} + +.dev-code-viewer.is-wrapped pre[class*='language-'], +.dev-code-viewer.is-wrapped pre[class*='language-'] code { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.dev-code-empty { + padding: 20px; + color: #6b7280; + font-size: 12px; + text-align: center; +} + +/* Resource header (detail pages) */ +.dev-resource-header { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: flex-start; + gap: 12px; + padding: 16px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background-color: #fff; +} + +body[data-theme='dark'] .dev-resource-header { + border-color: #374151; + background-color: #1f2937; +} + +.dev-resource-header-icon { + display: flex; + width: 40px; + height: 40px; + flex: 0 0 40px; + align-items: center; + justify-content: center; + border-radius: 10px; + font-size: 16px; +} + +.dev-resource-header-icon.is-blue, +.dev-tone-blue { + background-color: #dbeafe; + color: #1d4ed8; +} + +.dev-resource-header-icon.is-green, +.dev-tone-green { + background-color: #d1fae5; + color: #047857; +} + +.dev-resource-header-icon.is-amber, +.dev-tone-amber { + background-color: #fef3c7; + color: #b45309; +} + +.dev-resource-header-icon.is-red, +.dev-tone-red { + background-color: #fee2e2; + color: #b91c1c; +} + +.dev-resource-header-icon.is-purple, +.dev-tone-purple { + background-color: #ede9fe; + color: #6d28d9; +} + +.dev-resource-header-icon.is-gray, +.dev-tone-gray { + background-color: #e5e7eb; + color: #374151; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-blue, +body[data-theme='dark'] .dev-tone-blue { + background-color: #1e3a8a; + color: #93c5fd; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-green, +body[data-theme='dark'] .dev-tone-green { + background-color: #064e3b; + color: #6ee7b7; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-amber, +body[data-theme='dark'] .dev-tone-amber { + background-color: #78350f; + color: #fcd34d; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-red, +body[data-theme='dark'] .dev-tone-red { + background-color: #7f1d1d; + color: #fca5a5; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-purple, +body[data-theme='dark'] .dev-tone-purple { + background-color: #4c1d95; + color: #c4b5fd; +} + +body[data-theme='dark'] .dev-resource-header-icon.is-gray, +body[data-theme='dark'] .dev-tone-gray { + background-color: #374151; + color: #d1d5db; +} + +.dev-resource-header-main { + display: flex; + min-width: 0; + flex: 1 1 320px; + flex-direction: column; + gap: 4px; +} + +.dev-resource-header-title-row { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.dev-resource-header-title { + min-width: 0; + margin: 0; + color: #111827; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +body[data-theme='dark'] .dev-resource-header-title { + color: #f9fafb; +} + +.dev-resource-header-sub { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + color: #6b7280; + font-size: 12px; + line-height: 18px; +} + +.dev-resource-header-sub > * { + white-space: nowrap; +} + +body[data-theme='dark'] .dev-resource-header-sub { + color: #9ca3af; +} + +.dev-resource-header-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + margin-left: auto; +} + +/* Section card (replaces stacked ContentPanels) */ +.dev-card { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background-color: #fff; +} + +body[data-theme='dark'] .dev-card { + border-color: #374151; + background-color: #1f2937; +} + +.dev-card-header { + display: flex; + min-height: 44px; + align-items: center; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid #e5e7eb; +} + +body[data-theme='dark'] .dev-card-header { + border-bottom-color: #374151; +} + +.dev-card-heading { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.dev-card-icon { + color: #6b7280; + font-size: 12px; +} + +.dev-card-title { + margin: 0; + color: #111827; + font-size: 13px; + font-weight: 600; +} + +body[data-theme='dark'] .dev-card-title { + color: #f3f4f6; +} + +.dev-card-subtitle { + color: #6b7280; + font-size: 12px; +} + +body[data-theme='dark'] .dev-card-subtitle { + color: #9ca3af; +} + +.dev-card-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.dev-card-body { + padding: 16px; +} + +.dev-card-body.is-flush { + padding: 0; +} + +.dev-count { + display: inline-flex; + height: 18px; + align-items: center; + padding: 0 6px; + border-radius: 999px; + background-color: #e5e7eb; + color: #374151; + font-size: 10px; + font-weight: 700; +} + +body[data-theme='dark'] .dev-count { + background-color: #374151; + color: #e5e7eb; +} + +/* Key-value grid */ +.dev-kv { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + font-size: 13px; +} + +.dev-kv.is-compact { + grid-template-columns: 120px minmax(0, 1fr); + gap: 6px 12px; + font-size: 12px; +} + +.dev-kv dt { + margin: 0; + color: #6b7280; +} + +body[data-theme='dark'] .dev-kv dt { + color: #9ca3af; +} + +.dev-kv dd { + min-width: 0; + margin: 0; + color: #111827; + overflow-wrap: anywhere; +} + +body[data-theme='dark'] .dev-kv dd { + color: #f3f4f6; +} + +/* Stat tiles */ +.dev-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr)); + gap: 12px; +} + +.dev-stat { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background-color: #fff; +} + +body[data-theme='dark'] .dev-stat { + border-color: #374151; + background-color: #1f2937; +} + +.dev-stat-label { + color: #6b7280; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +body[data-theme='dark'] .dev-stat-label { + color: #9ca3af; +} + +.dev-stat-value { + overflow: hidden; + color: #111827; + font-size: 20px; + font-weight: 700; + line-height: 1.15; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-stat-value { + color: #f9fafb; +} + +.dev-stat-value.is-down { + color: #dc2626; +} + +.dev-stat-value.is-up { + color: #059669; +} + +.dev-stat-hint { + overflow: hidden; + color: #6b7280; + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-stat-hint { + color: #9ca3af; +} + +/* States: empty, error, denied */ +.dev-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 48px 24px; + color: #6b7280; + text-align: center; +} + +body[data-theme='dark'] .dev-state { + color: #9ca3af; +} + +.dev-state-icon { + display: flex; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + border-radius: 10px; + background-color: #e5e7eb; + color: #374151; + font-size: 18px; +} + +body[data-theme='dark'] .dev-state-icon { + background-color: #374151; + color: #d1d5db; +} + +.dev-state-icon-danger { + background-color: #fee2e2; + color: #b91c1c; +} + +body[data-theme='dark'] .dev-state-icon-danger { + background-color: #7f1d1d; + color: #fca5a5; +} + +.dev-state-icon-warning { + background-color: #fef3c7; + color: #b45309; +} + +body[data-theme='dark'] .dev-state-icon-warning { + background-color: #78350f; + color: #fcd34d; +} + +.dev-state-title { + color: #111827; + font-size: 14px; + font-weight: 600; +} + +body[data-theme='dark'] .dev-state-title { + color: #f3f4f6; +} + +.dev-state-message { + max-width: 420px; + margin: 0; + font-size: 12px; + line-height: 18px; +} + +.dev-state-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 6px; +} + +/* Info / warning / danger blocks */ +.dev-note { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; + line-height: 18px; +} + +.dev-note > svg { + flex: 0 0 auto; + margin-top: 2px; +} + +.dev-note-info { + background-color: #dbeafe; + color: #1e40af; +} + +.dev-note-warning { + background-color: #fef3c7; + color: #92400e; +} + +.dev-note-danger { + background-color: #fee2e2; + color: #991b1b; +} + +body[data-theme='dark'] .dev-note-info { + background-color: #1e3a8a; + color: #bfdbfe; +} + +body[data-theme='dark'] .dev-note-warning { + background-color: #78350f; + color: #fde68a; +} + +body[data-theme='dark'] .dev-note-danger { + background-color: #7f1d1d; + color: #fecaca; +} + +/* Monospace tag (keys, ids, channel names) */ +.dev-tag { + display: inline-flex; + max-width: 100%; + height: 22px; + align-items: center; + gap: 6px; + padding: 0 7px; + border-radius: 5px; + background-color: #e5e7eb; + color: #374151; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + white-space: nowrap; +} + +.dev-tag > span { + overflow: hidden; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-tag { + background-color: #374151; + color: #e5e7eb; +} + +.dev-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +/* HTTP method pill */ +.dev-method { + display: inline-block; + min-width: 44px; + padding: 1px 6px; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + font-weight: 700; + text-align: center; + white-space: nowrap; +} + +.dev-method-get { + background-color: #dbeafe; + color: #1d4ed8; +} + +.dev-method-post { + background-color: #d1fae5; + color: #047857; +} + +.dev-method-put, +.dev-method-patch { + background-color: #fef3c7; + color: #b45309; +} + +.dev-method-delete { + background-color: #fee2e2; + color: #b91c1c; +} + +body[data-theme='dark'] .dev-method-get { + background-color: #1e3a8a; + color: #93c5fd; +} + +body[data-theme='dark'] .dev-method-post { + background-color: #064e3b; + color: #6ee7b7; +} + +body[data-theme='dark'] .dev-method-put, +body[data-theme='dark'] .dev-method-patch { + background-color: #78350f; + color: #fcd34d; +} + +body[data-theme='dark'] .dev-method-delete { + background-color: #7f1d1d; + color: #fca5a5; +} + +/* Page body layout */ +.dev-page { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; +} + +.dev-page-scroll { + height: 100%; + overflow-y: auto; +} + +/* Compact tables: the identity column is the only flexible one */ +.dev-table-compact .next-table td, +.dev-table-compact .next-table th { + white-space: nowrap; +} + +.dev-table-compact .table-cell-identity { + max-width: none; +} + +/* Section header: keep the title, controls and actions on one baseline */ +.next-view-section-subheader .dev-subheader-controls { + display: flex; + min-width: 0; + align-items: center; + gap: 12px; + margin-left: 12px; +} + +/* Drawer (resource context panel) body */ +.dev-panel-content { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; +} + +/* Dashboard additions */ +.dev-period-selector { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.dev-period-range { + overflow: hidden; + color: #6b7280; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-period-range { + color: #9ca3af; +} + +.developers-widget-footer { + overflow: hidden; + padding: 6px 12px; + border-top: 1px solid #e5e7eb; + color: #6b7280; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .developers-widget-footer { + border-top-color: #374151; + color: #9ca3af; +} + +.dev-widget-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.dev-widget-table th { + padding: 4px 8px; + color: #6b7280; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + white-space: nowrap; +} + +.dev-widget-table td { + padding: 6px 8px; + border-top: 1px solid #f3f4f6; + white-space: nowrap; +} + +body[data-theme='dark'] .dev-widget-table td { + border-top-color: #374151; +} + +.dev-widget-row { + cursor: pointer; +} + +.dev-widget-row:hover td { + background-color: #f8fafc; +} + +body[data-theme='dark'] .dev-widget-row:hover td { + background-color: #111827; +} + +.dev-identity { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.dev-identity-icon { + display: flex; + width: 26px; + height: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + border-radius: 6px; + font-size: 11px; +} + +.dev-identity-text { + display: flex; + min-width: 0; + flex-direction: column; +} + +.dev-identity-title { + overflow: hidden; + color: #111827; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-identity-title { + color: #f3f4f6; +} + +.dev-identity-sub { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; + overflow: hidden; + color: #6b7280; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-identity-sub { + color: #9ca3af; +} + +.dev-bars-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + gap: 8px 16px; + padding: 4px; +} + +.dev-bar-row { + display: flex; + flex-direction: column; + gap: 3px; + padding: 3px 8px; +} + +.dev-bar-meta { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} + +.dev-bar-label { + overflow: hidden; + color: #111827; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-bar-label { + color: #f3f4f6; +} + +.dev-bar-value { + flex: 0 0 auto; + font-weight: 700; +} + +.dev-bar-track { + height: 5px; + overflow: hidden; + border-radius: 3px; + background-color: #e5e7eb; +} + +body[data-theme='dark'] .dev-bar-track { + background-color: #374151; +} + +.dev-bar-fill { + height: 100%; + border-radius: 3px; + background-color: #3485e2; +} + +.dev-bar-fill.is-purple { + background-color: #7c3aed; +} + +.dev-timeline { + margin: 0; + padding: 4px 8px; + list-style: none; +} + +.dev-timeline-item { + position: relative; + display: flex; + gap: 10px; + padding: 6px 0 10px 2px; +} + +.dev-timeline-item::before { + position: absolute; + top: 18px; + bottom: -2px; + left: 7px; + width: 2px; + background-color: #e5e7eb; + content: ''; +} + +body[data-theme='dark'] .dev-timeline-item::before { + background-color: #374151; +} + +.dev-timeline-item:last-child::before { + display: none; +} + +.dev-timeline-dot { + z-index: 1; + width: 12px; + height: 12px; + flex: 0 0 12px; + margin-top: 3px; + border: 2px solid #d1d5db; + border-radius: 999px; + background-color: #fff; +} + +body[data-theme='dark'] .dev-timeline-dot { + border-color: #4b5563; + background-color: #1f2937; +} + +.dev-timeline-dot.is-ok { + border-color: #10b981; + background-color: #10b981; +} + +.dev-timeline-dot.is-bad { + border-color: #ef4444; + background-color: #ef4444; +} + +.dev-timeline-dot.is-wait { + border-color: #f59e0b; +} + +.dev-timeline-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.dev-timeline-title { + overflow: hidden; + color: #111827; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-timeline-title { + color: #f3f4f6; +} + +.dev-timeline-meta { + overflow: hidden; + color: #6b7280; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +body[data-theme='dark'] .dev-timeline-meta { + color: #9ca3af; +} + +.dev-quick-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); + gap: 8px; + align-content: start; +} + +.dev-quick-link { + width: 100%; +} + +.dev-quick-link .btn { + width: 100%; + justify-content: flex-start; +} + +/* Onboarding */ +.dev-onboarding .dev-card-body { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; +} + +.dev-onboarding-title { + margin: 0; + color: #111827; + font-size: 18px; + font-weight: 700; +} + +body[data-theme='dark'] .dev-onboarding-title { + color: #f9fafb; +} + +.dev-onboarding-text { + margin: 4px 0 0; + color: #4b5563; + font-size: 13px; +} + +body[data-theme='dark'] .dev-onboarding-text { + color: #d1d5db; +} + +.dev-onboarding-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); + gap: 12px; +} + +.dev-onboarding-step { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + opacity: 0.85; +} + +.dev-onboarding-step.is-next { + border-color: #3485e2; + opacity: 1; +} + +body[data-theme='dark'] .dev-onboarding-step { + border-color: #374151; +} + +.dev-onboarding-step p { + margin: 0; + color: #4b5563; + font-size: 12px; + line-height: 18px; +} + +body[data-theme='dark'] .dev-onboarding-step p { + color: #d1d5db; +} + +.dev-onboarding-step-head { + display: flex; + align-items: center; + gap: 8px; + color: #111827; + font-size: 13px; +} + +body[data-theme='dark'] .dev-onboarding-step-head { + color: #f3f4f6; +} + +.dev-onboarding-step.is-next .dev-step-num { + border-color: #3485e2; + background-color: #3485e2; + color: #fff; +} + +.dev-onboarding-step-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: auto; +} + +.dev-onboarding-code { + padding: 8px 10px; + border-radius: 6px; + background-color: #0f172a; + color: #e2e8f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + overflow-x: auto; +} + +/* API keys */ +.dev-key-cell .click-to-copy--value { + display: inline-flex; + max-width: 100%; +} + +.dev-tag-icon { + color: #9ca3af; +} + +.dev-cell-text { + display: inline-block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.dev-muted { + color: #6b7280; +} + +body[data-theme='dark'] .dev-muted { + color: #9ca3af; +} + +.dev-inline { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.dev-inline-wrap { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 6px; +} + +.dev-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + gap: 0 14px; +} + +.dev-modal-body .input-group { + margin-bottom: 14px; +} + +.dev-modal-body .input-group:last-child { + margin-bottom: 0; +} + +.dev-label { + color: #111827; + font-size: 13px; + font-weight: 600; +} + +body[data-theme='dark'] .dev-label { + color: #f3f4f6; +} + +.dev-error { + margin-top: 6px; + color: #dc2626; + font-size: 12px; +} + +.dev-divider { + height: 1px; + margin: 14px 0; + background-color: #e5e7eb; +} + +body[data-theme='dark'] .dev-divider { + background-color: #374151; +} + +.dev-chip-choices { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dev-chip { + display: inline-flex; + height: 28px; + align-items: center; + padding: 0 10px; + border: 1px solid #d1d5db; + border-radius: 999px; + background-color: #fff; + color: #374151; + font-size: 12px; + white-space: nowrap; + cursor: pointer; +} + +.dev-chip.is-active { + border-color: #3485e2; + background-color: rgb(52 133 226 / 10%); + color: #1c6cc7; + font-weight: 600; +} + +.dev-chip:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +body[data-theme='dark'] .dev-chip { + border-color: #4b5563; + background-color: #1f2937; + color: #e5e7eb; +} + +body[data-theme='dark'] .dev-chip.is-active { + color: #8dbbef; +} + +.dev-reveal { + display: flex; + flex-direction: column; + gap: 14px; +} + +.dev-reveal-row { + display: flex; + align-items: stretch; + gap: 8px; +} + +.dev-reveal-value { + display: block; + min-width: 0; + flex: 1 1 auto; + padding: 10px 12px; + border-radius: 6px; + background-color: #0f172a; + color: #e2e8f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.dev-reveal-value.is-muted { + background-color: #e5e7eb; + color: #111827; +} + +body[data-theme='dark'] .dev-reveal-value.is-muted { + background-color: #374151; + color: #f3f4f6; +} + +.dev-reveal-copy { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.dev-reveal-copy .click-to-copy--tooltip { + display: none; +} + +.dev-ack { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #111827; + font-size: 13px; + cursor: pointer; +} + +body[data-theme='dark'] .dev-ack { + border-color: #374151; + color: #f3f4f6; +} + +.dev-panel-identity { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.dev-panel-identity .dev-identity-icon { + width: 36px; + height: 36px; + flex-basis: 36px; + font-size: 14px; +} + +.dev-panel-identity .dev-identity-title { + font-size: 15px; +} + +.dev-list-header .next-view-section-subheader-actions { + gap: 4px; +} + +.dev-path-cell { + width: 100%; + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.dev-onboarding-code span { + display: block; +} + +.dev-onboarding-code-indent { + padding-left: 16px; +} diff --git a/addon/templates/api-keys/index.hbs b/addon/templates/api-keys/index.hbs index 0099311..a5d3a5c 100644 --- a/addon/templates/api-keys/index.hbs +++ b/addon/templates/api-keys/index.hbs @@ -3,7 +3,7 @@ @title={{t "developers.api-keys.index.title"}} @searchQuery={{this.query}} @onSearch={{perform this.search}} - @searchDisabled={{cannot "developers list api-keys"}} + @searchDisabled={{cannot "developers list api-key"}} @onReload={{this.reload}} @data={{@model}} @columns={{this.columns}} @@ -13,33 +13,29 @@ @actionButtons={{this.actionButtons}} @bulkActions={{this.bulkActions}} @controller={{this}} + @filterPickerButtonClass="hidden" + @headerClass="dev-list-header" + as |rows| > <:header> -
- - {{#if this.isTestMode}} - - - - {{/if}} -
+ + <:default> + + + diff --git a/addon/templates/home/index.hbs b/addon/templates/home/index.hbs index 1f2674f..3a3694f 100644 --- a/addon/templates/home/index.hbs +++ b/addon/templates/home/index.hbs @@ -8,7 +8,18 @@ @createWrapperClass="developers-dashboard-create-wrapper" @stickyHeader={{true}} class="developers-dashboard-page" - /> + > + <:actions> + + + <:body> + {{#if this.developerMetrics.isFirstIntegration}} +
+ +
+ {{/if}} + + {{outlet}} diff --git a/addon/utils/format.js b/addon/utils/format.js new file mode 100644 index 0000000..139ec01 --- /dev/null +++ b/addon/utils/format.js @@ -0,0 +1,167 @@ +import { isBlank } from '@ember/utils'; +import { formatDistanceToNowStrict, format as formatDate, isValid } from 'date-fns'; + +/** + * Masks the middle of an API key: `flb_live_z2c4…pzWfR`. + * + * @param {String} key + * @param {Number} head characters kept after the prefix + * @param {Number} tail characters kept at the end + * @returns {String|null} + */ +export function maskKey(key, head = 4, tail = 5) { + if (typeof key !== 'string' || key.length === 0) { + return null; + } + + const match = key.match(/^(flb_(?:live|test)_)(.+)$/); + const prefix = match ? match[1] : ''; + const body = match ? match[2] : key; + + if (body.length <= head + tail + 1) { + return key; + } + + return `${prefix}${body.slice(0, head)}…${body.slice(-tail)}`; +} + +/** + * Milliseconds as a short duration: `184 ms`, `2.4 s`. + */ +export function formatMs(value) { + const ms = Number(value); + + if (!Number.isFinite(ms)) { + return null; + } + + if (ms >= 1000) { + return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)} s`; + } + + return `${Math.round(ms)} ms`; +} + +/** + * Seconds (how core-api stores durations) as a short duration. + */ +export function formatSeconds(value) { + const seconds = Number(value); + + if (!Number.isFinite(seconds)) { + return null; + } + + return formatMs(seconds * 1000); +} + +/** + * `2 min ago`, or null when blank. + */ +export function timeAgo(value) { + if (isBlank(value)) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + + if (!isValid(date)) { + return null; + } + + return `${formatDistanceToNowStrict(date)} ago`; +} + +/** + * `Oct 14, 12:36:41`. + */ +export function timeStamp(value, pattern = 'MMM d, HH:mm:ss') { + if (isBlank(value)) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + + if (!isValid(date)) { + return null; + } + + return formatDate(date, pattern); +} + +/** + * The host and path of a URL without scheme or query: `api.acme.com/hooks/fleetbase`. + */ +export function shortUrl(value) { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + try { + const url = new URL(value); + + return `${url.host}${url.pathname === '/' ? '' : url.pathname}`; + } catch { + return value.replace(/^https?:\/\//, ''); + } +} + +/** + * Bytes of a string or object as `1.2 KB`. + */ +export function byteSize(value) { + if (isBlank(value)) { + return null; + } + + const text = typeof value === 'string' ? value : JSON.stringify(value); + const bytes = new Blob([text]).size; + + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * The status class of an HTTP status code: `2xx`, `4xx`, `5xx`. + */ +export function statusClass(code) { + const number = parseInt(code, 10); + + if (!Number.isFinite(number)) { + return null; + } + + return `${Math.floor(number / 100)}xx`; +} + +/** + * The badge status for an HTTP status code. + */ +export function statusTone(code) { + const number = parseInt(code, 10); + + if (!Number.isFinite(number)) { + return 'info'; + } + + if (number >= 500) { + return 'danger'; + } + + if (number >= 400) { + return 'warning'; + } + + if (number >= 200 && number < 300) { + return 'success'; + } + + return 'info'; +} diff --git a/addon/utils/resource-descriptors.js b/addon/utils/resource-descriptors.js new file mode 100644 index 0000000..5d857f1 --- /dev/null +++ b/addon/utils/resource-descriptors.js @@ -0,0 +1,229 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ +import { get } from '@ember/object'; +import { isBlank } from '@ember/utils'; +import { maskKey, shortUrl, timeAgo, statusTone } from './format'; + +function first(record, ...paths) { + for (const path of paths) { + const value = get(record ?? {}, path); + + if (!isBlank(value)) { + return value; + } + } + + return null; +} + +function isExpired(record) { + const expiresAt = get(record ?? {}, 'expires_at'); + + if (isBlank(expiresAt)) { + return false; + } + + const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); + + return !Number.isNaN(date.getTime()) && date.getTime() < Date.now(); +} + +/** An opener that hands the record to the engine's panel service. */ +function panelOpener(owner, method) { + return (record) => { + const panels = owner.lookup('service:developer-panels'); + + if (!panels || typeof panels[method] !== 'function') { + return false; + } + + panels[method](record); + + return true; + }; +} + +/** An opener that transitions to a route inside the engine. */ +function routeOpener(owner, routeName) { + return (record) => { + const router = owner.lookup('service:host-router') ?? owner.lookup('service:router'); + + if (!router || !record) { + return false; + } + + router.transitionTo(`console.developers.${routeName}`, record); + + return true; + }; +} + +/** + * Descriptors for the Developers resources: how an identity cell, a select + * option and a hover summary render an API key, a webhook endpoint, an event, + * a request log, a delivery attempt and a socket channel. + */ +export default function buildDevelopersResourceDescriptors(owner) { + return [ + { + key: 'api-credential', + labelKey: 'developers.resource.api-credential', + icon: 'key', + modelNames: ['api-credential'], + aliases: ['api-key'], + polymorphicTypes: ['Fleetbase\\Models\\ApiCredential', 'core:api-credential'], + permission: 'developers view api-key', + statusTones: { live: 'text-green-500', test: 'text-yellow-500', expired: 'text-red-500' }, + title: (key) => first(key, 'name') ?? maskKey(get(key, 'key')), + identifier: (key) => maskKey(get(key, 'key')), + image: () => ({ icon: 'key' }), + status: (key) => (isExpired(key) ? 'expired' : get(key, 'test_mode') ? 'test' : 'live'), + selectDetails: (key) => [maskKey(get(key, 'key')), get(key, 'test_mode') ? 'test' : 'live', timeAgo(get(key, 'last_used_at'))], + facts: (key) => [ + { label: 'Key', value: maskKey(get(key, 'key')) }, + { label: 'Environment', value: get(key, 'test_mode') ? 'test' : 'live', format: 'humanize' }, + { label: 'Last used', value: get(key, 'last_used_at'), format: 'date' }, + { label: 'Expires', value: get(key, 'expires_at'), format: 'date' }, + { label: 'Created', value: get(key, 'created_at'), format: 'date' }, + ], + open: panelOpener(owner, 'openApiKey'), + }, + { + key: 'webhook-endpoint', + labelKey: 'developers.resource.webhook-endpoint', + icon: 'globe', + modelNames: ['webhook-endpoint'], + aliases: ['webhook'], + polymorphicTypes: ['Fleetbase\\Models\\WebhookEndpoint', 'core:webhook-endpoint'], + permission: 'developers view webhook', + statusTones: { enabled: 'text-green-500', disabled: 'text-gray-400', failing: 'text-red-500', degraded: 'text-yellow-500' }, + title: (endpoint) => shortUrl(get(endpoint, 'url')) ?? first(endpoint, 'public_id'), + identifier: (endpoint) => first(endpoint, 'description'), + image: () => ({ icon: 'globe' }), + status: (endpoint) => first(endpoint, 'health_status', 'status'), + selectDetails: (endpoint) => [first(endpoint, 'description'), first(endpoint, 'mode')], + facts: (endpoint) => [ + { label: 'URL', value: get(endpoint, 'url') }, + { label: 'Status', value: get(endpoint, 'status'), format: 'humanize' }, + { label: 'Mode', value: get(endpoint, 'mode'), format: 'humanize' }, + { label: 'Version', value: get(endpoint, 'version') }, + { label: 'Created', value: get(endpoint, 'created_at'), format: 'date' }, + ], + open: routeOpener(owner, 'webhooks.view'), + }, + { + key: 'api-event', + labelKey: 'developers.resource.api-event', + icon: 'bolt', + modelNames: ['api-event'], + aliases: ['event'], + polymorphicTypes: ['Fleetbase\\Models\\ApiEvent', 'core:api-event'], + permission: 'developers view event', + title: (event) => first(event, 'event', 'public_id'), + identifier: (event) => first(event, 'public_id'), + image: (event) => ({ icon: eventIcon(get(event, 'event')) }), + selectDetails: (event) => [first(event, 'description')], + facts: (event) => [ + { label: 'Description', value: get(event, 'description') }, + { label: 'Source', value: get(event, 'source'), format: 'humanize' }, + { label: 'Created', value: get(event, 'created_at'), format: 'date' }, + ], + open: panelOpener(owner, 'openEvent'), + }, + { + key: 'api-request-log', + labelKey: 'developers.resource.api-request-log', + icon: 'terminal', + modelNames: ['api-request-log'], + aliases: ['request', 'log'], + polymorphicTypes: ['Fleetbase\\Models\\ApiRequestLog', 'core:api-request-log'], + permission: 'developers view log', + statusTones: { success: 'text-green-500', warning: 'text-yellow-500', danger: 'text-red-500', info: 'text-gray-400' }, + title: (log) => first(log, 'path', 'public_id'), + identifier: (log) => first(log, 'public_id'), + image: () => ({ icon: 'terminal' }), + status: (log) => statusTone(get(log, 'status_code')), + selectDetails: (log) => [first(log, 'method'), first(log, 'status_code')], + facts: (log) => [ + { label: 'Method', value: get(log, 'method') }, + { label: 'Status', value: get(log, 'status_code') }, + { label: 'Duration', value: get(log, 'duration') }, + { label: 'IP', value: get(log, 'ip_address') }, + { label: 'Time', value: get(log, 'created_at'), format: 'date' }, + ], + open: panelOpener(owner, 'openRequest'), + }, + { + key: 'webhook-request-log', + labelKey: 'developers.resource.webhook-request-log', + icon: 'paper-plane', + modelNames: ['webhook-request-log'], + aliases: ['attempt', 'delivery'], + polymorphicTypes: ['Fleetbase\\Models\\WebhookRequestLog', 'core:webhook-request-log'], + permission: 'developers view webhook', + statusTones: { successful: 'text-green-500', failed: 'text-red-500', pending: 'text-yellow-500' }, + title: (attempt) => first(attempt, 'api_event.event', 'public_id'), + identifier: (attempt) => first(attempt, 'public_id'), + image: () => ({ icon: 'paper-plane' }), + status: (attempt) => first(attempt, 'status'), + open: panelOpener(owner, 'openAttempt'), + }, + { + key: 'socket-channel', + labelKey: 'developers.resource.socket-channel', + icon: 'tower-broadcast', + modelNames: ['socket-channel'], + aliases: ['channel'], + polymorphicTypes: ['socket-channel'], + permission: 'developers view socket', + statusTones: { allowed: 'text-green-500', denied: 'text-red-500', unknown: 'text-gray-400', watching: 'text-green-500' }, + title: (channel) => first(channel, 'name'), + identifier: (channel) => first(channel, 'description'), + image: (channel) => ({ icon: channelIcon(get(channel, 'type')) }), + status: (channel) => first(channel, 'access', 'status') ?? 'unknown', + open: routeOpener(owner, 'sockets.view'), + }, + ]; +} + +/** An icon for an event type from its resource prefix. */ +export function eventIcon(eventName) { + const prefix = typeof eventName === 'string' ? eventName.split('.')[0] : ''; + + return ( + { + order: 'box', + payload: 'boxes-stacked', + entity: 'cube', + waypoint: 'location-dot', + driver: 'id-card', + vehicle: 'truck', + fleet: 'truck-ramp-box', + contact: 'address-book', + place: 'map-pin', + chat_message: 'comment', + chat_channel: 'comments', + chat_participant: 'user-group', + chat_receipt: 'check-double', + chat_log: 'file-lines', + service_quote: 'file-invoice-dollar', + service_rate: 'tags', + service_area: 'draw-polygon', + purchase_rate: 'receipt', + vendor: 'store', + user: 'user', + }[prefix] ?? 'bolt' + ); +} + +/** An icon for a socket channel from its type. */ +export function channelIcon(type) { + return ( + { + company: 'building', + api: 'key', + custom: 'headphones', + user: 'user', + driver: 'id-card', + }[type] ?? 'tower-broadcast' + ); +} diff --git a/addon/utils/retention.js b/addon/utils/retention.js new file mode 100644 index 0000000..b9a8bd7 --- /dev/null +++ b/addon/utils/retention.js @@ -0,0 +1,13 @@ +/** + * How many days of API request logs and webhook delivery logs the platform keeps. + * + * core-api schedules `purge:api-logs --days 2` and `purge:webhook-logs --days 2` + * (CoreServiceProvider). The UI states this value wherever a list or panel + * promises history. + * + * TODO(v1.1): read this from the runtime config the console fetches so the + * console and the scheduler cannot drift apart. + */ +export const RETENTION_DAYS = 2; + +export default RETENTION_DAYS; diff --git a/app/components/api-key/expiry-cell.js b/app/components/api-key/expiry-cell.js new file mode 100644 index 0000000..6e9ee94 --- /dev/null +++ b/app/components/api-key/expiry-cell.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/api-key/expiry-cell'; diff --git a/app/components/api-key/key-cell.js b/app/components/api-key/key-cell.js new file mode 100644 index 0000000..0d1d76c --- /dev/null +++ b/app/components/api-key/key-cell.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/api-key/key-cell'; diff --git a/app/components/api-key/last-used-cell.js b/app/components/api-key/last-used-cell.js new file mode 100644 index 0000000..ee7c30c --- /dev/null +++ b/app/components/api-key/last-used-cell.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/api-key/last-used-cell'; diff --git a/app/components/api-key/panel.js b/app/components/api-key/panel.js new file mode 100644 index 0000000..54f4ebd --- /dev/null +++ b/app/components/api-key/panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/api-key/panel'; diff --git a/app/components/api-key/reveal.js b/app/components/api-key/reveal.js new file mode 100644 index 0000000..4f02a56 --- /dev/null +++ b/app/components/api-key/reveal.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/api-key/reveal'; diff --git a/app/components/code-viewer.js b/app/components/code-viewer.js new file mode 100644 index 0000000..6a3c3e3 --- /dev/null +++ b/app/components/code-viewer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/code-viewer'; diff --git a/app/components/dashboard/onboarding.js b/app/components/dashboard/onboarding.js new file mode 100644 index 0000000..aef9085 --- /dev/null +++ b/app/components/dashboard/onboarding.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/dashboard/onboarding'; diff --git a/app/components/dashboard/period-selector.js b/app/components/dashboard/period-selector.js new file mode 100644 index 0000000..59b26c8 --- /dev/null +++ b/app/components/dashboard/period-selector.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/dashboard/period-selector'; diff --git a/app/components/environment-switch.js b/app/components/environment-switch.js new file mode 100644 index 0000000..9e48cd5 --- /dev/null +++ b/app/components/environment-switch.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/environment-switch'; diff --git a/app/components/filter-bar.js b/app/components/filter-bar.js new file mode 100644 index 0000000..2e4f557 --- /dev/null +++ b/app/components/filter-bar.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/filter-bar'; diff --git a/app/components/modal/steps.js b/app/components/modal/steps.js new file mode 100644 index 0000000..2d307a1 --- /dev/null +++ b/app/components/modal/steps.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/modal/steps'; diff --git a/app/components/resource-header.js b/app/components/resource-header.js new file mode 100644 index 0000000..741ab25 --- /dev/null +++ b/app/components/resource-header.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/resource-header'; diff --git a/app/components/section-card.js b/app/components/section-card.js new file mode 100644 index 0000000..86ab186 --- /dev/null +++ b/app/components/section-card.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/section-card'; diff --git a/app/components/states/denied.js b/app/components/states/denied.js new file mode 100644 index 0000000..bf5e187 --- /dev/null +++ b/app/components/states/denied.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/states/denied'; diff --git a/app/components/states/empty.js b/app/components/states/empty.js new file mode 100644 index 0000000..b386a36 --- /dev/null +++ b/app/components/states/empty.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/states/empty'; diff --git a/app/components/states/error.js b/app/components/states/error.js new file mode 100644 index 0000000..b9aa0e6 --- /dev/null +++ b/app/components/states/error.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/components/states/error'; diff --git a/app/controllers/home/index.js b/app/controllers/home/index.js new file mode 100644 index 0000000..0c9eec9 --- /dev/null +++ b/app/controllers/home/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/controllers/home/index'; diff --git a/app/helpers/dev-duration.js b/app/helpers/dev-duration.js new file mode 100644 index 0000000..9ca45ff --- /dev/null +++ b/app/helpers/dev-duration.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/dev-duration'; diff --git a/app/helpers/mask-key.js b/app/helpers/mask-key.js new file mode 100644 index 0000000..5936bef --- /dev/null +++ b/app/helpers/mask-key.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/mask-key'; diff --git a/app/helpers/short-url.js b/app/helpers/short-url.js new file mode 100644 index 0000000..8c5994d --- /dev/null +++ b/app/helpers/short-url.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/short-url'; diff --git a/app/helpers/status-tone.js b/app/helpers/status-tone.js new file mode 100644 index 0000000..6acc555 --- /dev/null +++ b/app/helpers/status-tone.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/status-tone'; diff --git a/app/helpers/time-ago.js b/app/helpers/time-ago.js new file mode 100644 index 0000000..0461255 --- /dev/null +++ b/app/helpers/time-ago.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/time-ago'; diff --git a/app/helpers/time-stamp.js b/app/helpers/time-stamp.js new file mode 100644 index 0000000..0e57a1f --- /dev/null +++ b/app/helpers/time-stamp.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/helpers/time-stamp'; diff --git a/app/services/developer-metrics.js b/app/services/developer-metrics.js new file mode 100644 index 0000000..7f1083e --- /dev/null +++ b/app/services/developer-metrics.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/services/developer-metrics'; diff --git a/app/services/developer-panels.js b/app/services/developer-panels.js new file mode 100644 index 0000000..3f6a0d5 --- /dev/null +++ b/app/services/developer-panels.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/services/developer-panels'; diff --git a/design/IMPLEMENTATION_PROMPT.md b/design/IMPLEMENTATION_PROMPT.md new file mode 100644 index 0000000..4f7d65f --- /dev/null +++ b/design/IMPLEMENTATION_PROMPT.md @@ -0,0 +1,100 @@ +# Prompt for Claude Code: implement the Developers module redesign (`@fleetbase/dev-engine`) + +> Paste everything below this line into Claude Code, run from `packages/dev-engine` inside the fleetbase-dev monorepo. + +--- + +## 1. Your job + +Implement the redesign of the Fleetbase Developers console that is specified on the design canvas at **https://claude.ai/artifact/9tHmccX6pQbP4m2U721GBp**. Work screen by screen, verify each screen visually in the running console against its board on the canvas, and finish with every board in scope implemented, lint clean, and translated. + +Read the canvas with the Artifact tool (`action: "read"` on that URL, then `path: "project/.dc.html"` for individual boards). Every board carries numbered callouts and a notes panel that explains each callout and names the ember-ui component that renders it. The board files are plain HTML, so you can read the exact layout, copy, spacing and states from them. Board names you will use: + +| Section | Boards | +|---|---| +| Research (read first) | `Main`, `Audit`, `Backlog`, `Handoff`, `Components` | +| Global | `Shell` (navigation, Cmd-K search, shared empty / skeleton / error / permission-denied states) | +| Dashboard | `Dashboard`, `DashboardOnboarding` | +| API keys | `ApiKeysList`, `ApiKeyCreate` (interactive), `ApiKeyRoll`, `ApiKeyDetail`, `ApiKeyDialogs` | +| Webhooks | `WebhooksList`, `WebhookForm`, `WebhookDetail`, `WebhookStates` | +| Channels | `ChannelsList`, `ChannelViewer`, `ChannelStates` | +| Events | `EventsList`, `EventDetail` | +| Requests | `RequestsList`, `RequestDetail` | +| Flows | `FlowOnboarding`, `FlowDebugWebhook`, `FlowRollKey`, `FlowChannel` | +| Dark and mobile | `*Dark`, `*Mobile` wrappers of the main screens | + +Work autonomously. Do not stop to ask unless the console is unreachable. Make routine calls yourself and record every assumption in the PR description. + +--- + +## 2. Scope and defaults + +Implement **v1: everything that works with the existing core-api**. Design the code so that v1.1 and v2 items plug in later, but do not build backend endpoints in this pass. The `Handoff` board lists every change by repository and priority; the `Backlog` board gives each item its API capability class (a, b, c). Implement every class **a** item. For class **b** and **c** items, build the UI slot and leave it behind a clearly named, disabled affordance only where the board shows one (for example the "Send test event" button renders disabled with help text "Available in a later release" and a `TODO(v2)` comment), otherwise omit it. + +Defaults to apply unless the user has said otherwise (these are the six open decisions on the `Main` board): + +1. **Secrets.** Remove the secret column and all click-to-reveal of the secret from the list. The reveal-once step in `ApiKeyCreate` and `ApiKeyRoll` is built, and it shows whatever the create and roll responses return today. Add a `TODO(v1.1)` where the backend must start returning an independent one-time secret. +2. **Redeliver, test event, recover.** UI slots only, disabled, as described above. +3. **Naming.** Sidebar and header labels become Dashboard, API Keys, Webhooks, Events, Requests, Channels in that order. Route names and paths stay as they are (`logs`, `sockets`); only translations and menu registration change. +4. **Retention.** Replace every "15 days" string with a value read from a single constant `RETENTION_DAYS` in the engine, defaulting to 2, with a `TODO(v1.1)` to read it from runtime config. +5. **Socket auth.** Do not change ember-core. The channel viewer must render the `no_token` denial state from `ChannelStates` honestly when the server refuses. +6. **Drawer versus page.** Events, requests, attempts and API keys open in a right-hand Overlay drawer from their list; a "full page" action transitions to the existing `view` route. Webhook endpoints and channels keep full pages. + +Out of scope: core-api, ember-core, ember-ui source changes. If a component you need does not exist in ember-ui, create it inside this engine under `addon/components/` (see rule 1 below) and list it in the PR. + +--- + +## 3. Design rules (non-negotiable) + +1. **ember-ui is the base, not the ceiling.** Inputs, selects, dropdowns, buttons, badges, toggles, checkboxes, modals, overlays, tabs, pagination, skeletons, code blocks and table cells come from `@fleetbase/ember-ui` (`packages/ember-ui/addon/components`). Never write a native `` left in the engine; no `ContentPanel` outside detail bodies; no wrapped label, title, button or subtext at 1440, 1280 or 1024 px; no horizontal page scroll at 390 px; every list has empty, loading, error and denied states; every gated control renders disabled with help text; lints and intl lint clean; registries and shared services still work (open the API-key dialog from Fleetbase AI if that engine is installed, otherwise assert the service API is unchanged with a unit test). diff --git a/design/REDESIGN_PROMPT.md b/design/REDESIGN_PROMPT.md new file mode 100644 index 0000000..908f738 --- /dev/null +++ b/design/REDESIGN_PROMPT.md @@ -0,0 +1,237 @@ +# Prompt for Claude Design: Complete redesign of the Fleetbase Developers module (`@fleetbase/dev-engine`) + +> Paste everything below this line into Claude Design. + +--- + +## 1. Your role and the deliverable + +You are the lead product designer for the **Fleetbase Developers console** (the `@fleetbase/dev-engine` Ember engine, mounted at `console.developers`). Produce a **complete, section-by-section redesign mockup** of the entire module, including every list view, every detail view, every modal/form, every flow, and every state (empty, loading, error, permission denied, dark mode, mobile). The output is a single design artifact that an engineer can build from and that I can review screen-by-screen. + +Do not jump to mockups. Earn them by running the six research phases below first, and show your evidence. Each design decision in the final artifact must trace back to a finding from the audit, the codebase, the API, a competitor pattern, or a Fleetbase design standard. + +Work autonomously. Do not stop to ask me questions unless you are genuinely blocked (for example the console shows a login screen or is unreachable). The console and browser are already set up for you; see Phase 1. Make routine judgment calls yourself and state the assumption in the artifact. + +--- + +## 2. Context you must know + +**Product.** Fleetbase is an open-source modular logistics and fleet operations platform. The console is an Ember app made of "engines" (extensions). The Developers extension is where integrators manage API access and observe what their integrations are doing. Its users are backend developers, integration engineers, and technical ops staff, not drivers or dispatchers. + +**What the module contains today (5 sections plus a dashboard):** + +| Section | Route | What it does today | +|---|---|---| +| Dashboard | `/developers` | Widget dashboard (`@extension="developers"`) with 8 KPI tiles (API requests, error rate, avg latency, webhook success rate, active API keys, active webhooks, webhook failures, events emitted), 4 analytics/ops widgets (API traffic, webhook delivery health, endpoint health, event stream), developer activity, quick resources, and a legacy "API Metrics" widget. | +| API Keys | `/developers/api-keys` | Table of API credentials (name, public key, secret key via click-to-reveal, environment live/test, expiry, last used, created). Header has a "Test mode" toggle and, when on, a native `` vs Fleetbase `Select`), spacing and alignment issues, unlabeled or ambiguous controls, missing states (empty, loading, error), dark-mode defects, contrast failures, truncation and overflow, mobile breakage, dead or commented-out UI, and anything that violates the Fleetbase standards in Phase 5. Rate each issue by severity (blocker, major, minor, polish). + +--- + +## 4. Phase 2: Codebase research (frontend) + +Read the module source at `packages/dev-engine/addon` (GitHub: https://github.com/fleetbase/dev-engine). Specifically: + +- `routes.js`, `extension.js` (menu, widgets, registries), `engine.js` +- `templates/**` and `controllers/**` for all five sections (columns, action buttons, bulk actions, query params, search) +- `components/modals/*.hbs` (api-key-form, rename-api-key-form, roll-api-key-form, webhook-form, listen-custom-channel) +- `components/webhook/{metrics,details,attempts}.hbs|js`, `components/api-event/*`, `components/api-request-log/*` +- `components/widget/*` (dashboard widgets and the data they fetch) +- `services/api-key-actions.js`, `services/webhook-actions.js` +- `models/{api-credential,api-event,api-request-log,webhook-endpoint,webhook-request-log}.js` +- `styles/dev-engine.css`, `translations/en-us.yaml` + +Produce a **capability inventory**: for each model, every attribute that exists, and whether the UI currently surfaces it. I already know of several attributes that exist on the model but are never shown or editable; verify and extend this list: + +- `api-credential`: `browser_origins` (CORS allow-list), `api` (API scope), `expires_at` is only settable at create/roll, `last_used_at`, `test_mode`. +- `webhook-endpoint`: `version`, `mode`, `is_listening_on_all_events`, `api_credential_name`, created/updated by. +- `webhook-request-log`: `attempt` number, `headers`, `meta`, `sent_at`, `duration`, `reason_phrase`, relation to `api_event`. +- `api-request-log`: `full_url`, `content_type`, `ip_address`, `response_headers`, `request_raw_body`, `response_raw_body`, `related_resources`, `related`, `source`, `version`. +- `api-event`: `source`, `method`, `api_credential_uuid`. + +Also produce a **behavior inventory** of what the UI can do today (every action, filter, sort, search, bulk action, export) and a **gap list** of obvious missing behaviors. Candidate gaps to verify, not assume: + +- No one-time secret reveal flow after creating or rolling a key; the secret sits in the table behind click-to-reveal. +- Test-mode toggle plus native select in the API keys header is confusing and does not explain what it changes. +- No way to edit expiration, CORS origins, or scope after creation. +- Webhook form uses native selects and a bespoke events dropdown instead of standard Fleetbase select components; no inline validation of the URL; no "send test event" or "ping endpoint". +- Webhook attempts have no redeliver/retry, no response headers, no attempt-number grouping, no latency trend, no link to the originating event. +- Sockets list is not a real resource list: no last-activity, no subscriber count, no filtering; the viewer has no pause, clear, filter, pretty JSON, copy, download, or event-type chips. +- Events and Logs lists have no filters (date range, event type, source, API credential, HTTP method, status class, version) and no saved views; detail pages have no "related" navigation (event to its webhook deliveries, log to its API key, log to the events it produced) and no copy-as-cURL or copy-as-JSON. +- Detail pages use stacked `ContentPanel` tables instead of the standard `Layout::Resource::Panel` pattern used by FleetOps. +- No designed empty states, loading skeletons, or error states anywhere. + +--- + +## 5. Phase 3: Backend research (`fleetbase/core-api`) + +Read `packages/core-api` (GitHub: https://github.com/fleetbase/core-api), especially `src/routes.php` and these controllers under `src/Http/Controllers/Internal/v1`: `ApiCredentialController`, `WebhookEndpointController`, `WebhookRequestLogController`, `ApiEventController`, `ApiRequestLogController`, `DeveloperMetricsController`, `DeveloperSearchController`. Also read the corresponding models, resources (serializers), and filters under `src/Models`, `src/Http/Resources`, `src/Http/Filter`. + +Known internal endpoints to account for: + +- `api-credentials` CRUD plus `PATCH api-credentials/roll/{id}` and `GET api-credentials/export` +- `webhook-endpoints` CRUD plus `PATCH enable/{id}`, `PATCH disable/{id}`, `GET webhook-endpoints/events` (the catalog of subscribable events), `GET webhook-endpoints/versions` +- `webhook-request-logs`, `api-events`, `api-request-logs` (list, view, with filters; check what filter params each supports) +- `GET metrics/dev/kpis`, `dev/api-traffic`, `dev/webhook-delivery`, `dev/credentials`, `dev/events`, `dev/endpoint-health`, `dev/activity` +- `GET developers/search` +- The socket server (`socket/` directory in the monorepo, SocketCluster based) and how channel authorization works, since the viewer shows authorization failures. + +Deliver an **API capability matrix**: for each UI improvement you propose, mark it as (a) possible with existing endpoints and fields, (b) possible with a small backend change (name the controller and the change), or (c) requires a new endpoint. Do not design features that depend on data the platform cannot produce without labeling them clearly as (b) or (c). + +--- + +## 6. Phase 4: Competitor and pattern analysis + +Research and screenshot or sketch the relevant patterns from these products, then extract concrete, reusable patterns (not generic praise): + +- **Stripe Dashboard, Developers section and Workbench**: API keys (restricted keys, reveal-once, last used, roll with grace period), Webhooks (endpoint list with error-rate, event selection UX, test/live toggle, attempt detail with request/response tabs, resend), Events (filterable stream, event to related requests), Logs (filterable request log, request detail with headers/body tabs, copy as cURL), Workbench's bottom-drawer inspector. +- **GitHub webhooks**: Recent deliveries list, per-delivery request/response tabs, redeliver button, delivery status iconography. +- **Svix and Hookdeck**: endpoint health, attempt timelines, retry schedules, replay, event-type catalog with schemas. +- **Twilio Console and Shopify Partners**: API key / credential management, debugger and error logs. +- **Pusher Debug Console and Ably dev console**: live socket/channel inspector (pause, clear, filter, publish test message, pretty JSON, event tags, connection state). +- **Supabase, Vercel, and Datadog log explorers**: log filtering, time-range pickers, saved views, dense row design, side-panel detail. +- **Clerk and Resend** (optional): clean API-key and webhook UIs with good empty states and onboarding. + +For each competitor, note: information architecture, list density and columns, filter model, detail layout (page vs side panel vs drawer), how secrets are revealed, how test vs live is communicated, how failures are surfaced, and empty/onboarding states. End with a **pattern shortlist** of the 10 to 15 patterns you will adopt and why they fit Fleetbase's users. + +--- + +## 7. Phase 5: Fleetbase UI/UX standards you must follow + +Fleetbase has an established component library, `@fleetbase/ember-ui` (source in `packages/ember-ui/addon/components`, docs at https://fleetbase.io/docs/ui, playground at https://fleetbase.github.io/ember-ui/). FleetOps (`packages/fleetops`) is the reference implementation of the modern patterns and should be your visual and structural benchmark. Read the component sources and the playground, then **design with these components, by name, rather than inventing new primitives**: + +**Lists and tables** +- `Layout::Resource::Tabular` with `@registry`, `@actionButtons`, `@bulkActions`, `@onRowClick`, search, pagination. Keep this as the list shell. +- `Table::Cell::Identity` (and the typed variants `user-identity`, `resource-identity`, `company-identity`, `role-identity`, `group-identity`, `file-identity`) for the primary identity column: icon or image, title, subtitle, inline badges, optional status dot, click handler, and hover popover. Use this for API keys, webhook endpoints, socket channels, events, and logs instead of plain text cells. +- `Table::Cell::Status`, `Table::Cell::Dropdown` (row actions), `Table::Cell::LinkTo`, `click-to-copy`, `click-to-reveal`, `Table::Cell::ExpandArrow` (expandable rows). +- `FiltersPicker` and `Filter::Select` / `Filter::MultiInput` for list filtering; `VisibleColumnPicker` for column visibility; `Pagination`. + +**Detail views and panels** +- `Layout::Resource::Panel` with `Layout::Resource::Panel::Header` and `Layout::Resource::Panel::HeaderActions` (FleetOps uses this in 78 places, for example `fleetops/addon/components/driver/panel-header.hbs`). Detail pages for webhooks, events, logs, and socket channels should follow this pattern, with a header identity block (icon, title, subtitle, status badge) and header actions. +- `Layout::Resource::Card`, `Card::Header`, `Card::Body`, `Card::Footer`, `Layout::Resource::CardsGrid` for card layouts (candidate for the sockets list and dashboard quick resources). +- `ContentPanel` (collapsible titled panel with title slot and status badge) for sub-sections inside a detail view. +- `Overlay` with `Overlay::Header`, `Overlay::Body`, `Overlay::Footer`, and `Drawer` for side panels. Consider an overlay or drawer for log/event/attempt inspection from a list so users do not lose their place. +- `Tabs` / `TabNavigation` for request/response/headers/body switching. `Timeline` for attempt histories. `Skeleton`, `Skeleton::Card`, `Skeleton::Form`, `Skeleton::CardsGrid` for loading. `StatWidget` and `Dashboard::WidgetCard` for KPI tiles. + +**Forms and inputs** +- `InputGroup`, `InputLabel` (with `@helpText`, `@required`), `InputInfo`, `InfoBlock`. +- `Select`, `ModelSelect`, `ModelSelectMultiple`, `MultiSelect`, `FetchSelect`, `ComboBox` rendered with the `Resource::SelectOption` / `select-option/*` option component (photo or icon, title, details line). Replace every native `
- - - - - - - - - - - - - - -
- {{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-request-log/credential-cell.hbs b/addon/components/api-request-log/credential-cell.hbs new file mode 100644 index 0000000..ebcc3fc --- /dev/null +++ b/addon/components/api-request-log/credential-cell.hbs @@ -0,0 +1,8 @@ +{{#if @row.api_credential_uuid}} + + + {{or @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..c8da097 --- /dev/null +++ b/addon/components/api-request-log/panel.hbs @@ -0,0 +1,65 @@ +
+
+ +
+ {{this.log.method}} {{this.log.path}} + + {{or this.duration "—"}} + · {{this.createdAt}} + · {{this.log.public_id}} + +
+
+
+
+ +
+
{{t "developers.logs.panel.full-url"}}
+
{{this.log.full_url}}
+
{{t "developers.logs.index.api-credential"}}
+
+ {{#if this.log.api_credential_uuid}} + + {{else}} + — + {{/if}} +
+
{{t "developers.component.api-request-log.details.ip-address"}}
+
+ {{#if this.log.ip_address}} + + {{else}} + — + {{/if}} +
+
{{t "developers.common.version"}}
+
{{n-a this.log.version}} · {{t "developers.common.source"}}
+
{{t "developers.logs.panel.content-type"}}
+
{{n-a this.log.content_type}}
+
{{t "developers.logs.panel.sizes"}}
+
{{t "developers.logs.panel.sizes-value" request=(or this.requestSize "0 B") response=(or this.responseSize "0 B")}}
+ {{#if this.relatedResources.length}} +
{{t "developers.logs.panel.related"}}
+
{{#each this.relatedResources as |id|}}{{id}}{{/each}}
+ {{/if}} +
+ + + {{#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}} + +
diff --git a/addon/components/api-request-log/panel.js b/addon/components/api-request-log/panel.js new file mode 100644 index 0000000..a8e9760 --- /dev/null +++ b/addon/components/api-request-log/panel.js @@ -0,0 +1,167 @@ +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'; + + 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 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/status-cell.hbs b/addon/components/api-request-log/status-cell.hbs new file mode 100644 index 0000000..addbab4 --- /dev/null +++ b/addon/components/api-request-log/status-cell.hbs @@ -0,0 +1,3 @@ + + + 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..2e1c7e9 --- /dev/null +++ b/addon/components/api-request-log/time-cell.hbs @@ -0,0 +1 @@ +{{time-stamp @value "MMM d, 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/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..45119ad --- /dev/null +++ b/addon/components/channel/connection-badge.js @@ -0,0 +1,67 @@ +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 () => { + for await (const _ of instance.listener('connect')) { + if (this.isDestroyed) break; + this.state = 'connected'; + } + })(); + + (async () => { + for await (const _ of instance.listener('close')) { + if (this.isDestroyed) break; + this.state = 'disconnected'; + } + })(); + + (async () => { + 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/modals/listen-custom-channel.hbs b/addon/components/modals/listen-custom-channel.hbs index 4dd6a52..eaca696 100644 --- a/addon/components/modals/listen-custom-channel.hbs +++ b/addon/components/modals/listen-custom-channel.hbs @@ -1,9 +1,32 @@ -