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 @@
-
+
-
+
{{#each this.resources as |resource|}}
-
-
- {{resource.label}}
-
-
+
{{/each}}
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..22619a7 100644
--- a/addon/controllers/api-keys/index.js
+++ b/addon/controllers/api-keys/index.js
@@ -1,27 +1,22 @@
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 { task, timeout } from 'ember-concurrency';
+import { maskKey, timeAgo } from '../../utils/format';
+import isRowControlClick from '../../utils/is-row-control-click';
export default class ApiKeysIndexController extends Controller {
@service apiKeyActions;
+ @service devViewport;
@service currentUser;
@service intl;
@service modalsManager;
@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,23 +46,24 @@ 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',
iconClass: 'rotate-icon-45',
+ wrapperClass: 'hidden md:flex',
text: this.intl.t('developers.common.export'),
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 +77,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 +90,174 @@ 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',
+ 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: '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',
- },
- ],
- },
- ];
+ {
+ id: 'key',
+ label: this.intl.t('developers.api-keys.index.public-key'),
+ valuePath: 'key',
+ width: 190,
+ hidden: this.devViewport.below(640),
+ 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: 130,
+ hidden: this.devViewport.below(640),
+ 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: 130,
+ hidden: this.devViewport.below(1100),
+ 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: 150,
+ hidden: this.devViewport.below(1280),
+ 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',
+ },
+ ],
+ },
+ ];
+ }
/**
- * 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 +266,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 +327,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,
- });
+ this.editApiKey(apiKey, { onDecline: this.clearDeepLinkedApiKey, onFinish: this.clearDeepLinkedApiKey });
} 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 +342,19 @@ export default class ApiKeysIndexController extends Controller {
this.view_api_key = null;
this.hostRouter.transitionTo({ queryParams: { view_api_key: null } });
}
+
+ maskKey(key) {
+ return maskKey(key);
+ }
+
+ /**
+ * Row clicks open the record unless the click landed on a control inside the row.
+ */
+ @action rowClick(row) {
+ if (isRowControlClick()) {
+ return;
+ }
+
+ return this.viewApiKey(row);
+ }
}
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/events/index.js b/addon/controllers/events/index.js
index 5eef967..ffad4cb 100644
--- a/addon/controllers/events/index.js
+++ b/addon/controllers/events/index.js
@@ -3,153 +3,223 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isBlank } from '@ember/utils';
-import { timeout } from 'ember-concurrency';
-import { task } from 'ember-concurrency-decorators';
+import { task, timeout } from 'ember-concurrency';
import fetchFrom from '@fleetbase/ember-core/decorators/legacy-fetch-from';
+import fromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
+import { RETENTION_DAYS } from '../../utils/retention';
+import isRowControlClick from '../../utils/is-row-control-click';
export default class EventsIndexController extends BaseController {
@service filters;
+ @service devViewport;
@service intl;
@service hostRouter;
@service abilities;
+ @service developerPanels;
/**
- * All api versions
- *
- * @memberof LogsIndexController
- * @var {Array}
+ * All event types.
*/
@fetchFrom('webhook-endpoints/events') webhookEvents;
/**
- * Queryable parameters for this controller's model
- *
- * @var {Array}
+ * All api credentials.
*/
- queryParams = this.registeredQueryParams('event', ['query', 'page', 'limit', 'sort', 'event', 'created_at']);
+ @fromStore('api-credential', { limit: -1 }) apiCredentials;
- /**
- * The current page of data being viewed
- *
- * @var {Integer}
- */
- @tracked page = 1;
+ queryParams = this.registeredQueryParams('event', ['query', 'page', 'limit', 'sort', 'event', 'created_at', 'source', 'api_credential_uuid', 'method']);
- /**
- * The maximum number of items to show per page
- *
- * @var {Integer}
- */
+ @tracked page = 1;
@tracked limit = 40;
-
- /**
- * The param to sort the data on, the param with prepended `-` is descending
- *
- * @var {String}
- */
@tracked sort = '-id';
-
- /**
- * The param to query the data by
- *
- * @var {String}
- */
@tracked query;
-
- /**
- * The filterable param `created_at`
- *
- * @var {String}
- */
@tracked created_at;
-
- /**
- * The filterable `event`
- *
- * @var {String}
- */
@tracked event;
+ @tracked source;
+ @tracked api_credential_uuid;
+ @tracked method;
+ @tracked table;
+ retentionDays = RETENTION_DAYS;
- /**
- * Columns for table component.
- *
- * @var {Array}
- */
- @tracked columns = [
- {
- id: 'description',
- sticky: true,
- label: this.intl.t('developers.common.event'),
- valuePath: 'description',
- sortable: false,
- },
- {
- id: 'event',
- label: this.intl.t('developers.common.code'),
- valuePath: 'event',
- sortable: false,
- filterable: true,
- filterComponent: 'filter/select',
- filterOptions: this.webhookEvents,
- },
- {
- id: 'public-id',
- label: this.intl.t('developers.common.id'),
- valuePath: 'public_id',
- cellComponent: 'click-to-copy',
- align: 'right',
- sortable: false,
- },
- {
- id: 'created-at',
- label: this.intl.t('developers.common.date'),
- valuePath: 'createdAt',
- filterParam: 'created_at',
- sortParam: 'created_at',
- sortable: false,
- align: 'right',
- filterable: true,
- filterComponent: 'filter/date',
- },
- ];
+ get credentialNames() {
+ const names = {};
+
+ (this.apiCredentials ?? []).forEach((credential) => {
+ names[credential.id] = credential.name ?? credential.key;
+ });
+
+ return names;
+ }
+
+ get columns() {
+ return [
+ {
+ id: 'event',
+ label: this.intl.t('developers.common.event'),
+ valuePath: 'event',
+ sortable: false,
+ cellComponent: 'table/cell/identity',
+ resourceType: 'api-event',
+ permission: 'developers view event',
+ action: this.openEvent,
+ wrapperClass: 'max-w-full',
+ labelPath: 'event',
+ badges: (event) => [{ key: 'description', label: event.description, class: 'dev-identity-badge-plain' }],
+ badgeLimit: 1,
+ filterable: true,
+ filterParam: 'event',
+ filterLabel: this.intl.t('developers.common.event-type'),
+ filterComponent: 'filter/select',
+ filterOptions: this.webhookEvents,
+ },
+ {
+ id: 'source',
+ label: this.intl.t('developers.common.source'),
+ valuePath: 'source',
+ width: 130,
+ hidden: this.devViewport.below(640),
+ sortable: false,
+ filterable: true,
+ filterLabel: this.intl.t('developers.common.source'),
+ filterComponent: 'filter/select',
+ filterOptions: [
+ { label: this.intl.t('developers.events.sources.api'), value: 'api' },
+ { label: this.intl.t('developers.events.sources.console'), value: 'console' },
+ ],
+ cellComponent: 'api-event/source-cell',
+ },
+ {
+ id: 'method',
+ label: this.intl.t('developers.logs.index.http-method'),
+ valuePath: 'method',
+ width: 100,
+ sortable: false,
+ hidden: true,
+ filterable: true,
+ filterLabel: this.intl.t('developers.logs.index.http-method'),
+ filterComponent: 'filter/multi-option',
+ filterOptions: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
+ cellComponent: 'api-request-log/method-cell',
+ },
+ {
+ id: 'api-credential',
+ label: this.intl.t('developers.webhooks.index.credential'),
+ valuePath: 'api_credential_uuid',
+ width: 170,
+ hidden: this.devViewport.below(1100),
+ sortable: false,
+ filterable: true,
+ filterParam: 'api_credential_uuid',
+ filterLabel: this.intl.t('developers.webhooks.index.credential'),
+ filterComponent: 'filter/select',
+ filterOptionLabel: 'fullName',
+ filterOptionValue: 'id',
+ filterOptions: this.apiCredentials,
+ cellComponent: 'api-event/credential-cell',
+ cellComponentArgs: { names: this.credentialNames },
+ },
+ {
+ id: 'public-id',
+ label: this.intl.t('developers.common.id'),
+ valuePath: 'public_id',
+ width: 160,
+ hidden: this.devViewport.below(1280),
+ sortable: false,
+ cellComponent: 'api-key/key-cell',
+ },
+ {
+ id: 'created-at',
+ label: this.intl.t('developers.common.time'),
+ valuePath: 'created_at',
+ width: 150,
+ hidden: this.devViewport.below(480),
+ sortable: false,
+ filterable: true,
+ filterParam: 'created_at',
+ filterLabel: this.intl.t('developers.common.date'),
+ filterComponent: 'filter/date',
+ cellComponent: 'api-request-log/time-cell',
+ },
+ {
+ 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: 'open', label: this.intl.t('developers.common.open'), icon: 'bolt', fn: this.openEvent, permission: 'developers view event' },
+ {
+ id: 'open-page',
+ label: this.intl.t('developers.common.open-full-page'),
+ icon: 'arrow-up-right-from-square',
+ fn: this.openFullPage,
+ permission: 'developers view event',
+ },
+ { id: 'copy-id', label: this.intl.t('developers.events.actions.copy-id'), icon: 'copy', fn: this.copyId },
+ { id: 'copy-json', label: this.intl.t('developers.events.actions.copy-json'), icon: 'code', fn: this.copyJson },
+ ],
+ },
+ ];
+ }
+
+ get emptyState() {
+ return {
+ icon: 'bolt',
+ title: this.intl.t('developers.events.empty.title'),
+ message: this.intl.t('developers.events.empty.message', { days: RETENTION_DAYS }),
+ };
+ }
- /**
- * The search task.
- *
- * @void
- */
@task({ restartable: true }) *search({ target: { value } }) {
- // if no query don't search
if (isBlank(value)) {
this.query = null;
return;
}
- // timeout for typing
yield timeout(250);
- // reset page for results
if (this.page > 1) {
this.page = 1;
}
- // update the query param
this.query = value;
}
- /**
- * Link user to full request log view
- *
- * @void
- */
- @action onRowClick(log) {
- return this.hostRouter.transitionTo('console.developers.events.view', log);
+ @action openEvent(event) {
+ return this.developerPanels.openEvent(event);
+ }
+
+ @action openFullPage(event) {
+ return this.hostRouter.transitionTo('console.developers.events.view', event);
+ }
+
+ @action copyId(event) {
+ return navigator.clipboard?.writeText(event.public_id);
+ }
+
+ @action copyJson(event) {
+ return navigator.clipboard?.writeText(JSON.stringify(event.data ?? {}, null, 2));
}
- /**
- * Reload data.
- */
@action reload() {
return this.hostRouter.refresh();
}
+
+ /**
+ * Row clicks open the record unless the click landed on a control inside the row.
+ */
+ @action rowClick(row) {
+ if (isRowControlClick()) {
+ return;
+ }
+
+ return this.openEvent(row);
+ }
}
diff --git a/addon/controllers/events/view.js b/addon/controllers/events/view.js
index b61c506..db34120 100644
--- a/addon/controllers/events/view.js
+++ b/addon/controllers/events/view.js
@@ -4,19 +4,16 @@ import { action } from '@ember/object';
export default class EventsViewController extends BaseController {
@service intl;
+ @service hostRouter;
/**
* Header buttons. Extensions add to them through `developers:event:details`.
- *
- * @var {Array}
*/
get actionButtons() {
- return this.detailsActionButtons('event', this.model, [
- { id: 'back', icon: 'long-arrow-left', iconPrefix: 'fas', text: this.intl.t('developers.common.back'), onClick: this.goBack },
- ]);
+ return this.detailsActionButtons('event', this.model, []);
}
@action goBack() {
- return window.history.back();
+ return this.hostRouter.transitionTo('console.developers.events.index');
}
}
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/controllers/logs/index.js b/addon/controllers/logs/index.js
index 25ce336..9e4b2c7 100644
--- a/addon/controllers/logs/index.js
+++ b/addon/controllers/logs/index.js
@@ -1,207 +1,331 @@
import BaseController from '../base-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 { timeout } from 'ember-concurrency';
-import { task } from 'ember-concurrency-decorators';
+import { task, timeout } from 'ember-concurrency';
import fromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
import fetchFrom from '@fleetbase/ember-core/decorators/legacy-fetch-from';
+import { RETENTION_DAYS } from '../../utils/retention';
+import isRowControlClick from '../../utils/is-row-control-click';
+import parseRequestQuery from '../../utils/parse-request-query';
export default class LogsIndexController extends BaseController {
@service filters;
+ @service devViewport;
@service intl;
@service hostRouter;
@service abilities;
+ @service developerPanels;
+ @service fetch;
+ @service notifications;
- /**
- * All api versions
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
@fetchFrom('webhook-endpoints/versions') apiVersions;
-
- /**
- * All api credentials.
- *
- * @memberof WebhooksIndexController
- */
+ @fetchFrom('metrics/dev/api-traffic', { period: '7d' }) traffic;
@fromStore('api-credential', { limit: -1 }) apiCredentials;
- /**
- * Queryable parameters for this controller's model
- *
- * @var {Array}
- */
- queryParams = this.registeredQueryParams('log', ['query', 'page', 'limit', 'sort', 'version', 'key', 'method', 'created_at']);
+ queryParams = this.registeredQueryParams('log', [
+ 'query',
+ 'page',
+ 'limit',
+ 'sort',
+ 'version',
+ 'key',
+ 'method',
+ 'created_at',
+ 'source',
+ 'status_code',
+ 'status_code_gte',
+ 'status_code_lt',
+ 'path',
+ 'ip_address',
+ 'duration_gte',
+ 'duration_lte',
+ ]);
- /**
- * 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 = 40;
-
- /**
- * The param to sort the data on, the param with prepended `-` is descending
- *
- * @var {String}
- */
@tracked sort = '-id';
-
- /**
- * The param to query the data by
- *
- * @var {String}
- */
@tracked query;
-
- /**
- * The filterable param `created_at`
- *
- * @var {String}
- */
@tracked created_at;
-
- /**
- * The filterable param `version`
- *
- * @var {String}
- */
@tracked version;
+ @tracked method;
+ @tracked key;
+ @tracked source;
+ @tracked status_code;
+ @tracked status_code_gte;
+ @tracked status_code_lt;
+ @tracked path;
+ @tracked ip_address;
+ @tracked duration_gte;
+ @tracked duration_lte;
+ @tracked table;
+ @tracked counts = {};
+ retentionDays = RETENTION_DAYS;
+
+ constructor() {
+ super(...arguments);
+ this.loadCounts.perform();
+ }
/**
- * The filterable param `method`
- *
- * @var {String}
+ * Status-class totals for the segments, three cheap `limit=1` reads.
*/
- @tracked method;
+ @task({ restartable: true }) *loadCounts() {
+ const scope = {};
+
+ ['key', 'method', 'version', 'source', 'created_at', 'path', 'ip_address', 'duration_gte', 'duration_lte', 'query'].forEach((param) => {
+ if (!isBlank(this[param])) {
+ scope[param] = this[param];
+ }
+ });
+
+ const read = (params) => this.fetch.get('api-request-logs', { limit: 1, ...scope, ...params }).then((response) => response?.meta?.total ?? 0);
+
+ try {
+ const [all, success, client, server] = yield Promise.all([
+ read({}),
+ read({ status_code_gte: 200, status_code_lt: 300 }),
+ read({ status_code_gte: 400, status_code_lt: 500 }),
+ read({ status_code_gte: 500, status_code_lt: 600 }),
+ ]);
+
+ this.counts = { all, success, client, server };
+ } catch {
+ this.counts = {};
+ }
+ }
+
+ get errorRate() {
+ const { all = 0, client = 0, server = 0 } = this.counts;
+
+ return all ? Math.round(((client + server) / all) * 1000) / 10 : 0;
+ }
/**
- * The filterable param `key`
- *
- * @var {String}
+ * Built-in views: one click sets the filters it stands for.
*/
- @tracked key;
+ get views() {
+ return [
+ { id: 'errors', label: this.intl.t('developers.logs.views.errors'), params: { status_code_gte: 400, status_code_lt: null, duration_gte: null, method: null } },
+ { id: 'slow', label: this.intl.t('developers.logs.views.slow'), params: { duration_gte: 1, status_code_gte: null, status_code_lt: null, method: null } },
+ { id: 'writes', label: this.intl.t('developers.logs.views.writes'), params: { method: 'POST', status_code_gte: null, status_code_lt: null, duration_gte: null } },
+ ];
+ }
/**
- * Columns for table component.
- *
- * @var {Array}
+ * Status-class segments map to `status_code_gte` / `status_code_lt`, which
+ * the generic filter operator suffixes support.
*/
- @computed('apiCredentials.@each.id', 'apiVersions.[]') get columns() {
+ get segments() {
+ const { all, success, client, server } = this.counts;
+
+ return [
+ { label: this.intl.t('developers.common.all'), count: all, params: { status_code_gte: null, status_code_lt: null } },
+ { label: '2xx', count: success, params: { status_code_gte: 200, status_code_lt: 300 } },
+ { label: '4xx', count: client, params: { status_code_gte: 400, status_code_lt: 500 } },
+ { label: '5xx', count: server, params: { status_code_gte: 500, status_code_lt: 600 } },
+ ];
+ }
+
+ get columns() {
return [
{
- id: 'description',
- sticky: true,
- label: this.intl.t('developers.common.description'),
- valuePath: 'description',
+ id: 'request',
+ label: this.intl.t('developers.logs.index.request'),
+ valuePath: 'path',
sortable: false,
+ resizable: true,
+ cellComponent: 'api-request-log/request-cell',
},
{
id: 'status',
label: this.intl.t('developers.common.status'),
- valuePath: 'status',
+ valuePath: 'status_code',
+ width: 120,
sortable: false,
- cellComponent: 'table/cell/status',
- cellClassNames: 'uppercase',
+ cellComponent: 'api-request-log/status-cell',
},
{
- id: 'public-id',
- label: this.intl.t('developers.common.id'),
- valuePath: 'public_id',
- cellComponent: 'click-to-copy',
- align: 'right',
+ id: 'duration',
+ label: this.intl.t('developers.logs.index.duration'),
+ valuePath: 'duration',
+ width: 90,
+ hidden: this.devViewport.below(640),
sortable: false,
+ align: 'right',
+ cellComponent: 'api-request-log/duration-cell',
},
{
id: 'api-credential-name',
label: this.intl.t('developers.logs.index.api-credential'),
valuePath: 'api_credential_name',
- cellComponent: 'click-to-copy',
- filterParam: 'key',
+ width: 170,
+ hidden: this.devViewport.below(1100),
sortable: false,
filterable: true,
+ filterParam: 'key',
+ filterLabel: this.intl.t('developers.logs.index.api-credential'),
filterComponent: 'filter/select',
filterOptionLabel: 'fullName',
filterOptionValue: 'id',
filterOptions: this.apiCredentials,
+ cellComponent: 'api-request-log/credential-cell',
},
{
id: 'method',
label: this.intl.t('developers.logs.index.http-method'),
valuePath: 'method',
+ width: 90,
+ sortable: false,
+ hidden: true,
filterable: true,
+ filterLabel: this.intl.t('developers.logs.index.http-method'),
filterComponent: 'filter/multi-option',
filterOptions: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
+ cellComponent: 'api-request-log/method-cell',
+ },
+ {
+ id: 'source',
+ label: this.intl.t('developers.common.source'),
+ valuePath: 'source',
+ width: 130,
+ hidden: this.devViewport.below(1280),
+ sortable: false,
+ filterable: true,
+ filterLabel: this.intl.t('developers.common.source'),
+ filterComponent: 'filter/select',
+ filterOptions: [
+ { label: this.intl.t('developers.events.sources.api'), value: 'api' },
+ { label: this.intl.t('developers.events.sources.console'), value: 'console' },
+ ],
+ cellComponent: 'api-request-log/source-cell',
},
{
id: 'version',
label: this.intl.t('developers.common.version'),
valuePath: 'version',
+ width: 110,
+ hidden: this.devViewport.below(1280),
+ sortable: false,
filterable: true,
+ filterLabel: this.intl.t('developers.common.version'),
filterComponent: 'filter/select',
filterOptions: this.apiVersions,
+ cellClassNames: 'dev-mono',
},
{
id: 'created-at',
- label: this.intl.t('developers.common.date'),
- valuePath: 'createdAt',
- filterParam: 'created_at',
- sortParam: 'created_at',
+ label: this.intl.t('developers.common.time'),
+ valuePath: 'created_at',
+ width: 100,
+ hidden: this.devViewport.below(640),
sortable: false,
- align: 'right',
filterable: true,
+ filterLeading: true,
+ filterParam: 'created_at',
+ filterLabel: this.intl.t('developers.common.date'),
filterComponent: 'filter/date',
+ cellComponent: 'api-request-log/time-cell',
+ },
+ {
+ 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: 'open', label: this.intl.t('developers.common.open'), icon: 'terminal', fn: this.openRequest, permission: 'developers view log' },
+ { id: 'copy-id', label: this.intl.t('developers.logs.actions.copy-id'), icon: 'copy', fn: this.copyId },
+ { id: 'copy-curl', label: this.intl.t('developers.code-viewer.copy-curl'), icon: 'terminal', fn: this.copyCurl },
+ { id: 'events', label: this.intl.t('developers.logs.actions.view-events'), icon: 'bolt', disabled: true, helpText: this.intl.t('developers.common.unavailable-later') },
+ { separator: true },
+ { id: 'filter-path', label: this.intl.t('developers.logs.actions.filter-path'), icon: 'filter', fn: this.filterByPath },
+ { id: 'filter-ip', label: this.intl.t('developers.logs.actions.filter-ip'), icon: 'filter', fn: this.filterByIp },
+ ],
},
];
}
- /**
- * The search task.
- *
- * @void
- */
- @task({ restartable: true }) *search({ target: { value } }) {
- // if no query don't search
- if (isBlank(value)) {
- this.query = null;
- return;
- }
+ get emptyState() {
+ return {
+ icon: 'terminal',
+ title: this.intl.t('developers.logs.empty.title'),
+ message: this.intl.t('developers.logs.empty.message', { days: RETENTION_DAYS }),
+ };
+ }
- // timeout for typing
+ @task({ restartable: true }) *search({ target: { value } }) {
yield timeout(250);
- // reset page for results
- if (this.page > 1) {
- this.page = 1;
- }
+ const { query, params } = parseRequestQuery(value);
+
+ Object.keys(params).forEach((param) => {
+ this[param] = params[param];
+ });
- // update the query param
- this.query = value;
+ this.query = isBlank(query) ? null : query;
+ this.page = 1;
+ this.loadCounts.perform();
}
- /**
- * Link user to full request log view
- *
- * @void
- */
- @action onRowClick(log) {
+ @action applyView(view) {
+ Object.keys(view.params).forEach((param) => {
+ this[param] = view.params[param] ?? undefined;
+ });
+
+ this.page = 1;
+ this.loadCounts.perform();
+ }
+
+ @action openRequest(log) {
+ return this.developerPanels.openRequest(log);
+ }
+
+ @action openFullPage(log) {
return this.hostRouter.transitionTo('console.developers.logs.view', log);
}
- /**
- * Reload data.
- */
+ @action copyId(log) {
+ return navigator.clipboard?.writeText(log.public_id).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied')));
+ }
+
+ @action copyCurl(log) {
+ return navigator.clipboard?.writeText(log.curl).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied-curl')));
+ }
+
+ @action filterByPath(log) {
+ this.path = log.path;
+ this.page = 1;
+ this.loadCounts.perform();
+ }
+
+ @action filterByIp(log) {
+ this.ip_address = log.ip_address;
+ this.page = 1;
+ this.loadCounts.perform();
+ }
+
@action reload() {
+ this.loadCounts.perform();
return this.hostRouter.refresh();
}
+
+ /**
+ * Row clicks open the record unless the click landed on a control inside the row.
+ */
+ @action rowClick(row) {
+ if (isRowControlClick()) {
+ return;
+ }
+
+ return this.openRequest(row);
+ }
}
diff --git a/addon/controllers/logs/view.js b/addon/controllers/logs/view.js
index 8f3e8b1..ffd96c6 100644
--- a/addon/controllers/logs/view.js
+++ b/addon/controllers/logs/view.js
@@ -4,17 +4,16 @@ import { action } from '@ember/object';
export default class LogsViewController extends BaseController {
@service intl;
+ @service hostRouter;
/**
* Header buttons. Extensions add to them through `developers:log:details`.
- *
- * @var {Array}
*/
get actionButtons() {
- return this.detailsActionButtons('log', this.model, [{ id: 'back', icon: 'long-arrow-left', iconPrefix: 'fas', text: this.intl.t('developers.common.back'), onClick: this.goBack }]);
+ return this.detailsActionButtons('log', this.model, []);
}
@action goBack() {
- return window.history.back();
+ return this.hostRouter.transitionTo('console.developers.logs.index');
}
}
diff --git a/addon/controllers/sockets/index.js b/addon/controllers/sockets/index.js
index d938b8e..90ec69d 100644
--- a/addon/controllers/sockets/index.js
+++ b/addon/controllers/sockets/index.js
@@ -1,22 +1,33 @@
import BaseController from '../base-controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
-import { action, computed } from '@ember/object';
+import { action, get } from '@ember/object';
+import { isBlank } from '@ember/utils';
import fromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
+import { maskKey, timeAgo } from '../../utils/format';
+import isRowControlClick from '../../utils/is-row-control-click';
export default class SocketsIndexController extends BaseController {
@service modalsManager;
+ @service devViewport;
+ @service socketChannels;
@service intl;
@service notifications;
@service currentUser;
- @service store;
@service hostRouter;
@service abilities;
+ @tracked type = null;
+ @tracked query = null;
+ @tracked table;
+
+ /**
+ * All api credentials.
+ */
+ @fromStore('api-credential', { limit: -1 }) apiCredentials;
+
/**
* Header buttons. Extensions add to them through `developers:socket:table:actions`.
- *
- * @var {Array}
*/
get actionButtons() {
return [
@@ -32,105 +43,236 @@ export default class SocketsIndexController extends BaseController {
];
}
- /**
- * All columns applicable for orders
- *
- * @var {Array}
- */
- @tracked columns = [
- {
- label: '',
- width: '12%',
- sortable: false,
- cellComponent: 'table/cell/link-to',
- linkText: this.intl.t('developers.sockets.index.link-text'),
- linkClass: 'btn btn-default btn-xs flex flex-row-reverse',
- linkIcon: 'arrow-right',
- linkIconClass: 'ml-2',
- route: 'sockets.view',
- cellClassNames: 'no-underline',
- },
- {
- id: 'name',
- label: this.intl.t('developers.sockets.index.channel'),
- valuePath: 'name',
- width: '88%',
- cellComponent: 'click-to-copy',
- },
- ];
+ get columns() {
+ return [
+ {
+ id: 'name',
+ label: this.intl.t('developers.sockets.index.channel'),
+ valuePath: 'name',
+ sortable: false,
+ cellComponent: 'table/cell/identity',
+ resourceType: 'socket-channel',
+ permission: 'developers view socket',
+ action: this.watchChannel,
+ wrapperClass: 'max-w-full dev-mono-identity',
+ showStatusDot: true,
+ statusPath: (channel) => channel.access ?? 'unknown',
+ badges: (channel) =>
+ channel.watching
+ ? [{ key: 'watching', label: this.intl.t('developers.sockets.index.watching'), class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }]
+ : [],
+ popover: false,
+ },
+ {
+ id: 'type',
+ label: this.intl.t('developers.sockets.index.type'),
+ valuePath: 'type',
+ width: 150,
+ hidden: this.devViewport.below(640),
+ sortable: false,
+ cellComponent: 'channel/type-cell',
+ },
+ { id: 'source', label: this.intl.t('developers.common.source'), valuePath: 'source', width: 180, hidden: this.devViewport.below(1100), sortable: false },
+ {
+ id: 'access',
+ label: this.intl.t('developers.sockets.index.access'),
+ valuePath: 'access',
+ width: 140,
+ hidden: this.devViewport.below(640),
+ sortable: false,
+ cellComponent: 'channel/access-cell',
+ },
+ { id: 'activity', label: this.intl.t('developers.sockets.index.last-activity'), valuePath: 'activity', width: 180, hidden: this.devViewport.below(1280), 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: 'watch', label: this.intl.t('developers.sockets.index.watch'), icon: 'tower-broadcast', fn: this.watchChannel, permission: 'developers view socket' },
+ { id: 'copy', label: this.intl.t('developers.sockets.index.copy-name'), icon: 'copy', fn: this.copyName },
+ {
+ id: 'forget',
+ label: this.intl.t('developers.sockets.index.forget'),
+ icon: 'trash',
+ class: 'text-red-700',
+ fn: this.forgetChannel,
+ isVisible: (channel) => channel.type === 'custom',
+ },
+ ],
+ },
+ ];
+ }
- /**
- * All api credentials.
- *
- * @memberof SocketsIndexController
- */
- @fromStore('api-credential', { limit: -1 }) apiCredentials;
+ get segments() {
+ const counts = this.allChannels.reduce((acc, channel) => ({ ...acc, [channel.type]: (acc[channel.type] ?? 0) + 1 }), {});
+
+ return [
+ { label: this.intl.t('developers.common.all'), param: 'type', value: null, count: this.allChannels.length },
+ { label: this.intl.t('developers.sockets.types.company'), param: 'type', value: 'company', count: counts.company ?? 0 },
+ { label: this.intl.t('developers.sockets.types.api'), param: 'type', value: 'api', count: counts.api ?? 0 },
+ { label: this.intl.t('developers.sockets.types.custom'), param: 'type', value: 'custom', count: counts.custom ?? 0 },
+ ];
+ }
/**
- * Get all available known socket channels
- *
- * @readonly
- * @memberof SocketsIndexController
+ * Every known channel: company, one per credential, and remembered custom ones.
*/
- @computed('apiCredentials.@each.id', 'currentUser.companyId') get channels() {
+ get allChannels() {
const channels = [];
- const apiCredentials = this.apiCredentials ?? [];
+ // Read through `get` so the async from-store property re-renders the list once it resolves.
+ // eslint-disable-next-line ember/no-get
+ const credentials = get(this, 'apiCredentials') ?? [];
+ const companyId = this.currentUser.companyId;
+
+ channels.push(
+ this.decorate({
+ name: `company.${companyId}`,
+ type: 'company',
+ source: this.currentUser.user?.company_name ?? this.intl.t('developers.sockets.types.company'),
+ description: this.intl.t('developers.sockets.index.company-description'),
+ })
+ );
- for (let index = 0; index < apiCredentials.length; index++) {
- const apiCredential = apiCredentials.objectAt(index);
+ for (let index = 0; index < credentials.length; index++) {
+ const credential = credentials.objectAt ? credentials.objectAt(index) : credentials[index];
- if (!apiCredential?.id) {
+ if (!credential?.id) {
continue;
}
- channels.push({
- name: `api.${apiCredential.id}`,
- source: apiCredentials,
- });
+ channels.push(
+ this.decorate({
+ name: `api.${credential.id}`,
+ type: 'api',
+ source: credential.name ?? maskKey(credential.key),
+ environment: credential.test_mode ? 'test' : 'live',
+ description: this.intl.t('developers.sockets.index.api-description', { name: credential.name ?? maskKey(credential.key) }),
+ })
+ );
}
- channels.pushObject({
- name: `company.${this.currentUser.companyId}`,
- });
+ for (const name of this.socketChannels.customChannels) {
+ channels.push(
+ this.decorate({
+ name,
+ type: 'custom',
+ source: this.intl.t('developers.sockets.index.custom-source'),
+ description: this.intl.t('developers.sockets.index.custom-description'),
+ })
+ );
+ }
return channels;
}
- /**
- * Link user to full event log view
- *
- * @void
- */
- @action onRowClick(row) {
- return this.hostRouter.transitionTo('console.developers.sockets.view', row);
+ decorate(channel) {
+ const state = this.socketChannels.stateFor(channel.name);
+ const lastActivity = state.lastActivityAt ? `${timeAgo(state.lastActivityAt)}${state.lastEvent ? ` · ${state.lastEvent}` : ''}` : null;
+
+ return {
+ ...channel,
+ resourceType: 'socket-channel',
+ access: state.access ?? 'unknown',
+ reason: state.reason ?? null,
+ watching: Boolean(state.watching),
+ activity: lastActivity ?? this.intl.t('developers.sockets.index.no-activity'),
+ };
+ }
+
+ get channels() {
+ let channels = this.allChannels;
+
+ if (!isBlank(this.type)) {
+ channels = channels.filter((channel) => channel.type === this.type);
+ }
+
+ if (!isBlank(this.query)) {
+ const query = this.query.toLowerCase();
+ channels = channels.filter(
+ (channel) =>
+ channel.name.toLowerCase().includes(query) ||
+ String(channel.source ?? '')
+ .toLowerCase()
+ .includes(query)
+ );
+ }
+
+ return channels;
+ }
+
+ @action search({ target: { value } }) {
+ this.query = isBlank(value) ? null : value;
+ }
+
+ @action watchChannel(channel) {
+ return this.hostRouter.transitionTo('console.developers.sockets.view', channel.name);
+ }
+
+ @action copyName(channel) {
+ return navigator.clipboard?.writeText(channel.name).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied')));
+ }
+
+ @action forgetChannel(channel) {
+ this.socketChannels.forgetChannel(channel.name);
}
/**
* Prompt dialog to allow user to listen on a custom socket channel
- *
- * @void
*/
@action listenOnCustomChannel() {
this.modalsManager.show('modals/listen-custom-channel', {
title: this.intl.t('developers.sockets.index.listen-channel-title'),
+ modalClass: 'dev-modal modal-lg',
acceptButtonText: this.intl.t('developers.sockets.index.listen-channel-button-text'),
acceptButtonIcon: 'headphones',
+ acceptButtonScheme: 'magic',
declineButtonIcon: 'times',
declineButtonIconPrefix: 'fas',
channelId: null,
+ remember: true,
+ error: null,
+ rules: this.socketChannels.prefixRules,
+ toggleRemember: (checked) => this.modalsManager.setOption('remember', checked),
confirm: (modal) => {
- modal.startLoading();
- const channelId = modal.getOption('channelId');
+ const channelId = String(modal.getOption('channelId') ?? '').trim();
+ const error = this.socketChannels.validateName(channelId);
+
+ if (error) {
+ modal.setOption('error', error);
+ return;
+ }
- return this.hostRouter.transitionTo('console.developers.sockets.view', { name: channelId });
+ if (modal.getOption('remember')) {
+ this.socketChannels.rememberChannel(channelId);
+ }
+
+ modal.done();
+ return this.hostRouter.transitionTo('console.developers.sockets.view', channelId);
},
});
}
- /**
- * Reload data.
- */
@action reload() {
return this.hostRouter.refresh();
}
+
+ /**
+ * Row clicks open the record unless the click landed on a control inside the row.
+ */
+ @action rowClick(row) {
+ if (isRowControlClick()) {
+ return;
+ }
+
+ return this.watchChannel(row);
+ }
}
diff --git a/addon/controllers/sockets/view.js b/addon/controllers/sockets/view.js
index 9801172..a4d6f31 100644
--- a/addon/controllers/sockets/view.js
+++ b/addon/controllers/sockets/view.js
@@ -2,86 +2,175 @@ import BaseController from '../base-controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
+import { isBlank } from '@ember/utils';
import { format } from 'date-fns';
+const MAX_MESSAGES = 2000;
+
+/**
+ * The live channel viewer. Keeps every message of the session (capped), with
+ * pause, clear, type and text filters, pretty / raw rendering, timestamp
+ * density and a newline-delimited JSON download. Connection, subscription,
+ * denial, kick-out and reconnect states are first-class.
+ */
export default class SocketsViewController extends BaseController {
@service hostRouter;
@service intl;
@service socket;
+ @service socketChannels;
+ @service developerPanels;
+ @service notifications;
- /**
- * Header buttons. Extensions add to them through `developers:socket:details`.
- *
- * @var {Array}
- */
- get actionButtons() {
- return this.detailsActionButtons('socket', this.model, [
- { id: 'back', icon: 'long-arrow-left', iconPrefix: 'fas', text: this.intl.t('developers.common.back'), onClick: this.goBack },
- ]);
+ /** connecting | subscribed | denied | kicked | reconnecting | error | closed */
+ @tracked connection = 'connecting';
+ @tracked reason = null;
+ @tracked messages = [];
+ @tracked buffered = [];
+ @tracked paused = false;
+ @tracked showData = true;
+ @tracked showLifecycle = true;
+ @tracked showErrors = true;
+ @tracked query = '';
+ @tracked eventType = null;
+ @tracked timeFormat = 'ms';
+ @tracked expandedIds = new Set();
+ @tracked startedAt = null;
+ @tracked channel = null;
+ #cleanup = [];
+
+ get channelName() {
+ return this.model?.name;
}
- /**
- * Incoming events logged from socket
- *
- * @memberof SocketsViewController
- */
- @tracked events = [];
+ get channelType() {
+ return this.socketChannels.typeOf(this.channelName);
+ }
- /**
- * Date format to use for socket console events.
- *
- * @memberof SocketsViewController
- */
- consoleDateFormat = 'MMM-dd HH:mm';
+ get iconTone() {
+ return { company: 'is-purple', api: 'is-green', custom: 'is-blue' }[this.channelType] ?? 'is-gray';
+ }
- /**
- * Sends the user back.
- *
- * @memberof SocketsViewController
- */
- @action goBack() {
- return window.history.back();
+ get connectionBadge() {
+ return { subscribed: 'success', connecting: 'warning', reconnecting: 'warning', denied: 'danger', kicked: 'danger', error: 'danger', closed: 'inactive' }[this.connection] ?? 'info';
}
- /**
- * Whether the last subscription attempt was refused. Cleared when the
- * channel subscribes (the socket service resubscribes channels it lost
- * for token reasons) and when leaving the page.
- *
- * @memberof SocketsViewController
- */
- @tracked subscriptionFailed = false;
+ get isSubscribed() {
+ return this.connection === 'subscribed';
+ }
- /**
- * The "Awaiting events..." indicator only makes sense while the channel can
- * still deliver something.
- *
- * @memberof SocketsViewController
- */
- get isAwaitingEvents() {
- return this.events.length > 0 && !this.subscriptionFailed;
+ get isDenied() {
+ return this.connection === 'denied';
+ }
+
+ get isNoToken() {
+ return (
+ this.isDenied &&
+ String(this.reason ?? '')
+ .toLowerCase()
+ .includes('no_token')
+ );
+ }
+
+ get dataCount() {
+ return this.messages.filter((message) => message.kind === 'data').length;
+ }
+
+ get lifecycleCount() {
+ return this.messages.filter((message) => message.kind === 'lifecycle').length;
+ }
+
+ get errorCount() {
+ return this.messages.filter((message) => message.kind === 'error').length;
+ }
+
+ get rate() {
+ if (!this.startedAt || this.dataCount === 0) {
+ return null;
+ }
+
+ const seconds = Math.max(1, (Date.now() - this.startedAt.getTime()) / 1000);
+
+ return (this.dataCount / seconds).toFixed(2);
+ }
+
+ get eventTypes() {
+ return Array.from(new Set(this.messages.filter((message) => message.event).map((message) => message.event))).sort();
+ }
+
+ get visibleMessages() {
+ const query = this.query.trim().toLowerCase();
+
+ return this.messages
+ .filter((message) => {
+ if (message.kind === 'data' && !this.showData) return false;
+ if (message.kind === 'lifecycle' && !this.showLifecycle) return false;
+ if (message.kind === 'error' && !this.showErrors) return false;
+ if (!isBlank(this.eventType) && message.event !== this.eventType) return false;
+ if (query && !message.searchText.includes(query)) return false;
+ return true;
+ })
+ .map((message) => ({ ...message, time: this.formatTime(message.at), expanded: this.expandedIds.has(message.id) }));
+ }
+
+ get hiddenCount() {
+ return this.messages.length - this.visibleMessages.length;
+ }
+
+ get hasFilters() {
+ return !this.showData || !this.showLifecycle || !this.showErrors || !isBlank(this.eventType) || this.query.trim() !== '';
+ }
+
+ get timeFormats() {
+ return [
+ { value: 'time', label: this.intl.t('developers.sockets.view.time-format-time') },
+ { value: 'ms', label: this.intl.t('developers.sockets.view.time-format-ms') },
+ { value: 'relative', label: this.intl.t('developers.sockets.view.time-format-relative') },
+ ];
+ }
+
+ formatTime(date) {
+ if (this.timeFormat === 'relative' && this.startedAt) {
+ const seconds = (date.getTime() - this.startedAt.getTime()) / 1000;
+ return `+${seconds.toFixed(1)}s`;
+ }
+
+ return format(date, this.timeFormat === 'ms' ? 'HH:mm:ss.SSS' : 'HH:mm:ss');
}
/**
- * Appends a line to the console output.
- *
- * @param {String} content
- * @param {String} color tailwind color name
- * @memberof SocketsViewController
+ * Appends a message; buffers while paused.
*/
- logEvent(content, color) {
- // Reassigned rather than mutated so the tracked property invalidates.
- this.events = [...this.events, { time: format(new Date(), this.consoleDateFormat), content, color }];
+ push(message) {
+ const entry = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ at: new Date(),
+ kind: 'data',
+ event: null,
+ resourceId: null,
+ eventId: null,
+ text: null,
+ payload: null,
+ tone: 'ok',
+ ...message,
+ };
+
+ entry.searchText = [entry.event, entry.resourceId, entry.eventId, entry.text, entry.payload ? JSON.stringify(entry.payload) : ''].filter(Boolean).join(' ').toLowerCase();
+
+ if (this.paused) {
+ this.buffered = [...this.buffered, entry].slice(-MAX_MESSAGES);
+ return;
+ }
+
+ this.messages = [...this.messages, entry].slice(-MAX_MESSAGES);
+ }
+
+ logLifecycle(text, tone = 'ok', extra = {}) {
+ this.push({ kind: tone === 'bad' ? 'error' : 'lifecycle', text, tone, ...extra });
}
/**
* Describes a refused subscription. Authorization refusals from the socket
* server arrive as an `AuthError` with a short snake_case `reason`.
- *
- * @param {String} channelName
- * @param {Error} error
- * @return {String}
- * @memberof SocketsViewController
*/
describeSubscribeFailure(channelName, error) {
if (error && error.name === 'AuthError') {
@@ -92,72 +181,238 @@ export default class SocketsViewController extends BaseController {
}
/**
- * Opens socket and logs all incoming events.
- *
- * @memberof SocketsViewController
+ * Opens the socket and logs everything that happens on the channel.
*/
@action async watchSocket(model) {
- // Create SocketClusterClient
+ this.reset();
+ this.startedAt = new Date();
+
+ const name = model.name;
const socket = this.socket.instance();
+ const listen = (emitter, event, handler) => {
+ const listener = emitter.listener(event);
+ (async () => {
+ for await (const payload of listener) {
+ if (this.isDestroyed) break;
+ handler(payload);
+ }
+ })();
+ this.#cleanup.push(() => emitter.closeListener?.(event));
+ };
- // Listen for socket connection errors
- (async () => {
- // eslint-disable-next-line no-unused-vars
- for await (let event of socket.listener('error')) {
- this.logEvent(this.intl.t('developers.sockets.view.socket-connection-error'), 'red');
- }
- })();
+ listen(socket, 'error', () => {
+ this.connection = this.isSubscribed ? 'reconnecting' : 'error';
+ this.logLifecycle(this.intl.t('developers.sockets.view.socket-connection-error'), 'bad');
+ });
- // Listen for socket connection
- (async () => {
- // eslint-disable-next-line no-unused-vars
- for await (let event of socket.listener('connect')) {
- this.logEvent(this.intl.t('developers.sockets.view.socket-connected'), 'green');
+ listen(socket, 'connect', () => {
+ this.logLifecycle(this.intl.t('developers.sockets.view.socket-connected-to', { host: socket.options?.hostname ?? '' }), 'ok', { event: 'connect' });
+ });
+
+ listen(socket, 'connecting', () => {
+ if (this.connection !== 'connecting') {
+ this.connection = 'reconnecting';
+ this.logLifecycle(this.intl.t('developers.sockets.view.reconnecting'), 'wait', { event: 'reconnect' });
}
- })();
+ });
- // Subscribe to the channel
- const channel = socket.subscribe(model.name);
+ // The console may already hold this channel (its own company channel, for
+ // instance); then no `subscribe` event will fire and we must not close it on leave.
+ const alreadySubscribed = socket.isSubscribed?.(name) === true;
+ const channel = socket.subscribe(name);
+ this.channel = channel;
- // Listen for channel subscription
- (async () => {
- // eslint-disable-next-line no-unused-vars
- for await (let event of channel.listener('subscribe')) {
- this.subscriptionFailed = false;
- this.logEvent(this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }), 'blue');
- }
- })();
+ const markSubscribed = () => {
+ this.connection = 'subscribed';
+ this.reason = null;
+ this.socketChannels.update(name, { access: 'allowed', reason: null, watching: true });
+ this.logLifecycle(this.intl.t('developers.sockets.view.subscribed-rule', { modelName: name, rule: this.ruleFor(name) }), 'ok', { event: 'subscribe' });
+ };
- // Listen for a refused subscription, e.g. a channel this user is not
- // authorized to see. Without this the page would wait silently forever.
- (async () => {
- for await (let { error } of channel.listener('subscribeFail')) {
- this.subscriptionFailed = true;
- this.logEvent(this.describeSubscribeFailure(model.name, error), 'red');
- }
- })();
+ listen(channel, 'subscribe', markSubscribed);
+
+ if (alreadySubscribed) {
+ markSubscribed();
+ }
+
+ listen(channel, 'subscribeFail', ({ error }) => {
+ this.connection = 'denied';
+ this.reason = error?.reason || error?.message || 'unknown';
+ this.socketChannels.update(name, { access: 'denied', reason: this.reason, watching: false });
+ this.logLifecycle(this.describeSubscribeFailure(name, error), 'bad', { event: 'subscribeFail' });
+ });
+
+ listen(channel, 'kickOut', ({ message }) => {
+ this.connection = 'kicked';
+ this.reason = message;
+ this.logLifecycle(this.intl.t('developers.sockets.view.socket-kicked-out', { modelName: name, reason: message }), 'bad', { event: 'kickOut' });
+ });
- // Listen for the server removing the subscription (for example when the
- // socket token expired). The socket service resubscribes when it can, and
- // the subscribe listener above logs that.
+ const consumer = typeof channel.createConsumer === 'function' ? channel.createConsumer() : channel;
(async () => {
- for await (let { message } of channel.listener('kickOut')) {
- this.logEvent(this.intl.t('developers.sockets.view.socket-kicked-out', { modelName: model.name, reason: message }), 'red');
+ for await (const data of consumer) {
+ if (this.isDestroyed) break;
+ this.receive(name, data);
}
})();
- // Listen for channel data
- (async () => {
- for await (let data of channel) {
- this.logEvent(JSON.stringify(data, undefined, 2), 'green');
+ this.#cleanup.push(() => {
+ consumer.return?.();
+
+ if (alreadySubscribed) {
+ channel.closeAllListeners?.();
+ } else {
+ channel.close();
}
- })();
+ });
+ this.hostRouter.one('routeWillChange', this.teardown);
+ }
+
+ receive(name, data) {
+ const payload = data && typeof data === 'object' ? data : { value: data };
+ const event = payload.event ?? payload.type ?? payload.name ?? null;
+ const resourceId = payload.data?.id ?? payload.data?.public_id ?? payload.id ?? null;
+ const eventId = payload.id && String(payload.id).startsWith('event_') ? payload.id : (payload.event_id ?? null);
+
+ this.socketChannels.recordMessage(name, event);
+ this.push({ kind: 'data', event, resourceId, eventId, payload, tone: 'ok' });
+ }
- // disconnect when transitioning
- this.hostRouter.on('routeWillChange', () => {
- channel.close();
- this.events = [];
- this.subscriptionFailed = false;
+ ruleFor(name) {
+ const prefix = String(name).split('.')[0];
+
+ return (
+ {
+ company: 'company.
',
+ api: 'api.',
+ user: 'user.',
+ driver: 'driver.',
+ }[prefix] ?? prefix
+ );
+ }
+
+ @action teardown() {
+ this.#cleanup.forEach((fn) => {
+ try {
+ fn();
+ } catch {
+ // listener already closed
+ }
});
+ this.#cleanup = [];
+ this.channel = null;
+
+ if (this.channelName) {
+ this.socketChannels.update(this.channelName, { watching: false });
+ }
+
+ this.connection = 'closed';
+ }
+
+ reset() {
+ this.teardown();
+ this.messages = [];
+ this.buffered = [];
+ this.paused = false;
+ this.connection = 'connecting';
+ this.reason = null;
+ this.expandedIds = new Set();
+ }
+
+ @action retry() {
+ this.watchSocket(this.model);
+ }
+
+ @action togglePause() {
+ this.paused = !this.paused;
+
+ if (!this.paused && this.buffered.length) {
+ this.messages = [...this.messages, ...this.buffered].slice(-MAX_MESSAGES);
+ this.buffered = [];
+ }
+ }
+
+ @action clear() {
+ this.messages = [];
+ this.buffered = [];
+ this.expandedIds = new Set();
+ }
+
+ @action toggleKind(kind) {
+ if (kind === 'data') this.showData = !this.showData;
+ if (kind === 'lifecycle') this.showLifecycle = !this.showLifecycle;
+ if (kind === 'error') this.showErrors = !this.showErrors;
+ }
+
+ @action setQuery(event) {
+ this.query = event.target.value ?? '';
+ }
+
+ @action setEventType(value) {
+ this.eventType = value || null;
+ }
+
+ @action setTimeFormat(value) {
+ this.timeFormat = value;
+ }
+
+ @action clearFilters() {
+ this.showData = true;
+ this.showLifecycle = true;
+ this.showErrors = true;
+ this.eventType = null;
+ this.query = '';
+ }
+
+ @action toggleExpanded(message) {
+ const next = new Set(this.expandedIds);
+
+ if (next.has(message.id)) {
+ next.delete(message.id);
+ } else {
+ next.add(message.id);
+ }
+
+ this.expandedIds = next;
+ }
+
+ @action copyPayload(message, event) {
+ event?.stopPropagation?.();
+ return navigator.clipboard?.writeText(JSON.stringify(message.payload, null, 2)).then(() => this.notifications.info(this.intl.t('developers.code-viewer.copied')));
+ }
+
+ @action openEvent(message, event) {
+ event?.stopPropagation?.();
+
+ if (message.eventId) {
+ return this.hostRouter.transitionTo('console.developers.events.view', message.eventId);
+ }
+ }
+
+ @action download() {
+ const lines = this.messages.map((message) =>
+ JSON.stringify({ at: message.at.toISOString(), kind: message.kind, event: message.event, text: message.text, payload: message.payload })
+ );
+ const blob = new Blob([lines.join('\n')], { type: 'application/x-ndjson' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+
+ anchor.href = url;
+ anchor.download = `${this.channelName}-${format(new Date(), 'yyyyMMdd-HHmmss')}.ndjson`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+ }
+
+ @action unsubscribe() {
+ this.teardown();
+ }
+
+ @action goBack() {
+ return this.hostRouter.transitionTo('console.developers.sockets.index');
+ }
+
+ @action forget() {
+ this.socketChannels.forgetChannel(this.channelName);
+ return this.goBack();
}
}
diff --git a/addon/controllers/webhooks/index.js b/addon/controllers/webhooks/index.js
index 5814883..1cf83ae 100644
--- a/addon/controllers/webhooks/index.js
+++ b/addon/controllers/webhooks/index.js
@@ -1,96 +1,57 @@
import BaseController from '../base-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 { timeout } from 'ember-concurrency';
-import { task } from 'ember-concurrency-decorators';
-import groupApiEvents from '@fleetbase/ember-core/utils/group-api-events';
+import { task, timeout } from 'ember-concurrency';
import fromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
import fetchFrom from '@fleetbase/ember-core/decorators/legacy-fetch-from';
+import { endpointHealth, HEALTH_BADGE } from '../../components/widget/endpoint-health';
+import { RETENTION_DAYS } from '../../utils/retention';
+import isRowControlClick from '../../utils/is-row-control-click';
export default class WebhooksIndexController extends BaseController {
@service webhookActions;
+ @service devViewport;
+ @service developerMetrics;
@service currentUser;
@service intl;
- @service modalsManager;
- @service notifications;
- @service store;
- @service fetch;
@service hostRouter;
@service abilities;
- /**
- * Group api events by resource
- *
- * @var {Object}
- */
- @computed('webhookEvents.[]') get groupedApiEvents() {
- return groupApiEvents(this.webhookEvents);
- }
-
- /**
- * 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 sort the data on, the param with prepended `-` is descending
- *
- * @var {String}
- */
@tracked query;
+ @tracked status;
+ @tracked api_credential_uuid;
+ @tracked version;
+ @tracked table;
/**
- * Queryable parameters for this controller's model
- *
- * @var {Array}
+ * Endpoint health for the last 7 days keyed by endpoint id, joined client side.
+ * TODO(v1.1): append success_rate_24h, failures_24h and last_delivery_at to the endpoint payload.
*/
- queryParams = this.webhookActions.queryParamsFor(['page', 'limit', 'sort', 'query']);
+ @tracked healthById = {};
- /**
- * All webhook events
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
- @fetchFrom('webhook-endpoints/events') webhookEvents;
+ constructor() {
+ super(...arguments);
+ this.loadHealth.perform();
+ }
- /**
- * All api versions
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
- @fetchFrom('webhook-endpoints/versions') apiVersions;
+ queryParams = this.webhookActions.queryParamsFor(['page', 'limit', 'sort', 'query', 'status', 'api_credential_uuid', 'version']);
- /**
- * All api credentials.
- *
- * @memberof WebhooksIndexController
- */
+ @fetchFrom('webhook-endpoints/versions') apiVersions;
@fromStore('api-credential', { limit: -1 }) apiCredentials;
+ retentionDays = RETENTION_DAYS;
+
+ get isSandbox() {
+ return this.currentUser.getOption('sandbox', false) === true;
+ }
+
/**
* Header buttons. Extensions add to them through `developers:webhook:table:actions`.
- *
- * @var {Array}
*/
get actionButtons() {
return [
@@ -106,101 +67,221 @@ export default class WebhooksIndexController extends BaseController {
];
}
- /**
- * Columns for table component.
- *
- * @var {Array}
- */
- @tracked columns = [
- {
- id: 'url',
- label: this.intl.t('developers.common.url'),
- valuePath: 'url',
- width: '40%',
- sortable: false,
- cellComponent: 'table/cell/link-to',
- permission: 'developers view webhook',
- route: 'webhooks.view',
- cellClassNames: 'no-underline',
- },
- {
- id: 'status',
- label: this.intl.t('developers.common.status'),
- valuePath: 'status',
- width: '15%',
- sortable: false,
- cellComponent: 'table/cell/status',
- },
- {
- id: 'mode',
- label: this.intl.t('developers.common.mode'),
- valuePath: 'mode',
- width: '15%',
- sortable: false,
- cellComponent: 'table/cell/status',
- },
- { id: 'version', label: this.intl.t('developers.common.version'), valuePath: 'version', width: '10%', sortable: false },
- { id: 'created-at', label: this.intl.t('developers.common.created'), valuePath: 'createdAt', sortable: false, width: '10%' },
- {
- 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',
- width: '10%',
- align: 'right',
- actions: [
- {
- id: 'view-webhook',
- label: this.intl.t('developers.webhooks.index.view-logs'),
- fn: this.viewWebhook,
- permission: 'developers view webhook',
- },
- {
- id: 'edit-webhook',
- label: this.intl.t('developers.webhooks.index.edit-webhook'),
- fn: this.editWebhook,
- permission: 'developers update webhook',
- },
- {
- id: 'delete-webhook',
- label: this.intl.t('developers.webhooks.index.delete-webhook'),
- fn: this.deleteWebhook,
- permission: 'developers delete webhook',
- },
- ],
- sortable: false,
- filterable: false,
- resizable: false,
- searchable: false,
- },
- ];
+ get failingCount() {
+ return Object.values(this.healthById).filter((item) => item.health === 'failing').length;
+ }
+
+ get columns() {
+ return [
+ {
+ id: 'url',
+ label: this.intl.t('developers.webhooks.index.endpoint'),
+ valuePath: 'url',
+ sortable: false,
+ resizable: true,
+ cellComponent: 'table/cell/identity',
+ resourceType: 'webhook-endpoint',
+ permission: 'developers view webhook',
+ action: this.viewWebhook,
+ wrapperClass: 'max-w-full',
+ showStatusDot: true,
+ statusPath: (webhook) => this.healthFor(webhook).health ?? webhook.status,
+ badges: (webhook) => this.badgesFor(webhook),
+ badgeLimit: 2,
+ },
+ {
+ id: 'status',
+ label: this.intl.t('developers.common.status'),
+ valuePath: 'status',
+ width: 120,
+ hidden: this.devViewport.below(640),
+ sortable: false,
+ filterable: true,
+ filterLabel: this.intl.t('developers.common.status'),
+ filterComponent: 'filter/select',
+ filterOptions: [
+ { label: this.intl.t('developers.webhooks.status.enabled'), value: 'enabled' },
+ { label: this.intl.t('developers.webhooks.status.disabled'), value: 'disabled' },
+ ],
+ cellComponent: 'webhook/status-cell',
+ cellComponentArgs: { healthFor: this.healthFor },
+ },
+ {
+ id: 'events',
+ label: this.intl.t('developers.webhooks.index.events'),
+ valuePath: 'events',
+ width: 160,
+ hidden: this.devViewport.below(640),
+ sortable: false,
+ cellComponent: 'webhook/events-cell',
+ },
+ {
+ id: 'api-credential',
+ label: this.intl.t('developers.webhooks.index.credential'),
+ valuePath: 'api_credential_name',
+ width: 160,
+ hidden: this.devViewport.below(1024),
+ sortable: false,
+ filterable: true,
+ filterParam: 'api_credential_uuid',
+ filterLabel: this.intl.t('developers.webhooks.index.credential'),
+ filterComponent: 'filter/select',
+ filterOptionLabel: 'fullName',
+ filterOptionValue: 'id',
+ filterOptions: this.apiCredentials,
+ cellComponent: 'webhook/credential-cell',
+ },
+ {
+ id: 'success',
+ label: this.intl.t('developers.webhooks.index.success-7d'),
+ valuePath: 'id',
+ width: 90,
+ hidden: this.devViewport.below(1280),
+ sortable: false,
+ align: 'right',
+ cellComponent: 'webhook/success-cell',
+ cellComponentArgs: { healthFor: this.healthFor },
+ },
+ {
+ id: 'last-delivery',
+ label: this.intl.t('developers.webhooks.index.last-delivery'),
+ valuePath: 'id',
+ width: 130,
+ hidden: this.devViewport.below(1100),
+ sortable: false,
+ cellComponent: 'webhook/last-delivery-cell',
+ cellComponentArgs: { healthFor: this.healthFor },
+ },
+ {
+ id: 'version',
+ label: this.intl.t('developers.common.version'),
+ valuePath: 'version',
+ width: 90,
+ hidden: this.devViewport.below(1440),
+ sortable: false,
+ filterable: true,
+ filterLabel: this.intl.t('developers.common.version'),
+ filterComponent: 'filter/select',
+ filterOptions: this.apiVersions,
+ cellClassNames: 'dev-mono',
+ },
+ {
+ 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-webhook', label: this.intl.t('developers.webhooks.actions.view'), icon: 'paper-plane', fn: this.viewWebhook, permission: 'developers view webhook' },
+ {
+ id: 'test-webhook',
+ label: this.intl.t('developers.webhooks.actions.test'),
+ icon: 'vial',
+ fn: this.testWebhook,
+ disabled: true,
+ helpText: this.intl.t('developers.common.unavailable-later'),
+ },
+ { id: 'edit-webhook', label: this.intl.t('developers.webhooks.actions.edit'), icon: 'pencil', fn: this.editWebhook, permission: 'developers update webhook' },
+ { separator: true },
+ {
+ id: 'disable-webhook',
+ label: this.intl.t('developers.webhooks.actions.disable'),
+ icon: 'ban',
+ fn: this.disableWebhook,
+ permission: 'developers disable webhook',
+ isVisible: (webhook) => webhook.isEnabled,
+ },
+ {
+ id: 'enable-webhook',
+ label: this.intl.t('developers.webhooks.actions.enable'),
+ icon: 'check',
+ fn: this.enableWebhook,
+ permission: 'developers enable webhook',
+ isVisible: (webhook) => !webhook.isEnabled,
+ },
+ {
+ id: 'delete-webhook',
+ label: this.intl.t('developers.webhooks.actions.delete'),
+ icon: 'trash',
+ fn: this.deleteWebhook,
+ class: 'text-red-700 hover:text-red-800',
+ permission: 'developers delete webhook',
+ },
+ ],
+ },
+ ];
+ }
+
+ get emptyState() {
+ return {
+ icon: 'globe',
+ title: this.isSandbox ? this.intl.t('developers.webhooks.empty.title-test') : this.intl.t('developers.webhooks.empty.title-live'),
+ message: this.intl.t('developers.webhooks.empty.message'),
+ action: { label: this.intl.t('developers.webhooks.index.button-text'), icon: 'plus', onClick: this.createWebhook, permission: 'developers create webhook' },
+ };
+ }
+
+ @action healthFor(webhook) {
+ const item = this.healthById[webhook?.id] ?? null;
+
+ if (!item) {
+ return { health: webhook?.status === 'disabled' ? 'disabled' : null, badge: null };
+ }
+
+ return item;
+ }
+
+ badgesFor(webhook) {
+ const badges = [];
+
+ if (webhook.is_listening_on_all_events) {
+ badges.push({ key: 'all-events', label: this.intl.t('developers.webhooks.badges.all-events'), class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' });
+ }
+
+ badges.push(
+ webhook.mode === 'test'
+ ? { key: 'test', label: this.intl.t('developers.common.test'), class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }
+ : { key: 'live', label: this.intl.t('developers.common.live'), class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' }
+ );
+
+ return badges;
+ }
+
+ @task *loadHealth() {
+ try {
+ const data = yield this.developerMetrics.fetch.get('metrics/dev/endpoint-health', { period: '7d' });
+ const byId = {};
+
+ (data?.items ?? []).forEach((item) => {
+ const health = endpointHealth(item);
+ byId[item.id] = { ...item, health, badge: HEALTH_BADGE[health] };
+ });
+
+ this.healthById = byId;
+ } catch {
+ this.healthById = {};
+ }
+ }
- /**
- * The search task.
- *
- * @void
- */
@task({ restartable: true }) *search({ target: { value } }) {
- // if no query don't search
if (isBlank(value)) {
this.query = null;
return;
}
- // timeout for typing
yield timeout(250);
- // reset page for results
if (this.page > 1) {
this.page = 1;
}
- // update the query param
this.query = value;
}
@@ -217,21 +298,36 @@ export default class WebhooksIndexController extends BaseController {
return this.webhookActions.deleteWebhook(...args);
}
- /**
- * Transition to view the webhook
- *
- * @param {WebhookEndpointModel} webhook
- * @return {Transition}
- * @memberof WebhooksIndexController
- */
+ @action disableWebhook(...args) {
+ return this.webhookActions.disableWebhook(...args);
+ }
+
+ @action enableWebhook(...args) {
+ return this.webhookActions.enableWebhook(...args);
+ }
+
+ // TODO(v2): POST webhook-endpoints/{id}/test does not exist yet.
+ @action testWebhook() {
+ return null;
+ }
+
@action viewWebhook(webhook) {
- return this.hostRouter.transitionTo('console.developers.webhooks.view', webhook);
+ return this.webhookActions.viewWebhook(webhook);
}
- /**
- * Reload data.
- */
@action reload() {
+ this.loadHealth.perform();
return this.hostRouter.refresh();
}
+
+ /**
+ * Row clicks open the record unless the click landed on a control inside the row.
+ */
+ @action rowClick(row) {
+ if (isRowControlClick()) {
+ return;
+ }
+
+ return this.viewWebhook(row);
+ }
}
diff --git a/addon/controllers/webhooks/view.js b/addon/controllers/webhooks/view.js
index b4032c5..891877c 100644
--- a/addon/controllers/webhooks/view.js
+++ b/addon/controllers/webhooks/view.js
@@ -1,61 +1,97 @@
import BaseController from '../base-controller';
-import { inject as controller } from '@ember/controller';
import { inject as service } from '@ember/service';
-import { action, computed } from '@ember/object';
-import groupApiEvents from '@fleetbase/ember-core/utils/group-api-events';
-import fromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
-import fetchFrom from '@fleetbase/ember-core/decorators/legacy-fetch-from';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { endpointHealth, HEALTH_BADGE, HEALTH_TONE } from '../../components/widget/endpoint-health';
+import { RETENTION_DAYS } from '../../utils/retention';
+import { shortUrl, timeAgo } from '../../utils/format';
export default class WebhooksViewController extends BaseController {
- @controller('webhooks.index') webhooksIndexController;
- @service modalsManager;
+ @service webhookActions;
+ @service developerMetrics;
@service intl;
@service notifications;
- @service fetch;
- @service universe;
@service hostRouter;
@service abilities;
- /**
- * All webhook events
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
- @fetchFrom('webhook-endpoints/events') webhookEvents;
+ @tracked health = null;
+ @tracked activeTab = 'deliveries';
+ retentionDays = RETENTION_DAYS;
- /**
- * All api versions
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
- @fetchFrom('webhook-endpoints/versions') apiVersions;
+ get webhook() {
+ return this.model;
+ }
- /**
- * All api credentials.
- *
- * @memberof WebhooksIndexController
- * @var {Array}
- */
- @fromStore('api-credential', { limit: -1 }) apiCredentials;
+ get shortUrl() {
+ return shortUrl(this.webhook?.url);
+ }
- /**
- * Group api events by resource
- *
- * @var {Object}
- */
- @computed('webhookEvents.[]') get groupedApiEvents() {
- return groupApiEvents(this.webhookEvents);
+ get healthState() {
+ if (!this.webhook) {
+ return 'idle';
+ }
+
+ if (this.webhook.status === 'disabled') {
+ return 'disabled';
+ }
+
+ return this.health ? endpointHealth(this.health) : 'idle';
+ }
+
+ get healthBadge() {
+ return HEALTH_BADGE[this.healthState] ?? 'info';
+ }
+
+ get healthTone() {
+ return HEALTH_TONE[this.healthState] ?? 'is-gray';
+ }
+
+ get isFailing() {
+ return this.healthState === 'failing';
+ }
+
+ get lastDelivery() {
+ return timeAgo(this.health?.last_delivery_at);
+ }
+
+ get stats() {
+ const health = this.health ?? {};
+ const rate = health.success_rate === null || health.success_rate === undefined ? null : Number(health.success_rate);
+
+ return [
+ {
+ label: this.intl.t('developers.webhooks.view.stat-success'),
+ value: rate === null ? '—' : `${rate.toFixed(1)}%`,
+ hint: this.intl.t('developers.webhooks.view.stat-success-hint'),
+ tone: rate !== null && rate < 95 ? 'is-down' : '',
+ },
+ {
+ label: this.intl.t('developers.webhooks.view.stat-deliveries'),
+ value: Number(health.deliveries ?? 0).toLocaleString(),
+ hint: this.intl.t('developers.webhooks.view.stat-deliveries-hint', { failed: Number(health.failures ?? 0).toLocaleString() }),
+ },
+ {
+ label: this.intl.t('developers.webhooks.view.stat-duration'),
+ value: health.average_duration_ms ? `${Math.round(health.average_duration_ms)} ms` : '—',
+ hint: this.intl.t('developers.webhooks.view.stat-duration-hint'),
+ },
+ { label: this.intl.t('developers.webhooks.view.stat-last'), value: this.lastDelivery ?? '—', hint: this.intl.t('developers.webhooks.view.stat-last-hint', { days: 7 }) },
+ ];
+ }
+
+ get tabs() {
+ return [
+ { id: 'deliveries', label: this.intl.t('developers.webhooks.view.tab-deliveries'), icon: 'paper-plane' },
+ { id: 'details', label: this.intl.t('developers.webhooks.view.tab-details'), icon: 'circle-info' },
+ ];
}
/**
* Header buttons. Extensions add to them through `developers:webhook:details`.
- *
- * @var {Array}
*/
get actionButtons() {
- const webhook = this.model;
+ const webhook = this.webhook;
const toggle = webhook?.isEnabled
? {
id: 'disable',
@@ -66,81 +102,61 @@ export default class WebhooksViewController extends BaseController {
}
: {
id: 'enable',
- icon: 'ban',
+ type: 'primary',
+ icon: 'check',
text: this.intl.t('developers.webhooks.view.enable-button-text'),
onClick: () => this.enableWebhook(webhook),
permission: 'developers enable webhook',
};
return this.detailsActionButtons('webhook', webhook, [
- { id: 'refresh', icon: 'refresh', onClick: this.reload, helpText: this.intl.t('common.reload-data') },
+ // TODO(v2): POST webhook-endpoints/{id}/test does not exist yet.
+ { id: 'test', icon: 'vial', text: this.intl.t('developers.webhooks.view.send-button-text'), disabled: true, helpText: this.intl.t('developers.common.unavailable-later') },
+ { id: 'edit', icon: 'pencil', text: this.intl.t('developers.common.edit'), onClick: () => this.editWebhook(webhook), permission: 'developers update webhook' },
toggle,
{
- id: 'delete',
- icon: 'times',
- iconPrefix: 'fas',
- text: this.intl.t('developers.webhooks.view.delete-button-text'),
- onClick: () => this.deleteWebhook(webhook),
- permission: 'developers delete webhook',
+ id: 'more',
+ icon: 'ellipsis',
+ items: [
+ { id: 'refresh', icon: 'rotate', label: this.intl.t('developers.common.reload-data'), fn: this.reload },
+ { id: 'delete', icon: 'trash', label: this.intl.t('developers.webhooks.view.delete-button-text'), class: 'text-red-500', fn: () => this.deleteWebhook(webhook) },
+ ],
},
]);
}
- /**
- * Toggles dialog to delete webhook
- *
- * @void
- */
- @action deleteWebhook(webhook) {
- this.webhooksIndexController.deleteWebhook(webhook, {
- onConfirm: () => {
- this.notifications.success(this.intl.t('developers.webhooks.view.webhook-deleted-success-message'));
- return this.hostRouter.transitionTo('console.developers.webhooks.index');
- },
- });
+ @task({ restartable: true }) *loadHealth() {
+ if (!this.webhook?.id) {
+ return;
+ }
+
+ try {
+ const data = yield this.developerMetrics.fetch.get('metrics/dev/endpoint-health', { period: '7d' });
+ this.health = (data?.items ?? []).find((item) => item.id === this.webhook.id) ?? null;
+ } catch {
+ this.health = null;
+ }
}
- /**
- * Toggles dialog to disable webhook
- *
- * @void
- */
- @action disableWebhook(webhook) {
- this.modalsManager.confirm({
- title: this.intl.t('developers.webhooks.view.disable-webhook-title'),
- body: this.intl.t('developers.webhooks.view.disable-webhook-body'),
- acceptButtonText: this.intl.t('developers.webhooks.view.disable-webhook-button-text'),
+ @action setTab(tab) {
+ this.activeTab = tab.id;
+ }
+
+ @action editWebhook(webhook) {
+ return this.webhookActions.editWebhook(webhook, {
confirm: async (modal) => {
- modal.startLoading();
+ const urlError = this.webhookActions.validateUrl(webhook.url);
- try {
- await webhook.disable();
- this.notifications.success(this.intl.t('developers.webhooks.view.disable-webhook-success-message'));
- modal.done();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
+ if (urlError) {
+ modal.setOption('urlError', urlError);
+ return;
}
- },
- });
- }
- /**
- * Toggles dialog to enable webhook
- *
- * @void
- */
- @action enableWebhook(webhook) {
- this.modalsManager.confirm({
- title: this.intl.t('developers.webhooks.view.enable-webhook-title'),
- body: this.intl.t('developers.webhooks.view.enable-webhook-body'),
- acceptButtonText: this.intl.t('developers.webhooks.view.enable-webhook-button-text'),
- confirm: async (modal) => {
modal.startLoading();
try {
- await webhook.enable();
- this.notifications.success(this.intl.t('developers.webhooks.view.enable-webhook-success-message'));
+ await webhook.save();
+ this.notifications.success(this.intl.t('developers.webhooks.view.update-endpoint-success-message'));
modal.done();
} catch (error) {
this.notifications.serverError(error);
@@ -150,32 +166,26 @@ export default class WebhooksViewController extends BaseController {
});
}
- /**
- * Toggles modal to update webhook details
- *
- * @param {WebhookEndpointModel} webhook
- * @void
- */
- @action updateWebhookDetails(webhook) {
- this.webhooksIndexController.editWebhook(webhook, {
- acceptButtonText: this.intl.t('developers.webhooks.view.update-endpoint-button-text'),
- eventOptions: this.groupedApiEvents,
- versionOptions: this.apiVersions,
- apiCredentialOptions: this.apiCredentials,
- confirm: () => {
- return webhook.save().then(() => {
- this.notifications.success(this.intl.t('developers.webhooks.view.update-endpoint-success-message'));
- });
- },
+ @action deleteWebhook(webhook) {
+ return this.webhookActions.deleteWebhook(webhook, {
+ onDeleted: () => this.hostRouter.transitionTo('console.developers.webhooks.index'),
});
}
- /**
- * Reloads the view data.
- *
- * @memberof WebhooksViewController
- */
+ @action disableWebhook(webhook) {
+ return this.webhookActions.disableWebhook(webhook);
+ }
+
+ @action enableWebhook(webhook) {
+ return this.webhookActions.enableWebhook(webhook);
+ }
+
+ @action goBack() {
+ return this.hostRouter.transitionTo('console.developers.webhooks.index');
+ }
+
@action reload() {
- this.hostRouter.refresh();
+ this.loadHealth.perform();
+ return this.hostRouter.refresh();
}
}
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/credential-name.js b/addon/helpers/credential-name.js
new file mode 100644
index 0000000..da1690b
--- /dev/null
+++ b/addon/helpers/credential-name.js
@@ -0,0 +1,6 @@
+import { helper } from '@ember/component/helper';
+import credentialName from '../utils/credential-name';
+
+export default helper(function ([value]) {
+ return credentialName(value);
+});
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/models/api-request-log.js b/addon/models/api-request-log.js
index 45b40f0..4236a5b 100644
--- a/addon/models/api-request-log.js
+++ b/addon/models/api-request-log.js
@@ -36,6 +36,28 @@ export default class ApiRequestLogModel extends Model {
@attr('date') updated_at;
/** @computed */
+ get curl() {
+ const headers = this.request_headers && typeof this.request_headers === 'object' ? this.request_headers : {};
+ const lines = [`curl -X ${this.method ?? 'GET'} '${this.full_url ?? this.path ?? ''}'`];
+
+ Object.keys(headers)
+ .filter((name) => !['host', 'content-length', 'connection', 'accept-encoding'].includes(name.toLowerCase()))
+ .forEach((name) => {
+ const value = Array.isArray(headers[name]) ? headers[name].join(', ') : headers[name];
+ const masked = ['authorization', 'x-api-key', 'cookie'].includes(name.toLowerCase()) ? `${String(value).slice(0, 14)}••••` : value;
+ lines.push(` -H '${name}: ${String(masked).replace(/'/g, "'\\''")}'`);
+ });
+
+ const body = this.request_body ?? this.request_raw_body;
+
+ if (body && this.method && this.method !== 'GET') {
+ const raw = typeof body === 'string' ? body : JSON.stringify(body);
+ lines.push(` --data-raw '${raw.replace(/'/g, "'\\''")}'`);
+ }
+
+ return lines.join(' \\\n');
+ }
+
@computed('status_code', 'reason_phrase') get status() {
return `${this.status_code} ${this.reason_phrase}`;
}
diff --git a/addon/routes.js b/addon/routes.js
index 341cb09..01e6e04 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -1,5 +1,7 @@
import buildRoutes from 'ember-engines/routes';
+// Engine routes; the host mounts them under `console.developers`.
+
export default buildRoutes(function () {
this.route('home', { path: '/' }, function () {
this.route('index', { path: '/' });
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/routes/events/index.js b/addon/routes/events/index.js
index 50fb8a0..5f66ef2 100644
--- a/addon/routes/events/index.js
+++ b/addon/routes/events/index.js
@@ -29,10 +29,19 @@ export default class EventsIndexRoute extends Route {
event: {
refreshModel: true,
},
+ source: {
+ refreshModel: true,
+ },
+ api_credential_uuid: {
+ refreshModel: true,
+ },
+ method: {
+ refreshModel: true,
+ },
};
@action loading(transition) {
- this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: 'Loading events...' });
+ this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: this.intl.t('developers.events.index.loading') });
}
beforeModel() {
diff --git a/addon/routes/logs/index.js b/addon/routes/logs/index.js
index 597a09a..e7f49b8 100644
--- a/addon/routes/logs/index.js
+++ b/addon/routes/logs/index.js
@@ -35,10 +35,19 @@ export default class LogsIndexRoute extends Route {
created_at: {
refreshModel: true,
},
+ source: {
+ refreshModel: true,
+ },
+ status_code_gte: {
+ refreshModel: true,
+ },
+ status_code_lt: {
+ refreshModel: true,
+ },
};
@action loading(transition) {
- this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: 'Loading logs...' });
+ this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: this.intl.t('developers.logs.index.loading') });
}
beforeModel() {
@@ -51,4 +60,9 @@ export default class LogsIndexRoute extends Route {
model(params) {
return this.store.query('api-request-log', params);
}
+
+ setupController(controller) {
+ super.setupController(...arguments);
+ controller.loadCounts.perform();
+ }
}
diff --git a/addon/routes/sockets/index.js b/addon/routes/sockets/index.js
index 6d18afd..7c83321 100644
--- a/addon/routes/sockets/index.js
+++ b/addon/routes/sockets/index.js
@@ -10,7 +10,7 @@ export default class SocketsIndexRoute extends Route {
@service intl;
@action loading(transition) {
- this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: 'Loading websockets...' });
+ this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: this.intl.t('developers.sockets.index.loading') });
}
beforeModel() {
diff --git a/addon/routes/sockets/view.js b/addon/routes/sockets/view.js
index f439920..786d316 100644
--- a/addon/routes/sockets/view.js
+++ b/addon/routes/sockets/view.js
@@ -18,7 +18,8 @@ export default class SocketsViewRoute extends Route {
return { name };
}
- async setupController(controller, model) {
+ setupController(controller, model) {
+ super.setupController(...arguments);
controller.watchSocket(model);
}
}
diff --git a/addon/routes/webhooks/index.js b/addon/routes/webhooks/index.js
index a996501..6dbf7fa 100644
--- a/addon/routes/webhooks/index.js
+++ b/addon/routes/webhooks/index.js
@@ -20,10 +20,22 @@ export default class WebhooksIndexRoute extends Route {
sort: {
refreshModel: true,
},
+ query: {
+ refreshModel: true,
+ },
+ status: {
+ refreshModel: true,
+ },
+ api_credential_uuid: {
+ refreshModel: true,
+ },
+ version: {
+ refreshModel: true,
+ },
};
@action loading(transition) {
- this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: 'Loading webhooks...' });
+ this.loader.showOnInitialTransition(transition, 'section.next-view-section', { loadingMessage: this.intl.t('developers.webhooks.index.loading') });
}
beforeModel() {
diff --git a/addon/services/api-key-actions.js b/addon/services/api-key-actions.js
index 90f7cb6..c6cc706 100644
--- a/addon/services/api-key-actions.js
+++ b/addon/services/api-key-actions.js
@@ -1,8 +1,21 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+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 +25,11 @@ export default class ApiKeyActionsService extends ResourceActionService {
this.initialize('api-credential', { permissionPrefix: 'developers', mountPrefix: 'console.developers', registryResource: 'api-key' });
}
+ get developerPanels() {
+ // The panels service lives in this engine, so resolve it through the engine's own owner.
+ return getOwner(this).lookup('service:developer-panels') ?? null;
+ }
+
transition = {
list: () => this.transitionTo('api-keys.index'),
};
@@ -19,77 +37,373 @@ 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, '-')}`) }));
+ }
+
+ /**
+ * Opens the key detail drawer.
+ */
+ @action viewApiKey(apiKey) {
+ const panels = this.developerPanels;
+
+ if (panels) {
+ return panels.openApiKey(apiKey, { actionButtons: this.panelActionButtons(apiKey) });
+ }
+
+ return this.editApiKey(apiKey);
+ }
+
+ panelActionButtons(apiKey) {
+ return [
+ { id: 'edit', icon: 'pencil', size: 'xs', text: this.intl.t('developers.common.edit'), onClick: () => this.editApiKey(apiKey), permission: 'developers update api-key' },
+ { id: 'roll', icon: 'rotate', size: 'xs', text: this.intl.t('developers.api-keys.actions.roll'), onClick: () => this.rollApiKey(apiKey), permission: 'developers roll api-key' },
+ {
+ id: 'more',
+ icon: 'ellipsis',
+ size: 'xs',
+ 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) },
+ ],
+ },
+ ];
+ }
+
/**
- * Toggles modal to create a new API key
+ * Step 1: the form. Step 2: the credential shown once.
*
* @void
*/
+ /**
+ * Browser origins as chips: the modal options hold a tracked copy and write
+ * back to the record on every change.
+ */
+ originOptions(apiKey) {
+ const origins = Array.isArray(apiKey.browser_origins) ? [...apiKey.browser_origins] : [];
+
+ return {
+ origins,
+ addOrigin: (origin) => {
+ const value = String(origin ?? '').trim();
+
+ if (!value || this.modalsManager.getOption('origins').includes(value)) {
+ return false;
+ }
+
+ const next = [...this.modalsManager.getOption('origins'), value];
+ this.modalsManager.setOption('origins', next);
+ apiKey.browser_origins = next;
+ },
+ removeOrigin: (index) => {
+ const next = this.modalsManager.getOption('origins').filter((_, position) => position !== index);
+ this.modalsManager.setOption('origins', next);
+ apiKey.browser_origins = next;
+ },
+ onEnter: (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.modalsManager.confirm?.();
+ }
+ },
+ };
+ }
+
@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'),
+ titleComponent: 'modals/api-key-form-title',
+ modalClass: 'dev-modal modal-md',
+ 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,
+ ...this.originOptions(apiKey),
+ setExpiration: (value) => {
+ apiKey.expires_at = value || null;
+ },
+ 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 }),
+ titleComponent: 'modals/api-key-form-title',
+ modalClass: 'dev-modal modal-md',
+ 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,
+ ...this.originOptions(apiKey),
+ setExpiration: (value) => {
+ apiKey.expires_at = value || null;
+ },
+ 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);
+ const firstDependent = dependents.webhooks[0]?.url ?? '';
+
+ return this.modalsManager.show('modals/api-key-delete', {
+ title: this.intl.t('developers.api-keys.delete.title', { apiKeyName }),
+ modalClass: 'dev-modal modal-md',
+ modalHeaderTitleClass: 'dev-modal-title-danger',
+ acceptButtonText: this.intl.t('developers.api-keys.delete.accept'),
+ acceptButtonScheme: 'danger',
+ acceptButtonIcon: 'trash',
+ declineButtonIcon: 'times',
+ declineButtonIconPrefix: 'fas',
+ apiKey,
+ dependents,
+ firstDependent,
+ setExpiration: () => {
+ this.modalsManager.done();
+ return this.editApiKey(apiKey);
+ },
+ 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 +413,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/api-key-insights.js b/addon/services/api-key-insights.js
new file mode 100644
index 0000000..75662f9
--- /dev/null
+++ b/addon/services/api-key-insights.js
@@ -0,0 +1,138 @@
+import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+import { htmlSafe } from '@ember/template';
+import { next } from '@ember/runloop';
+import { RETENTION_DAYS } from '../utils/retention';
+
+const SAMPLE = 200;
+const HOURS = RETENTION_DAYS * 24;
+
+/**
+ * Per-key usage read from the existing request log and webhook endpoints,
+ * shared by the key drawer tabs so each key is fetched once per session.
+ */
+class KeyInsights {
+ @tracked total = null;
+ @tracked logs = [];
+ @tracked errors = 0;
+ @tracked avgMs = null;
+ @tracked p95Ms = null;
+ @tracked bars = [];
+ @tracked webhooks = [];
+ @tracked webhookTotal = 0;
+ @tracked error = null;
+ @tracked loadedAt = null;
+
+ get errorRate() {
+ const sampled = this.logs.length;
+
+ return sampled ? Math.round((this.errors / sampled) * 1000) / 10 : 0;
+ }
+
+ get isLoaded() {
+ return this.loadedAt !== null;
+ }
+}
+
+export default class ApiKeyInsightsService extends Service {
+ @service fetch;
+
+ #byKey = new Map();
+ #requested = new Set();
+
+ /**
+ * The insights object for a key, created empty on first use.
+ */
+ stateFor(apiKey) {
+ const id = typeof apiKey === 'string' ? apiKey : apiKey?.id;
+
+ if (!this.#byKey.has(id)) {
+ this.#byKey.set(id, new KeyInsights());
+ }
+
+ return this.#byKey.get(id);
+ }
+
+ /**
+ * Loads once per key; call `refresh` to force.
+ */
+ ensure(apiKey) {
+ const state = this.stateFor(apiKey);
+ const id = typeof apiKey === 'string' ? apiKey : apiKey?.id;
+
+ // Tabs call this while rendering, so the fetch starts on the next run
+ // loop and never updates a tracked value inside the same computation.
+ if (!state.isLoaded && !this.#requested.has(id)) {
+ this.#requested.add(id);
+ next(this, () => this.load.perform(apiKey));
+ }
+
+ return state;
+ }
+
+ refresh(apiKey) {
+ return this.load.perform(apiKey);
+ }
+
+ @task *load(apiKey) {
+ const state = this.stateFor(apiKey);
+ state.error = null;
+
+ try {
+ const [logsResponse, webhooksResponse] = yield Promise.all([
+ this.fetch.get('api-request-logs', { key: apiKey.id, limit: SAMPLE, sort: '-created_at' }),
+ this.fetch.get('webhook-endpoints', { api_credential_uuid: apiKey.id, limit: 25 }),
+ ]);
+
+ const logs = logsResponse?.api_request_logs ?? [];
+ const durations = logs.map((log) => Number(log.duration ?? 0) * 1000).filter((ms) => Number.isFinite(ms));
+
+ state.logs = logs;
+ state.total = logsResponse?.meta?.total ?? logs.length;
+ state.errors = logs.filter((log) => Number(log.status_code) >= 400).length;
+ state.avgMs = durations.length ? Math.round(durations.reduce((sum, ms) => sum + ms, 0) / durations.length) : null;
+ state.p95Ms = durations.length ? Math.round([...durations].sort((a, b) => a - b)[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))]) : null;
+ state.bars = this.bucketByHour(logs);
+ state.webhooks = webhooksResponse?.webhook_endpoints ?? [];
+ state.webhookTotal = webhooksResponse?.meta?.total ?? state.webhooks.length;
+ } catch (error) {
+ state.error = error?.message ?? String(error);
+ }
+
+ state.loadedAt = new Date();
+
+ return state;
+ }
+
+ /**
+ * Requests per hour over the retention window, newest bucket last.
+ */
+ bucketByHour(logs) {
+ const now = Date.now();
+ const buckets = Array.from({ length: HOURS }, (_, index) => ({ offset: HOURS - 1 - index, count: 0, errors: 0 }));
+
+ logs.forEach((log) => {
+ const age = Math.floor((now - new Date(log.created_at).getTime()) / 3600000);
+
+ if (age < 0 || age >= HOURS) {
+ return;
+ }
+
+ const bucket = buckets[HOURS - 1 - age];
+ bucket.count += 1;
+
+ if (Number(log.status_code) >= 400) {
+ bucket.errors += 1;
+ }
+ });
+
+ const max = Math.max(1, ...buckets.map((bucket) => bucket.count));
+
+ return buckets.map((bucket) => {
+ const height = Math.round((bucket.count / max) * 100);
+
+ return { ...bucket, height, style: htmlSafe(`height: ${height}%`), isError: bucket.errors > 0 && bucket.errors >= bucket.count / 2 };
+ });
+ }
+}
diff --git a/addon/services/dev-viewport.js b/addon/services/dev-viewport.js
new file mode 100644
index 0000000..658900c
--- /dev/null
+++ b/addon/services/dev-viewport.js
@@ -0,0 +1,44 @@
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { debounce, cancel } from '@ember/runloop';
+
+/**
+ * Tracks the window width so list controllers can hide low-priority columns
+ * on narrower screens through the ember-ui `column.hidden` flag, which keeps
+ * the fixed-layout tables from squishing or overflowing.
+ */
+export default class DevViewportService extends Service {
+ @tracked width = typeof window !== 'undefined' ? window.innerWidth : 1440;
+
+ constructor() {
+ super(...arguments);
+
+ if (typeof window !== 'undefined') {
+ this._onResize = () => {
+ this._timer = debounce(this, this.measure, 120);
+ };
+ window.addEventListener('resize', this._onResize);
+ }
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+
+ if (typeof window !== 'undefined' && this._onResize) {
+ window.removeEventListener('resize', this._onResize);
+ }
+
+ cancel(this._timer);
+ }
+
+ measure() {
+ this.width = window.innerWidth;
+ }
+
+ /**
+ * True when the viewport is narrower than `px`.
+ */
+ below(px) {
+ return this.width < px;
+ }
+}
diff --git a/addon/services/developer-metrics.js b/addon/services/developer-metrics.js
new file mode 100644
index 0000000..6ff28e6
--- /dev/null
+++ b/addon/services/developer-metrics.js
@@ -0,0 +1,109 @@
+import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { next } from '@ember/runloop';
+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();
+ }
+
+ /**
+ * Widgets call this while rendering; the fetch starts on the next run loop
+ * so nothing read during render is updated inside the same computation.
+ */
+ ensureKpis() {
+ if (this.kpis || this.#kpisRequested === this.period) {
+ return;
+ }
+
+ this.#kpisRequested = this.period;
+ next(this, () => this.loadKpis.perform());
+ }
+
+ #kpisRequested = null;
+
+ /**
+ * 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..31d95fb
--- /dev/null
+++ b/addon/services/developer-panels.js
@@ -0,0 +1,113 @@
+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: false,
+ 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),
+ header: 'api-key/drawer-header',
+ width: '640px',
+ dismissible: false,
+ tabWrapperClass: 'dev-drawer-tabs',
+ tabStyle: 'github',
+ tabSize: 'sm',
+ tabs: [
+ { key: 'overview', label: this.intl.t('developers.common.overview'), component: 'api-key/tabs/overview' },
+ { key: 'requests', label: this.intl.t('developers.application.sidebar.items.requests'), component: 'api-key/tabs/requests' },
+ { key: 'webhooks', label: this.intl.t('developers.application.sidebar.items.webhooks'), component: 'api-key/tabs/webhooks' },
+ { key: 'channel', label: this.intl.t('developers.api-keys.drawer.channel'), component: 'api-key/tabs/channel' },
+ { key: 'activity', label: this.intl.t('developers.activity.title'), component: 'api-key/tabs/activity' },
+ ],
+ 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/services/socket-channels.js b/addon/services/socket-channels.js
new file mode 100644
index 0000000..23ee108
--- /dev/null
+++ b/addon/services/socket-channels.js
@@ -0,0 +1,119 @@
+import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { isArray } from '@ember/array';
+
+const OPTION_KEY = 'developerChannels';
+
+/**
+ * Socket channels the console knows about: the organization channel, one per
+ * API key, and custom channels the user asked to watch (remembered in user
+ * options). Also keeps the last state seen for each channel in this browser
+ * session: access decision, last message, message count, whether a viewer is
+ * subscribed.
+ *
+ * TODO(v2): last activity and subscriber counts from the socket server.
+ */
+export default class SocketChannelsService extends Service {
+ @service currentUser;
+ @service intl;
+
+ /** `{ [name]: { access, reason, lastActivityAt, lastEvent, messageCount, watching } }` */
+ @tracked state = {};
+
+ get customChannels() {
+ const names = this.currentUser.getOption(OPTION_KEY);
+
+ return isArray(names) ? names.filter((name) => typeof name === 'string' && name.trim() !== '') : [];
+ }
+
+ @action rememberChannel(name) {
+ const names = new Set(this.customChannels);
+ names.add(name.trim());
+ this.currentUser.setOption(OPTION_KEY, Array.from(names));
+ }
+
+ @action forgetChannel(name) {
+ this.currentUser.setOption(
+ OPTION_KEY,
+ this.customChannels.filter((candidate) => candidate !== name)
+ );
+ const next = { ...this.state };
+ delete next[name];
+ this.state = next;
+ }
+
+ stateFor(name) {
+ return this.state[name] ?? {};
+ }
+
+ @action update(name, patch) {
+ this.state = { ...this.state, [name]: { ...(this.state[name] ?? {}), ...patch } };
+ }
+
+ @action recordMessage(name, event) {
+ const current = this.state[name] ?? {};
+
+ this.update(name, {
+ lastActivityAt: new Date(),
+ lastEvent: event ?? current.lastEvent ?? null,
+ messageCount: (current.messageCount ?? 0) + 1,
+ });
+ }
+
+ /**
+ * The type of a channel from its prefix.
+ */
+ typeOf(name) {
+ const prefix = String(name ?? '').split('.')[0];
+
+ if (prefix === 'company') {
+ return 'company';
+ }
+
+ if (prefix === 'api') {
+ return 'api';
+ }
+
+ return 'custom';
+ }
+
+ /**
+ * Validates the shape the socket server enforces: non-empty, at most 255
+ * characters, no whitespace.
+ *
+ * @returns {String|null} an error message or null
+ */
+ validateName(name) {
+ const value = String(name ?? '').trim();
+
+ if (value === '') {
+ return this.intl.t('developers.sockets.listen.error-empty');
+ }
+
+ if (value.length > 255) {
+ return this.intl.t('developers.sockets.listen.error-length');
+ }
+
+ if (/\s/.test(value)) {
+ return this.intl.t('developers.sockets.listen.error-whitespace');
+ }
+
+ return null;
+ }
+
+ /**
+ * The known channel prefixes and who may subscribe, from the core and
+ * FleetOps channel resolvers.
+ */
+ get prefixRules() {
+ return [
+ { prefix: 'company.', description: this.intl.t('developers.sockets.rules.company'), access: 'you' },
+ { prefix: 'api.', description: this.intl.t('developers.sockets.rules.api'), access: 'you' },
+ { prefix: 'user.', description: this.intl.t('developers.sockets.rules.user'), access: 'you' },
+ { prefix: 'driver.', description: this.intl.t('developers.sockets.rules.driver'), access: 'you' },
+ { prefix: 'chat_channel.', description: this.intl.t('developers.sockets.rules.chat'), access: 'participants' },
+ { prefix: 'test.', description: this.intl.t('developers.sockets.rules.test'), access: 'admins' },
+ ];
+ }
+}
diff --git a/addon/services/webhook-actions.js b/addon/services/webhook-actions.js
index 6e61639..6d30a64 100644
--- a/addon/services/webhook-actions.js
+++ b/addon/services/webhook-actions.js
@@ -1,6 +1,10 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
+import { isBlank } from '@ember/utils';
import groupApiEvents from '@fleetbase/ember-core/utils/group-api-events';
+import { shortUrl } from '../utils/format';
+
+const PRIVATE_HOSTS = /^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|\[::1\])/i;
/**
* Webhook endpoint actions shared by the Developers console and other engines (for example Fleetbase AI).
@@ -13,24 +17,57 @@ export default class WebhookActionsService extends ResourceActionService {
transition = {
list: () => this.transitionTo('webhooks.index'),
+ view: (webhook) => this.viewWebhook(webhook),
};
modal = {
create: (...args) => this.createWebhook(...args),
edit: (...args) => this.editWebhook(...args),
+ delete: (...args) => this.deleteWebhook(...args),
+ enable: (...args) => this.enableWebhook(...args),
+ disable: (...args) => this.disableWebhook(...args),
};
/**
* Loads the event, version, and credential options the webhook form needs.
*/
async loadWebhookOptions() {
- const [webhookEvents, apiVersions, apiCredentials] = await Promise.all([
- this.fetch.get('webhook-endpoints/events'),
- this.fetch.get('webhook-endpoints/versions'),
- this.store.query('api-credential', { limit: -1 }),
- ]);
+ const [webhookEvents, apiVersions] = await Promise.all([this.fetch.get('webhook-endpoints/events'), this.fetch.get('webhook-endpoints/versions')]);
- return { webhookEvents, groupedApiEvents: groupApiEvents(webhookEvents), apiVersions, apiCredentials };
+ return { webhookEvents, groupedApiEvents: groupApiEvents(webhookEvents), apiVersions };
+ }
+
+ /**
+ * Mirrors the backend `PublicWebhookUrl` rule so the user learns before submitting.
+ *
+ * @returns {String|null} an error message, or null when valid
+ */
+ validateUrl(value) {
+ if (isBlank(value)) {
+ return this.intl.t('developers.webhooks.form.url-required');
+ }
+
+ let url;
+
+ try {
+ url = new URL(value);
+ } catch {
+ return this.intl.t('developers.webhooks.form.url-invalid');
+ }
+
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ return this.intl.t('developers.webhooks.form.url-invalid');
+ }
+
+ if (PRIVATE_HOSTS.test(url.hostname) || !url.hostname.includes('.')) {
+ return this.intl.t('developers.webhooks.form.url-private');
+ }
+
+ return null;
+ }
+
+ @action viewWebhook(webhook) {
+ return this.hostRouter.transitionTo('console.developers.webhooks.view', webhook);
}
/**
@@ -38,38 +75,23 @@ export default class WebhookActionsService extends ResourceActionService {
*
* @void
*/
- @action createWebhook() {
+ @action createWebhook(options = {}) {
const formPermission = 'developers create webhook';
const webhook = this.store.createRecord('webhook-endpoint', {
events: [],
mode: this.currentUser.getOption('sandbox') ? 'test' : 'live',
});
- this.editWebhook(webhook, {
+ return this.editWebhook(webhook, {
title: this.intl.t('developers.webhooks.index.add-webhook'),
- acceptButtonText: this.intl.t('developers.webhooks.index.add-webhook-button-text'),
+ acceptButtonText: this.intl.t('developers.webhooks.form.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.webhooks.index.new-webhook-success-message'),
formPermission,
webhook,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await webhook.save();
- this.notifications.success(this.intl.t('developers.webhooks.index.new-webhook-success-message'));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
+ ...options,
});
}
@@ -81,77 +103,87 @@ export default class WebhookActionsService extends ResourceActionService {
* @void
*/
@action async editWebhook(webhook, options = {}) {
- const { webhookEvents, groupedApiEvents, apiVersions, apiCredentials } = await this.loadWebhookOptions();
+ const { webhookEvents, apiVersions } = await this.loadWebhookOptions();
+ const formPermission = options.formPermission ?? 'developers update webhook';
+ const readOnly = this.abilities.cannot(formPermission);
+ const selectedCredential = webhook.api_credential_uuid
+ ? (this.store.peekRecord('api-credential', webhook.api_credential_uuid) ?? (await this.store.findRecord('api-credential', webhook.api_credential_uuid).catch(() => null)))
+ : null;
- const formPermission = 'developers update webhook';
- this.modalsManager.show('modals/webhook-form', {
- title: this.intl.t('developers.webhooks.index.edit-webhook-endpoint'),
+ if (!webhook.version && apiVersions?.length) {
+ webhook.version = apiVersions[apiVersions.length - 1];
+ }
+
+ return this.modalsManager.show('modals/webhook-form', {
+ title: this.intl.t('developers.webhooks.form.edit-title', { endpoint: shortUrl(webhook.url) ?? '' }),
+ modalClass: 'dev-modal modal-lg',
acceptButtonText: this.intl.t('developers.webhooks.index.edit-webhook-endpoint-button-text'),
acceptButtonIcon: 'save',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
+ acceptButtonDisabled: readOnly,
declineButtonIcon: 'times',
declineButtonIconPrefix: 'fas',
- eventOptions: groupedApiEvents,
+ successMessage: this.intl.t('developers.webhooks.view.update-endpoint-success-message'),
+ formPermission,
+ readOnly,
+ webhookEvents,
versionOptions: apiVersions,
- apiCredentialOptions: apiCredentials,
+ selectedCredential,
+ urlError: null,
webhook,
- setVersion: ({ target }) => {
- webhook.version = target.value || null;
- },
- setApiCredential: ({ target }) => {
- webhook.api_credential_uuid = target.value || null;
+ validateUrl: (event) => {
+ const value = event?.target?.value ?? webhook.url;
+ this.modalsManager.setOption('urlError', isBlank(value) ? null : this.validateUrl(value));
},
- searchEvents: (query) => {
- if (typeof query !== 'string') {
- return;
- }
- const resources = Object.keys(groupedApiEvents);
- const filteredEvents = {};
- resources.forEach((eventResource) => {
- filteredEvents[eventResource] = groupedApiEvents[eventResource].filter((event) => {
- return event.toLowerCase().includes(query.toLowerCase());
- });
- // if 0 events remove from filter
- if (filteredEvents[eventResource].length === 0) {
- delete filteredEvents[eventResource];
- }
- });
- this.modalsManager.setOption('eventOptions', filteredEvents);
+ setVersion: (value) => {
+ webhook.version = value || null;
},
- addEvent: (event) => {
- if (webhook.events.includes(event)) {
- return;
- }
-
- webhook.events.pushObject(event);
+ setApiCredential: (credential) => {
+ this.modalsManager.setOption('selectedCredential', credential);
+ webhook.api_credential_uuid = credential?.id ?? null;
},
- removeEvent: (event) => {
- webhook.events.removeObject(event);
- },
- clearEvents: () => {
- webhook.events.clear();
- },
- receiveAllEvents: () => {
- webhook.events.pushObjects(webhookEvents);
+ setEvents: (events) => {
+ webhook.events = events;
},
confirm: async (modal) => {
- modal.startLoading();
-
if (this.abilities.cannot(formPermission)) {
return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
}
+ const urlError = this.validateUrl(webhook.url);
+
+ if (urlError) {
+ modal.setOption('urlError', urlError);
+ return;
+ }
+
+ modal.startLoading();
+
try {
await webhook.save();
- this.notifications.success(this.intl.t('developers.webhooks.index.new-webhook-success-message'));
+ this.notifications.success(modal.getOption('successMessage'));
+ modal.done();
return this.hostRouter.refresh();
} catch (error) {
- this.notifications.serverError(error);
+ const message = error?.errors?.[0]?.detail ?? error?.errors?.[0] ?? error?.message;
+
+ if (typeof message === 'string' && /url/i.test(message)) {
+ modal.setOption('urlError', message);
+ } else {
+ this.notifications.serverError(error);
+ }
+
modal.stopLoading();
}
},
+ decline: (modal) => {
+ if (webhook.isNew) {
+ webhook.unloadRecord();
+ } else if (webhook.hasDirtyAttributes) {
+ webhook.rollbackAttributes();
+ }
+
+ modal.done();
+ },
...options,
});
}
@@ -164,21 +196,92 @@ export default class WebhookActionsService extends ResourceActionService {
* @void
*/
@action deleteWebhook(webhook, options = {}) {
- this.modalsManager.confirm({
- title: this.intl.t('developers.webhooks.index.delete-webhook-endpoint'),
- body: this.intl.t('developers.webhooks.index.delete-webhook-endpoint-body'),
+ return this.modalsManager.confirm({
+ title: this.intl.t('developers.webhooks.delete.title'),
+ body: this.intl.t('developers.webhooks.delete.body', { endpoint: shortUrl(webhook.url) ?? '' }),
+ acceptButtonText: this.intl.t('developers.webhooks.delete.accept'),
+ acceptButtonScheme: 'danger',
+ acceptButtonIcon: 'trash',
+ actionButtons: webhook.isEnabled
+ ? [
+ {
+ id: 'disable-instead',
+ text: this.intl.t('developers.webhooks.delete.disable-instead'),
+ icon: 'ban',
+ onClick: async () => {
+ await this.modalsManager.done();
+ this.disableWebhook(webhook);
+ },
+ },
+ ]
+ : [],
confirm: async (modal) => {
modal.startLoading();
try {
await webhook.destroyRecord();
this.notifications.success(this.intl.t('developers.webhooks.index.delete-webhook-success-message'));
+ modal.done();
+
+ if (typeof options.onDeleted === 'function') {
+ return options.onDeleted(webhook);
+ }
+
return this.hostRouter.refresh();
} catch (error) {
this.notifications.serverError(error);
modal.stopLoading();
}
},
+ });
+ }
+
+ /**
+ * Disable confirm: states what stops and that nothing is queued.
+ */
+ @action disableWebhook(webhook, options = {}) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('developers.webhooks.disable.title'),
+ body: this.intl.t('developers.webhooks.disable.body', { endpoint: shortUrl(webhook.url) ?? '' }),
+ acceptButtonText: this.intl.t('developers.webhooks.disable.accept'),
+ acceptButtonIcon: 'ban',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await webhook.disable();
+ this.notifications.success(this.intl.t('developers.webhooks.view.disable-webhook-success-message'));
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ ...options,
+ });
+ }
+
+ /**
+ * Enable confirm: the URL is re-validated by the backend.
+ */
+ @action enableWebhook(webhook, options = {}) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('developers.webhooks.enable.title'),
+ body: this.intl.t('developers.webhooks.enable.body', { endpoint: shortUrl(webhook.url) ?? '' }),
+ acceptButtonText: this.intl.t('developers.webhooks.enable.accept'),
+ acceptButtonIcon: 'check',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await webhook.enable();
+ this.notifications.success(this.intl.t('developers.webhooks.view.enable-webhook-success-message'));
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
...options,
});
}
diff --git a/addon/styles/dev-engine.css b/addon/styles/dev-engine.css
index 4a66599..62d09d7 100644
--- a/addon/styles/dev-engine.css
+++ b/addon/styles/dev-engine.css
@@ -370,3 +370,2947 @@ 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.
+ * Dark-theme overrides follow their light rule, so descending specificity is expected here.
+ */
+/* stylelint-disable no-descending-specificity */
+
+.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: fixed layout, explicit widths, the identity column is the only flexible one */
+.dev-table-compact table {
+ width: 100%;
+ table-layout: fixed;
+}
+
+.dev-table-compact td,
+.dev-table-compact th {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-table-compact td.overflow-visible {
+ overflow: visible;
+}
+
+.dev-table-compact .table-cell-identity {
+ max-width: none;
+}
+
+.dev-table-compact .dev-cell-text {
+ display: flex;
+ min-width: 0;
+ max-width: 100%;
+ align-items: center;
+ gap: 6px;
+}
+
+.dev-table-compact .dev-cell-text > * {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-table-compact .dev-cell-text > .status-badge,
+.dev-table-compact .dev-cell-text > .dev-identity-icon {
+ flex-shrink: 0;
+}
+
+/* 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: 8px;
+}
+
+/* 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;
+ color: #111827;
+ 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 */
+/* stylelint-disable-next-line selector-class-pattern */
+.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;
+}
+
+/* stylelint-disable-next-line selector-class-pattern */
+.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: 8px;
+}
+
+.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;
+}
+
+.dev-identity-icon-sm {
+ width: 22px;
+ height: 22px;
+ flex-basis: 22px;
+ font-size: 10px;
+}
+
+.dev-identity-title.is-regular {
+ font-weight: 500;
+}
+
+/* Event picker */
+.dev-event-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.dev-event-picker-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.dev-event-picker-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
+ overflow: hidden;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ background-color: #fff;
+ color: #111827;
+}
+
+body[data-theme='dark'] .dev-event-picker-body {
+ border-color: #4b5563;
+ background-color: #111827;
+ color: #e5e7eb;
+}
+
+.dev-event-picker-body label,
+.dev-event-picker-body button {
+ margin: 0;
+ color: inherit;
+}
+
+@media (width <= 640px) {
+ .dev-event-picker-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.dev-event-picker-list {
+ display: flex;
+ max-height: 300px;
+ flex-direction: column;
+ border-right: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .dev-event-picker-list {
+ border-right-color: #374151;
+}
+
+.dev-event-picker-search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .dev-event-picker-search {
+ border-bottom-color: #374151;
+}
+
+.dev-event-picker-input {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font-size: 12px;
+ outline: none;
+}
+
+.dev-event-picker-scroll {
+ overflow-y: auto;
+ font-size: 12px;
+}
+
+.dev-event-group-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background-color: #f9fafb;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+body[data-theme='dark'] .dev-event-group-head {
+ background-color: #1f2937;
+}
+
+.dev-event-group-name {
+ text-transform: capitalize;
+}
+
+.dev-event-group-count {
+ margin-left: auto;
+ color: #6b7280;
+ font-weight: 400;
+}
+
+.dev-event-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 10px 4px 28px;
+ cursor: pointer;
+}
+
+.dev-event-row:hover {
+ background-color: #f3f4f6;
+}
+
+body[data-theme='dark'] .dev-event-row:hover {
+ background-color: #1f2937;
+}
+
+.dev-event-picker-empty {
+ padding: 16px;
+ color: #6b7280;
+ text-align: center;
+}
+
+.dev-event-picker-selected {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+}
+
+.dev-event-picker-selected-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+}
+
+.dev-event-picker-hint {
+ font-size: 11px;
+}
+
+.dev-tag-remove {
+ display: inline-flex;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #6b7280;
+ cursor: pointer;
+}
+
+.dev-tag-remove:hover {
+ color: #dc2626;
+}
+
+.dev-event-picker.is-disabled {
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+/* Modal forms: steps keep a gap below, array inputs stay compact until they have rows */
+/* stylelint-disable-next-line selector-class-pattern */
+.flb--modal .dev-steps {
+ margin-bottom: 12px;
+}
+
+.dev-modal-body .array-input .h-48 {
+ height: auto;
+ min-height: 40px;
+ max-height: 192px;
+}
+
+.dev-modal-body .array-input .mb-4 {
+ margin-bottom: 8px;
+}
+
+/* Detail pages: tabs, tables, grids */
+.dev-tabs {
+ padding: 0 4px;
+}
+
+.dev-details-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
+ gap: 16px;
+ padding-top: 16px;
+}
+
+.dev-filter-bar-inset {
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .dev-filter-bar-inset {
+ border-bottom-color: #374151;
+ background-color: #111827;
+}
+
+.dev-filter-inline {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.dev-filter-date {
+ width: 200px;
+}
+
+.dev-filter-select {
+ width: 180px;
+}
+
+.dev-filter-search {
+ width: 220px;
+}
+
+.dev-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: auto;
+ font-size: 13px;
+}
+
+.dev-table th {
+ padding: 8px 12px;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+ color: #6b7280;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-align: left;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .dev-table th {
+ border-bottom-color: #374151;
+ background-color: #111827;
+ color: #9ca3af;
+}
+
+.dev-table td {
+ height: 40px;
+ padding: 6px 12px;
+ border-bottom: 1px solid #f3f4f6;
+ color: #111827;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .dev-table td {
+ border-bottom-color: #374151;
+ color: #f3f4f6;
+}
+
+.dev-table-row {
+ cursor: pointer;
+}
+
+.dev-table-row:hover td {
+ background-color: #f8fafc;
+}
+
+body[data-theme='dark'] .dev-table-row:hover td {
+ background-color: #1b2535;
+}
+
+.dev-table-row.is-expanded td {
+ border-bottom-color: transparent;
+}
+
+.dev-table-expand {
+ width: 28px;
+ padding-right: 0;
+}
+
+.dev-table-actions {
+ width: 40px;
+ text-align: right;
+}
+
+.dev-table-subrow td {
+ height: auto;
+ padding: 4px 12px 12px 44px;
+ background-color: #f9fafb;
+ white-space: normal;
+}
+
+body[data-theme='dark'] .dev-table-subrow td {
+ background-color: #111827;
+}
+
+.dev-timeline-compact {
+ padding: 0;
+}
+
+.dev-timeline-compact .dev-timeline-item {
+ align-items: flex-start;
+ cursor: pointer;
+}
+
+.dev-timeline-compact .dev-timeline-item:hover .dev-timeline-title {
+ color: #1c6cc7;
+}
+
+.dev-timeline-action {
+ margin-left: auto;
+ align-self: center;
+}
+
+.dev-timeline-foot {
+ display: flex;
+ gap: 8px;
+ padding: 4px 0 0 22px;
+ list-style: none;
+}
+
+.dev-pagination {
+ padding: 8px 12px;
+ border-top: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .dev-pagination {
+ border-top-color: #374151;
+}
+
+/* Channels */
+.dev-mono-identity .table-cell-identity [data-test-identity-label] {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 12px;
+}
+
+.dev-channel-header .dev-resource-header-title {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.dev-log {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 12px;
+}
+
+.dev-log-head,
+.dev-log-row {
+ display: grid;
+ grid-template-columns: 110px 190px minmax(0, 1fr) 72px;
+ gap: 0 12px;
+ align-items: start;
+ padding: 8px 14px;
+ border-bottom: 1px solid #f3f4f6;
+}
+
+body[data-theme='dark'] .dev-log-head,
+body[data-theme='dark'] .dev-log-row {
+ border-bottom-color: #374151;
+}
+
+.dev-log-head {
+ color: #6b7280;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.dev-log-row {
+ cursor: pointer;
+}
+
+.dev-log-row:hover {
+ background-color: #f8fafc;
+}
+
+body[data-theme='dark'] .dev-log-row:hover {
+ background-color: #1b2535;
+}
+
+.dev-log-row.is-lifecycle,
+.dev-log-row.is-error {
+ color: #6b7280;
+}
+
+.dev-log-row.is-error {
+ color: #b91c1c;
+}
+
+body[data-theme='dark'] .dev-log-row.is-error {
+ color: #fca5a5;
+}
+
+.dev-log-row.is-expanded {
+ background-color: #f8fafc;
+}
+
+body[data-theme='dark'] .dev-log-row.is-expanded {
+ background-color: #1b2535;
+}
+
+.dev-log-time {
+ color: #6b7280;
+ white-space: nowrap;
+}
+
+.dev-log-body {
+ min-width: 0;
+}
+
+.dev-log-preview,
+.dev-log-text {
+ display: block;
+ overflow: hidden;
+ color: #374151;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+body[data-theme='dark'] .dev-log-preview,
+body[data-theme='dark'] .dev-log-text {
+ color: #d1d5db;
+}
+
+.dev-log-row.is-error .dev-log-text {
+ color: inherit;
+}
+
+.dev-log-meta {
+ color: #6b7280;
+}
+
+.dev-log-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.dev-log-foot {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ color: #6b7280;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
+ font-size: 12px;
+}
+
+.dev-log-foot-end {
+ margin-left: auto;
+ white-space: nowrap;
+}
+
+@keyframes dev-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+.dev-pulse {
+ animation: dev-pulse 1.6s ease-in-out infinite;
+}
+
+@media (width <= 768px) {
+ .dev-log-head,
+ .dev-log-row {
+ grid-template-columns: 80px minmax(0, 1fr) 60px;
+ }
+
+ .dev-log-head > span:nth-child(2),
+ .dev-log-row > .dev-log-event {
+ display: none;
+ }
+}
+
+/* Events */
+.dev-identity-badge-plain {
+ max-width: 24rem !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ color: #6b7280 !important;
+ font-size: 11px !important;
+}
+
+body[data-theme='dark'] .dev-identity-badge-plain {
+ color: #9ca3af !important;
+}
+
+.dev-panel-identity-actions {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 4px;
+ margin-left: auto;
+}
+
+.dev-tag-button {
+ border: 0;
+ cursor: pointer;
+}
+
+.dev-tag-button:hover {
+ color: #1c6cc7;
+}
+
+.dev-page-narrow {
+ max-width: 1100px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+/* Requests */
+.dev-request-cell .dev-method {
+ flex: 0 0 auto;
+}
+
+.dev-kv-facts {
+ padding: 12px 14px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .dev-kv-facts {
+ border-color: #374151;
+ background-color: #111827;
+}
+
+/* Mobile */
+@media (width <= 768px) {
+ .next-content-overlay-panel:has(.dev-panel-content) {
+ width: 100vw !important;
+ max-width: 100vw !important;
+ }
+
+ .dev-subheader-controls {
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-left: 0;
+ }
+
+ .dev-env-hint,
+ .dev-period-range {
+ display: none;
+ }
+
+ .dev-filter-bar {
+ padding: 8px 12px;
+ }
+
+ .dev-filter-date,
+ .dev-filter-select,
+ .dev-filter-search {
+ width: 100%;
+ }
+
+ .dev-page {
+ padding: 12px;
+ gap: 12px;
+ }
+
+ .dev-resource-header {
+ padding: 12px;
+ }
+
+ .dev-resource-header-actions {
+ width: 100%;
+ margin-left: 0;
+ flex-wrap: wrap;
+ }
+
+ .dev-kv,
+ .dev-kv.is-compact {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 2px 0;
+ }
+
+ .dev-kv dt {
+ margin-top: 8px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ /* stylelint-disable-next-line selector-class-pattern */
+ .flb--default-modal.dev-modal .modal-dialog {
+ max-width: 100vw;
+ margin: 0;
+ }
+
+ /* stylelint-disable-next-line selector-class-pattern */
+ .flb--default-modal.dev-modal .modal-content {
+ min-height: 100vh;
+ border-radius: 0;
+ }
+
+ .dev-table-subrow td {
+ padding-left: 12px;
+ }
+
+ .dev-stats {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .dev-onboarding .dev-card-body {
+ padding: 14px;
+ }
+}
+
+/* Tables never squish: wrapper scrolls, cells keep their width */
+.dev-table-compact.next-table-wrapper {
+ overflow-x: auto;
+}
+
+/* Header controls never wrap their text */
+.next-view-section-subheader .btn,
+.next-view-section-subheader .btn span,
+.dev-filter-bar .btn,
+.dev-card-actions .btn,
+.dev-resource-header-actions .btn {
+ white-space: nowrap;
+}
+
+.next-view-section-subheader-actions {
+ flex-wrap: nowrap;
+}
+
+@media (width <= 1500px) {
+ .dev-env-hint {
+ display: none;
+ }
+}
+
+.developers-kpi-delta .status-badge-inner-wrap {
+ white-space: nowrap;
+}
+
+@media (width <= 1100px) {
+ .dev-list-header .next-view-section-subheader-actions .form-input.w-64 {
+ width: 9rem;
+ }
+}
+
+/* Phone widths: the dashboard title stays on one line next to the period selector */
+@media (width <= 640px) {
+ .developers-dashboard-title h1 {
+ max-width: 38vw;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+}
+
+/* Phone widths: the sidebar search covers lookups, so the list header drops its own input */
+@media (width <= 640px) {
+ .dev-list-header .next-view-section-subheader-actions .form-input.w-64 {
+ display: none;
+ }
+}
+
+/* Phone widths: drawer identity rows wrap their actions underneath, code toolbars wrap */
+@media (width <= 640px) {
+ .dev-panel-identity {
+ flex-wrap: wrap;
+ }
+
+ .dev-panel-identity .dev-identity-text {
+ min-width: 0;
+ flex: 1 1 12rem;
+ }
+
+ .dev-panel-identity-actions {
+ width: 100%;
+ justify-content: flex-start;
+ margin-top: 8px;
+ margin-left: 0;
+ }
+
+ .dev-code-toolbar,
+ .dev-code-actions {
+ flex-wrap: wrap;
+ row-gap: 6px;
+ }
+}
+
+/* Prefix-rule tables inside dialogs read in both themes */
+body[data-theme='dark'] .dev-widget-table {
+ color: #e5e7eb;
+}
+
+/* Drawer: custom header, tabs and body on the mockup's 12/16 rhythm */
+.dev-drawer-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .dev-drawer-header {
+ border-bottom-color: #374151;
+ background-color: #1f2937;
+}
+
+.dev-drawer-identity {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.dev-identity-icon-lg {
+ width: 36px;
+ height: 36px;
+ flex-basis: 36px;
+ border-radius: 8px;
+ font-size: 14px;
+}
+
+.dev-drawer-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 15px;
+}
+
+.dev-drawer-title .dev-truncate {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-drawer-actions {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 6px;
+}
+
+.dev-drawer-actions .btn-wrapper {
+ box-shadow: none;
+}
+
+.dev-drawer-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 16px;
+}
+
+.dev-drawer-tabs .resource-context-panel-tablist {
+ padding: 0 12px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .dev-drawer-tabs .resource-context-panel-tablist {
+ border-bottom-color: #374151;
+ background-color: #1f2937;
+}
+
+.dev-drawer-tabs .tab-navigation[data-style='github'] .tab-item {
+ padding: 8px 10px;
+ font-size: 12px;
+}
+
+.dev-stats-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.dev-bars {
+ display: flex;
+ height: 64px;
+ align-items: flex-end;
+ gap: 2px;
+}
+
+.dev-bars i {
+ display: block;
+ min-height: 2px;
+ flex: 1;
+ border-radius: 2px 2px 0 0;
+ background-color: #3485e2;
+ opacity: 0.85;
+}
+
+.dev-bars i.is-error {
+ background-color: #ef4444;
+}
+
+.dev-linked-table td {
+ padding: 8px 16px;
+}
+
+.dev-request-table th {
+ padding: 6px 12px;
+}
+
+.dev-request-table td {
+ padding: 6px 12px;
+}
+
+.dev-cell-ellipsis {
+ max-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-text-danger {
+ color: #dc2626;
+ font-weight: 600;
+}
+
+body[data-theme='dark'] .dev-text-danger {
+ color: #fca5a5;
+}
+
+/* Header actions: one 8px rhythm, hidden wormholes and dropdown placeholders take no space */
+.dev-list-header .next-view-section-subheader-actions > * {
+ margin-left: 0 !important;
+}
+
+.dev-list-header .next-view-section-subheader-actions > #next-view-section-subheader-wormhole-after-search,
+.dev-list-header .next-view-section-subheader-actions > .ember-basic-dropdown-content-placeholder {
+ display: none;
+}
+
+.dev-list-header .dev-env-switch-wrapper {
+ margin: 0;
+}
+
+.dev-timeline-text {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 12px;
+}
+
+.dev-timeline-text b {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+/* Dialogs: 560px, 14/16 header, 16 body with 14 gaps, 12/16 footer on the panel2 tone */
+/* stylelint-disable selector-class-pattern */
+.flb--modal.dev-modal > .flb--modal-dialog > .flb--modal-content {
+ width: 560px;
+ max-width: calc(100vw - 32px);
+ border-radius: 10px;
+}
+
+.flb--modal.dev-modal.modal-lg > .flb--modal-dialog > .flb--modal-content {
+ width: 720px;
+}
+
+.flb--modal.dev-modal .flb--modal-header {
+ align-items: center;
+ padding: 14px 16px;
+}
+
+.flb--modal.dev-modal .flb--modal-title {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 12px;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.flb--modal.dev-modal .dev-modal-title-text {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.flb--modal.dev-modal .dev-modal-title-danger .dev-modal-title-text,
+.flb--modal.dev-modal .flb--modal-title.dev-modal-title-danger,
+body[data-theme='dark'] .flb--modal.dev-modal .flb--modal-title.dev-modal-title-danger {
+ color: #dc2626;
+}
+
+body[data-theme='dark'] .flb--modal.dev-modal .flb--modal-title.dev-modal-title-danger {
+ color: #fca5a5;
+}
+
+.flb--modal.dev-modal .dev-modal-steps {
+ margin: 0;
+}
+
+.flb--modal.dev-modal .flb--modal-body {
+ padding: 16px;
+}
+
+.flb--modal.dev-modal .flb--modal-footer {
+ padding: 12px 16px;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .flb--modal.dev-modal .flb--modal-footer {
+ background-color: #1b2535;
+}
+/* stylelint-enable selector-class-pattern */
+
+.dev-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.dev-ig {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.dev-ig > label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ color: #111827;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+body[data-theme='dark'] .dev-ig > label {
+ color: #f3f4f6;
+}
+
+.dev-req {
+ color: #dc2626;
+}
+
+.dev-help {
+ color: #6b7280;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+body[data-theme='dark'] .dev-help {
+ color: #9ca3af;
+}
+
+.dev-input,
+.dev-form .form-input,
+.dev-form .ember-power-select-trigger {
+ min-height: 36px;
+ font-size: 13px;
+}
+
+.dev-segmented-static .dev-segment {
+ cursor: default;
+}
+
+/* stylelint-disable selector-class-pattern */
+.dev-tag-input.emberTagInput {
+ min-height: 36px;
+ height: auto;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 4px 6px;
+}
+
+.dev-tag-input .emberTagInput-tag {
+ display: inline-flex;
+ height: 24px;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ border-radius: 5px;
+ background-color: #e5e7eb;
+ color: #374151;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 12px;
+}
+
+body[data-theme='dark'] .dev-tag-input .emberTagInput-tag {
+ background-color: #374151;
+ color: #e5e7eb;
+}
+
+.dev-tag-input .emberTagInput-new > input {
+ min-width: 160px;
+ border: 0;
+ background: transparent;
+ font-size: 12px;
+ outline: none;
+}
+/* stylelint-enable selector-class-pattern */
+
+.dev-code-box {
+ display: block;
+ padding: 12px 14px;
+ border-radius: 6px;
+ background-color: #0b1220;
+ color: #e2e8f0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 13px;
+ line-height: 18px;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.dev-reveal-row-center {
+ align-items: center;
+}
+
+.dev-reveal-secret {
+ flex: 1;
+}
+
+.dev-tag-lg {
+ height: 32px;
+ font-size: 13px;
+}
+
+/* Requests: histogram strip, saved views, row tints by status class */
+.dev-histogram {
+ display: flex;
+ height: 36px;
+ align-items: flex-end;
+ gap: 2px;
+ padding: 0 2px;
+}
+
+.dev-histogram i {
+ display: block;
+ min-height: 2px;
+ flex: 1;
+ border-radius: 2px 2px 0 0;
+ background-color: #3485e2;
+ opacity: 0.85;
+}
+
+.dev-histogram i.is-error {
+ background-color: #ef4444;
+}
+
+.dev-view-link {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #2563eb;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+body[data-theme='dark'] .dev-view-link {
+ color: #8dbbef;
+}
+
+.dev-request-rows tr:has(.dev-status-warning) td {
+ background-color: rgb(245 158 11 / 5%);
+}
+
+.dev-request-rows tr:has(.dev-status-danger) td {
+ background-color: rgb(220 38 38 / 5%);
+}
+
+.dev-list-header-wide-search .next-view-section-subheader-actions .form-input.w-64 {
+ width: 300px;
+}
+
+@media (width <= 1280px) {
+ .dev-list-header-wide-search .next-view-section-subheader-actions .form-input.w-64 {
+ width: 14rem;
+ }
+}
+
+/* Request detail: resource header, facts strip, tabbed bodies, error view, cURL preview */
+.dev-request-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.dev-reshead {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .dev-reshead {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+.dev-reshead-icon {
+ display: flex;
+ width: 48px;
+ height: 48px;
+ flex: 0 0 48px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ background-color: #dbeafe;
+ color: #1d4ed8;
+ font-size: 18px;
+}
+
+.dev-reshead-icon.is-success {
+ background-color: #d1fae5;
+ color: #047857;
+}
+
+.dev-reshead-icon.is-warning {
+ background-color: #fef3c7;
+ color: #b45309;
+}
+
+.dev-reshead-icon.is-danger {
+ background-color: #fee2e2;
+ color: #b91c1c;
+}
+
+body[data-theme='dark'] .dev-reshead-icon {
+ background-color: #1e3a8a;
+ color: #93c5fd;
+}
+
+body[data-theme='dark'] .dev-reshead-icon.is-success {
+ background-color: #064e3b;
+ color: #6ee7b7;
+}
+
+body[data-theme='dark'] .dev-reshead-icon.is-warning {
+ background-color: #78350f;
+ color: #fcd34d;
+}
+
+body[data-theme='dark'] .dev-reshead-icon.is-danger {
+ background-color: #7f1d1d;
+ color: #fca5a5;
+}
+
+.dev-reshead-main {
+ min-width: 0;
+ flex: 1;
+}
+
+.dev-reshead-title {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.dev-reshead-path {
+ overflow: hidden;
+ font-size: 15px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-reshead-sub {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+ color: #6b7280;
+ font-size: 12px;
+}
+
+body[data-theme='dark'] .dev-reshead-sub {
+ color: #9ca3af;
+}
+
+.dev-reshead-actions {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8px;
+}
+
+.dev-stats-4 {
+ grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
+}
+
+.dev-stat-mono {
+ overflow: hidden;
+ font-size: 12px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-request-tablist {
+ padding: 0 8px;
+}
+
+.dev-request-tab-body {
+ padding: 12px;
+}
+
+.dev-request-error-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
+ gap: 16px;
+}
+
+.dev-headers-table td {
+ padding: 6px 12px;
+ font-size: 12px;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.dev-headers-table td:first-child {
+ width: 180px;
+}
+
+.dev-codebar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border: 1px solid #e5e7eb;
+ border-bottom: 0;
+ border-radius: 6px 6px 0 0;
+ background-color: #f9fafb;
+ color: #6b7280;
+ font-size: 11px;
+}
+
+body[data-theme='dark'] .dev-codebar {
+ border-color: #374151;
+ background-color: #1b2535;
+ color: #9ca3af;
+}
+
+.dev-codebar-actions {
+ display: flex;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.dev-code-pre {
+ margin: 0;
+ padding: 12px 14px;
+ border-radius: 0 0 6px 6px;
+ background-color: #0b1220;
+ color: #e2e8f0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 12px;
+ line-height: 18px;
+ overflow: auto;
+ white-space: pre;
+}
+
+.dev-code-viewer-flush .dev-code-toolbar {
+ border-radius: 0;
+}
+
+.dev-muted-title {
+ color: #6b7280;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.dev-header-summary {
+ font-size: 12px;
+}
+
+@media (width <= 1180px) {
+ .dev-header-summary {
+ display: none;
+ }
+}
+
+.dev-request-table td:first-child,
+.dev-request-table th:first-child {
+ width: 100%;
+}
+
+.dev-request-table td:not(:first-child),
+.dev-request-table th:not(:first-child) {
+ white-space: nowrap;
+}
+
+.dev-card-heading .dev-card-title {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.dev-card-heading .dev-card-subtitle {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dev-list-header .next-view-section-subheader-actions > .ember-basic-dropdown-trigger:has(> .hidden) {
+ display: none;
+}
diff --git a/addon/templates/api-keys/index.hbs b/addon/templates/api-keys/index.hbs
index 0099311..ee632bd 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,28 @@
@actionButtons={{this.actionButtons}}
@bulkActions={{this.bulkActions}}
@controller={{this}}
+ @filterPickerButtonClass="hidden"
+ @headerClass="dev-list-header"
>
<:header>
-
-
- {{#if this.isTestMode}}
-
-
-
- {{t "developers.api-keys.index.select-key"}}
-
-
- {{t "developers.api-keys.index.test-data"}}
-
- {{#each (filter-by "isTestKey" @model) as |key|}}
-
- {{or key.name key.key}}
-
- {{/each}}
-
-
- {{/if}}
-
+
+ <:default as |rows|>
+
+
+
diff --git a/addon/templates/events/index.hbs b/addon/templates/events/index.hbs
index fbe75c1..64f7832 100644
--- a/addon/templates/events/index.hbs
+++ b/addon/templates/events/index.hbs
@@ -7,11 +7,39 @@
@onReload={{this.reload}}
@data={{@model}}
@columns={{this.columns}}
- @selectable={{false}}
- @canSelectAll={{false}}
- @onRowClick={{this.onRowClick}}
@page={{this.page}}
@onPageChange={{fn (mut this.page)}}
@setupTable={{fn (mut this.table)}}
@controller={{this}}
-/>
+ @filterPickerButtonClass="hidden"
+ @headerClass="dev-list-header"
+>
+ <:header>
+
+
+ <:default as |rows|>
+
+ {{t "developers.common.retention" days=this.retentionDays}}
+
+
+
+
diff --git a/addon/templates/events/view.hbs b/addon/templates/events/view.hbs
index f77ff1c..bbd9ace 100644
--- a/addon/templates/events/view.hbs
+++ b/addon/templates/events/view.hbs
@@ -1,13 +1,17 @@
-
-
+
+
+
-