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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
> v0.2.17 ~ "Extensions can add columns, actions and buttons to developer pages"
> v0.2.18 ~ "The sockets viewer shows channel authorization failures"

---
## Highlights

- **Resource view registries.** Extensions can add the following for `api-key`, `webhook`, `event`, `log` and `socket`:
- columns, row actions, bulk actions and toolbar buttons, through `developers:<resource>:table:<slot>`;
- header buttons and menu items on the detail pages, through `developers:<resource>:details:<slot>`.
- **API keys, webhooks, events, logs and sockets use the standard table layout.**
- **Fix: search on the events and logs pages was always disabled.**
- **Channel authorization failures are visible.** The sockets viewer logs `subscribeFail` and `kickOut` with the server's reason, for example when a channel is not authorized, instead of waiting indefinitely. The custom-channel dialog opens the same view, so it is covered too. ([#51](https://github.com/fleetbase/dev-engine/pull/51))
- **Fix: socket event payloads render as text.** Published payloads were rendered as raw HTML in the sockets viewer; they are now escaped.

---
## Upgrading
Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5.
Pairs with fleetbase/ember-core v0.3.26, which authenticates the console socket. No configuration changes.

---
## Need help?
Expand Down
99 changes: 74 additions & 25 deletions addon/controllers/sockets/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,54 @@ export default class SocketsViewController extends BaseController {
return window.history.back();
}

/**
* 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;

/**
* 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;
}

/**
* Appends a line to the console output.
*
* @param {String} content
* @param {String} color tailwind color name
* @memberof SocketsViewController
*/
logEvent(content, color) {
// Reassigned rather than mutated so the tracked property invalidates.
this.events = [...this.events, { time: format(new Date(), this.consoleDateFormat), content, color }];
}

/**
* 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') {
return this.intl.t('developers.sockets.view.socket-subscribe-denied', { modelName: channelName, reason: error.reason || error.message });
}

return this.intl.t('developers.sockets.view.socket-subscribe-failed', { modelName: channelName, message: error ? error.message : '' });
}

/**
* Opens socket and logs all incoming events.
*
Expand All @@ -56,59 +104,60 @@ export default class SocketsViewController extends BaseController {
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of socket.listener('error')) {
// Push an event or notification for socket connection here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-connection-error'),
color: 'red',
});
this.logEvent(this.intl.t('developers.sockets.view.socket-connection-error'), 'red');
}
})();

// Listen for socket connection
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of socket.listener('connect')) {
// Push an event or notification for socket connection here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-connected'),
color: 'green',
});
this.logEvent(this.intl.t('developers.sockets.view.socket-connected'), 'green');
}
})();

// Listed on company channel
// Subscribe to the channel
const channel = socket.subscribe(model.name);

// Listen for channel subscription
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of channel.listener('subscribe')) {
// Push an event or notification for channel subscription here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }),
color: 'blue',
});
this.subscriptionFailed = false;
this.logEvent(this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }), 'blue');
}
})();

// Listen for channel subscription
// 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 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.
(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');
}
})();

// Listen for channel data
(async () => {
for await (let data of channel) {
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: JSON.stringify(data, undefined, 2),
color: 'green',
});
this.logEvent(JSON.stringify(data, undefined, 2), 'green');
}
})();

// disconnect when transitioning
this.hostRouter.on('routeWillChange', () => {
channel.close();
this.events = [];
this.subscriptionFailed = false;
});
}
}
6 changes: 2 additions & 4 deletions addon/templates/sockets/view.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="block w-1/2 font-mono font-semibold text-green-300">
{{t "developers.sockets.view.output"}}
</div>
{{#if this.events.length}}
{{#if this.isAwaitingEvents}}
<div class="flex items-center justify-end w-1/2 text-sm text-green-300">
<Spinner @iconClass="green-loader" />

Expand All @@ -37,9 +37,7 @@
<div class="text-{{event.color}}-300 uppercase text-sm font-mono pr-2 flex-shrink-0">
[{{event.time}}]:
</div>
<div class="text-{{event.color}}-300 text-sm font-mono">
{{html-safe event.content}}
</div>
<div class="text-{{event.color}}-300 text-sm font-mono whitespace-pre-wrap break-all">{{event.content}}</div>
</div>
{{else}}
<div class="flex items-center mt-6 text-green-300">
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/dev-engine",
"version": "0.2.17",
"version": "0.2.18",
"description": "Fleetbase Developers extension provides a module for managing developer resources such as API keys, webhooks, sockets, events and logs.",
"fleetbase": {
"route": "developers"
Expand Down
189 changes: 186 additions & 3 deletions tests/unit/controllers/sockets/view-test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,195 @@
import { module, test } from 'qunit';
import { setupTest } from 'dummy/tests/helpers';

class IntlStub {
t(key, params) {
return params ? `${key} ${JSON.stringify(params)}` : key;
}
}

class HostRouterStub {
handlers = {};

on(eventName, handler) {
this.handlers[eventName] = handler;
}
}

class EmptyServiceStub {}

// An async-iterable event stream a test can push into.
function eventStream() {
const queue = [];
const waiting = [];

return {
push(value) {
if (waiting.length) {
waiting.shift()({ done: false, value });
} else {
queue.push(value);
}
},
close() {
while (waiting.length) {
waiting.shift()({ done: true, value: undefined });
}
},
[Symbol.asyncIterator]() {
return {
next: () => (queue.length ? Promise.resolve({ done: false, value: queue.shift() }) : new Promise((resolve) => waiting.push(resolve))),
};
},
};
}

function fakeChannel() {
const listeners = {};
const data = eventStream();

return {
closed: false,
listener(eventName) {
listeners[eventName] = listeners[eventName] || eventStream();
return listeners[eventName];
},
emit(eventName, value) {
this.listener(eventName).push(value);
},
publish(value) {
data.push(value);
},
close() {
this.closed = true;
Object.values(listeners).forEach((stream) => stream.close());
data.close();
},
[Symbol.asyncIterator]() {
return data[Symbol.asyncIterator]();
},
};
}

// Stands in for the ember-core socket service: `instance()` returns a client
// whose subscribe hands back the test's channel.
class SocketStub {
channel = null;
subscribed = [];
clientListeners = {};

instance() {
return {
listener: (eventName) => {
this.clientListeners[eventName] = this.clientListeners[eventName] || eventStream();
return this.clientListeners[eventName];
},
subscribe: (name) => {
this.subscribed.push(name);
return this.channel;
},
};
}
}

function authError(reason) {
const error = new Error(`Subscription to channel denied: ${reason}`);
error.name = 'AuthError';
error.reason = reason;
return error;
}

const flush = () => new Promise((resolve) => setTimeout(resolve, 0));

module('Unit | Controller | sockets/view', function (hooks) {
setupTest(hooks);

// TODO: Replace this with your real tests.
hooks.beforeEach(function () {
this.owner.register('service:intl', IntlStub);
this.owner.register('service:host-router', HostRouterStub);
this.owner.register('service:socket', SocketStub);
this.owner.register('service:universe', EmptyServiceStub);

this.controller = this.owner.lookup('controller:sockets/view');
this.channel = fakeChannel();
this.socket = this.owner.lookup('service:socket');
this.socket.channel = this.channel;
});

test('it exists', function (assert) {
let controller = this.owner.lookup('controller:sockets/view');
assert.ok(controller);
assert.ok(this.controller);
});

test('an authorization refusal is shown with its reason', function (assert) {
const message = this.controller.describeSubscribeFailure('order.abc', authError('forbidden'));

assert.true(message.startsWith('developers.sockets.view.socket-subscribe-denied'));
assert.true(message.includes('"reason":"forbidden"'));
});

test('an authorization refusal without a reason falls back to the message', function (assert) {
const error = authError(undefined);
const message = this.controller.describeSubscribeFailure('order.abc', error);

assert.true(message.includes(JSON.stringify(error.message).slice(1, -1)));
});

test('any other refusal is shown with its message', function (assert) {
const message = this.controller.describeSubscribeFailure('order.abc', new Error('Socket hung up'));

assert.true(message.startsWith('developers.sockets.view.socket-subscribe-failed'));
assert.true(message.includes('Socket hung up'));
assert.true(this.controller.describeSubscribeFailure('order.abc', null).startsWith('developers.sockets.view.socket-subscribe-failed'));
});

test('a refused subscription is logged and stops the awaiting indicator', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });
assert.deepEqual(this.socket.subscribed, ['order.abc']);

this.channel.emit('subscribeFail', { error: authError('forbidden') });
await flush();

assert.true(this.controller.subscriptionFailed);
assert.false(this.controller.isAwaitingEvents);
assert.strictEqual(this.controller.events.length, 1);
assert.strictEqual(this.controller.events[0].color, 'red');
assert.true(this.controller.events[0].content.startsWith('developers.sockets.view.socket-subscribe-denied'));
});

test('a kick-out is logged and a later resubscribe clears the failure', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });

this.channel.emit('subscribeFail', { error: authError('token_expired') });
this.channel.emit('kickOut', { message: 'token_expired' });
await flush();
this.channel.emit('subscribe', {});
await flush();

assert.false(this.controller.subscriptionFailed);
assert.true(this.controller.isAwaitingEvents);
assert.deepEqual(
this.controller.events.map((event) => event.content.split(' ')[0]),
['developers.sockets.view.socket-subscribe-denied', 'developers.sockets.view.socket-kicked-out', 'developers.sockets.view.socket-subscribed']
);
});

test('channel data is logged as JSON', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });

this.channel.publish({ event: 'order.updated' });
await flush();

assert.strictEqual(this.controller.events[0].content, JSON.stringify({ event: 'order.updated' }, undefined, 2));
});

test('leaving the page closes the channel and resets the output', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });
this.channel.emit('subscribeFail', { error: authError('forbidden') });
await flush();

this.owner.lookup('service:host-router').handlers.routeWillChange();

assert.true(this.channel.closed);
assert.deepEqual(this.controller.events, []);
assert.false(this.controller.subscriptionFailed);
});
});
3 changes: 3 additions & 0 deletions translations/ar-ae.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ developers:
socket-connection-error: خطأ في اتصال المقبس!
socket-connected: المقبس متصل
socket-subscribed: المقبس مشترك في القناة {modelName}
socket-subscribe-denied: غير مصرح بالاشتراك في القناة {modelName} ({reason})
socket-subscribe-failed: "تعذر الاشتراك في القناة {modelName}: {message}"
socket-kicked-out: تمت الإزالة من القناة {modelName} ({reason})
output: الإخراج
awaiting-events: في انتظار الأحداث...
webhooks:
Expand Down
Loading
Loading