feat: update devtools almost real time using signals

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2023-12-05 15:35:36 +02:00
parent db84649e81
commit 59a4508efe
28 changed files with 114 additions and 84 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-devtools-backend': patch
'@backstage/plugin-devtools': patch
---
Update devtools information almost real time using signals plugin
+7
View File
@@ -73,6 +73,7 @@ import { DefaultEventBroker } from '@backstage/plugin-events-backend';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { metrics } from '@opentelemetry/api';
import { SignalService } from '@backstage/plugin-signals-node';
// Expose opentelemetry metrics using a Prometheus exporter on
// http://localhost:9464/metrics . See prometheus.yml in packages/backend for
@@ -99,6 +100,11 @@ function makeCreateEnv(config: Config) {
});
const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' }));
const signalService = SignalService.create({
logger: root,
eventBroker,
identity,
});
root.info(`Created UrlReader ${reader}`);
@@ -120,6 +126,7 @@ function makeCreateEnv(config: Config) {
permissions,
scheduler,
identity,
signalService,
};
};
}
+1
View File
@@ -25,5 +25,6 @@ export default async function createPlugin(
logger: env.logger,
config: env.config,
permissions: env.permissions,
signalService: env.signalService,
});
}
+1 -13
View File
@@ -15,25 +15,13 @@
*/
import { Router } from 'express';
import { createRouter } from '@backstage/plugin-signals-backend';
import { SignalsService } from '@backstage/plugin-signals-node';
import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const service = SignalsService.create({
logger: env.logger,
identity: env.identity,
eventBroker: env.eventBroker,
});
setInterval(() => {
console.log('publishing');
service.publish('*', 'devtools:info', { now: new Date().toISOString() });
}, 5000);
return await createRouter({
logger: env.logger,
service,
service: env.signalService,
});
}
+2
View File
@@ -27,6 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { EventBroker } from '@backstage/plugin-events-node';
import { SignalService } from '@backstage/plugin-signals-node';
export type PluginEnvironment = {
logger: Logger;
@@ -40,4 +41,5 @@ export type PluginEnvironment = {
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
signalService: SignalService;
};
+3
View File
@@ -11,6 +11,7 @@ import express from 'express';
import { ExternalDependency } from '@backstage/plugin-devtools-common';
import { Logger } from 'winston';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { SignalService } from '@backstage/plugin-signals-node';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -40,5 +41,7 @@ export interface RouterOptions {
logger: Logger;
// (undocumented)
permissions: PermissionEvaluator;
// (undocumented)
signalService?: SignalService;
}
```
+1
View File
@@ -38,6 +38,7 @@
"@backstage/plugin-devtools-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-signals-node": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@types/express": "*",
+13 -1
View File
@@ -33,6 +33,7 @@ import { errorHandler } from '@backstage/backend-common';
import express from 'express';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import { SignalService } from '@backstage/plugin-signals-node';
/** @public */
export interface RouterOptions {
@@ -40,17 +41,28 @@ export interface RouterOptions {
logger: Logger;
config: Config;
permissions: PermissionEvaluator;
signalService?: SignalService;
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, config, permissions } = options;
const { logger, config, permissions, signalService } = options;
const devToolsBackendApi =
options.devToolsBackendApi || new DevToolsBackendApi(logger, config);
if (signalService) {
// Publish info periodically using the signal service
setInterval(async () => {
if (signalService.hasSubscribers('devtools:info')) {
const info = await devToolsBackendApi.listInfo();
await signalService.publish('*', 'devtools:info', info);
}
}, 5000);
}
const router = Router();
router.use(express.json());
router.use(
@@ -27,10 +27,9 @@ import {
makeStyles,
Paper,
Theme,
Typography,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useInfo } from '../../../hooks';
import { InfoDependenciesTable } from './InfoDependenciesTable';
import DescriptionIcon from '@material-ui/icons/Description';
@@ -39,7 +38,7 @@ import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard';
import { BackstageLogoIcon } from './BackstageLogoIcon';
import FileCopyIcon from '@material-ui/icons/FileCopy';
import { DevToolsInfo } from '@backstage/plugin-devtools-common';
import { useSignalsApi } from '@backstage/plugin-signals-react';
import { useSignalApi } from '@backstage/plugin-signals-react';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -74,14 +73,18 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => {
/** @public */
export const InfoContent = () => {
const classes = useStyles();
const [info, setInfo] = useState<DevToolsInfo | undefined>(undefined);
const { about, loading, error } = useInfo();
// Just testing for signals
const [messages, setMessages] = React.useState<string[]>([]);
useSignalsApi('devtools:info', message => {
messages.push(JSON.stringify(message));
setMessages([...messages]);
useSignalApi('devtools:info', message => {
setInfo(message as DevToolsInfo);
});
useEffect(() => {
if (!loading && !error && about) {
setInfo(about);
}
}, [about, loading, error]);
if (loading) {
return <Progress />;
} else if (error) {
@@ -89,11 +92,6 @@ export const InfoContent = () => {
}
return (
<Box>
<Paper>
{messages.map((msg, i) => {
return <Typography key={i}>{msg}</Typography>;
})}
</Paper>
<Paper className={classes.paperStyle}>
<List className={classes.flexContainer}>
<ListItem>
@@ -104,7 +102,7 @@ export const InfoContent = () => {
</ListItemAvatar>
<ListItemText
primary="Operating System"
secondary={about?.operatingSystem}
secondary={info?.operatingSystem}
/>
</ListItem>
<ListItem>
@@ -115,7 +113,7 @@ export const InfoContent = () => {
</ListItemAvatar>
<ListItemText
primary="Resource utilization"
secondary={about?.resourceUtilization}
secondary={info?.resourceUtilization}
/>
</ListItem>
<ListItem>
@@ -126,7 +124,7 @@ export const InfoContent = () => {
</ListItemAvatar>
<ListItemText
primary="NodeJS Version"
secondary={about?.nodeJsVersion}
secondary={info?.nodeJsVersion}
/>
</ListItem>
<ListItem>
@@ -137,14 +135,14 @@ export const InfoContent = () => {
</ListItemAvatar>
<ListItemText
primary="Backstage Version"
secondary={about?.backstageVersion}
secondary={info?.backstageVersion}
/>
</ListItem>
<Divider orientation="vertical" variant="middle" flexItem />
<ListItem
button
onClick={() => {
copyToClipboard({ about });
copyToClipboard({ about: info });
}}
className={classes.copyButton}
>
@@ -157,7 +155,7 @@ export const InfoContent = () => {
</ListItem>
</List>
</Paper>
<InfoDependenciesTable infoDependencies={about?.dependencies} />
<InfoDependenciesTable infoDependencies={info?.dependencies} />
</Box>
);
};
+2 -2
View File
@@ -5,7 +5,7 @@
```ts
import express from 'express';
import { Logger } from 'winston';
import { SignalsService } from '@backstage/plugin-signals-node';
import { SignalService } from '@backstage/plugin-signals-node';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -15,7 +15,7 @@ export interface RouterOptions {
// (undocumented)
logger: Logger;
// (undocumented)
service: SignalsService;
service: SignalService;
}
// (No @packageDocumentation comment for this package)
@@ -18,9 +18,9 @@ import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { SignalsService } from '@backstage/plugin-signals-node';
import { SignalService } from '@backstage/plugin-signals-node';
const signalsServiceMock: jest.Mocked<SignalsService> = {} as any;
const signalsServiceMock: jest.Mocked<SignalService> = {} as any;
describe('createRouter', () => {
let app: express.Express;
@@ -17,12 +17,12 @@ import { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { SignalsService } from '@backstage/plugin-signals-node';
import { SignalService } from '@backstage/plugin-signals-node';
/** @public */
export interface RouterOptions {
logger: Logger;
service: SignalsService;
service: SignalService;
}
/** @public */
@@ -21,7 +21,7 @@ import {
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import { SignalsService } from '@backstage/plugin-signals-node';
import { SignalService } from '@backstage/plugin-signals-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
export interface ServerOptions {
@@ -43,7 +43,7 @@ export async function startStandaloneServer(
issuer: await discovery.getExternalBaseUrl('auth'),
});
const signals = SignalsService.create({
const signals = SignalService.create({
logger: logger,
identity,
});
+5 -4
View File
@@ -19,19 +19,20 @@ export type ServiceOptions = {
};
// @public (undocumented)
export type SignalsEventBrokerPayload = {
export type SignalEventBrokerPayload = {
recipients?: string[];
topic?: string;
message?: JsonObject;
};
// @public (undocumented)
export class SignalsService implements EventSubscriber {
export class SignalService implements EventSubscriber {
// (undocumented)
static create(options: ServiceOptions): SignalsService;
static create(options: ServiceOptions): SignalService;
handleUpgrade: (req: Request_2) => Promise<void>;
hasSubscribers(topic: string): boolean;
// (undocumented)
onEvent(params: EventParams<SignalsEventBrokerPayload>): Promise<void>;
onEvent(params: EventParams<SignalEventBrokerPayload>): Promise<void>;
publish(
to: string | string[],
topic: string,
@@ -22,7 +22,7 @@ import { Logger } from 'winston';
import {
ServiceOptions,
SignalConnection,
SignalsEventBrokerPayload,
SignalEventBrokerPayload,
} from './types';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { IncomingMessage } from 'http';
@@ -36,7 +36,7 @@ import {
} from '@backstage/plugin-auth-node';
/** @public */
export class SignalsService implements EventSubscriber {
export class SignalService implements EventSubscriber {
private readonly serverId: string;
private connections: Map<string, SignalConnection> = new Map<
string,
@@ -48,7 +48,7 @@ export class SignalsService implements EventSubscriber {
private server: WebSocketServer;
static create(options: ServiceOptions) {
return new SignalsService(options);
return new SignalService(options);
}
private constructor(options: ServiceOptions) {
@@ -75,7 +75,7 @@ export class SignalsService implements EventSubscriber {
}
/**
* Handles request upgradce to websocket and adds the connection to internal
* Handles request upgrade to websocket and adds the connection to internal
* list for publish/subscribe functionality
* @param req - Request
*/
@@ -184,6 +184,19 @@ export class SignalsService implements EventSubscriber {
);
}
/**
* Checks if there is active subscriptions to specific topic.
* This can be useful to skip heavy processing before publishing messages if there are no subscriptions.
* @param topic - topic to check for subscriptions
*/
hasSubscribers(topic: string): boolean {
return (
[...this.connections.values()].find(conn =>
conn.subscriptions.has(topic),
) !== undefined
);
}
private async publishInternal(
recipients: string[],
topic: string,
@@ -229,7 +242,7 @@ export class SignalsService implements EventSubscriber {
}
}
async onEvent(params: EventParams<SignalsEventBrokerPayload>): Promise<void> {
async onEvent(params: EventParams<SignalEventBrokerPayload>): Promise<void> {
const { eventPayload, metadata } = params;
// Discard message from same server to prevent duplicate messages
if (!metadata?.server || metadata.server === this.serverId) {
+1 -1
View File
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export * from './SignalsService';
export * from './SignalService';
export * from './types';
+1 -1
View File
@@ -29,7 +29,7 @@ export type ServiceOptions = {
};
/** @public */
export type SignalsEventBrokerPayload = {
export type SignalEventBrokerPayload = {
recipients?: string[];
topic?: string;
message?: JsonObject;
+3 -3
View File
@@ -7,16 +7,16 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
// @public (undocumented)
export type SignalsApi = {
export type SignalApi = {
subscribe(topic: string, onMessage: (message: JsonObject) => void): string;
unsubscribe(subscription: string): void;
};
// @public (undocumented)
export const signalsApiRef: ApiRef<SignalsApi>;
export const signalApiRef: ApiRef<SignalApi>;
// @public (undocumented)
export const useSignalsApi: (
export const useSignalApi: (
topic: string,
onMessage: (message: JsonObject) => void,
) => void;
@@ -17,12 +17,12 @@ import { createApiRef } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
/** @public */
export const signalsApiRef = createApiRef<SignalsApi>({
id: 'plugin.signals.service',
export const signalApiRef = createApiRef<SignalApi>({
id: 'plugin.signal.service',
});
/** @public */
export type SignalsApi = {
export type SignalApi = {
subscribe(topic: string, onMessage: (message: JsonObject) => void): string;
unsubscribe(subscription: string): void;
+1 -1
View File
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './SignalsApi';
export * from './SignalApi';
+1 -1
View File
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './useSignalsApi';
export * from './useSignalApi';
@@ -13,17 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { signalsApiRef } from '../api';
import { signalApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { useEffect, useState } from 'react';
/** @public */
export const useSignalsApi = (
export const useSignalApi = (
topic: string,
onMessage: (message: JsonObject) => void,
) => {
const signals = useApi(signalsApiRef);
const signals = useApi(signalApiRef);
const [subscription, setSubscription] = useState<null | string>(null);
useEffect(() => {
if (!subscription) {
+3 -3
View File
@@ -7,17 +7,17 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { SignalsApi } from '@backstage/plugin-signals-react';
import { SignalApi } from '@backstage/plugin-signals-react';
// @public (undocumented)
export class SignalsClient implements SignalsApi {
export class SignalClient implements SignalApi {
// (undocumented)
static create(options: {
identity: IdentityApi;
discoveryApi: DiscoveryApi;
connectTimeout?: number;
reconnectTimeout?: number;
}): SignalsClient;
}): SignalClient;
// (undocumented)
static readonly DEFAULT_CONNECT_TIMEOUT_MS: number;
// (undocumented)
@@ -13,24 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SignalsApi } from '@backstage/plugin-signals-react';
import { SignalApi } from '@backstage/plugin-signals-react';
import { JsonObject } from '@backstage/types';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { v4 as uuid } from 'uuid';
/** @internal */
type Subscription = {
topic: string;
callback: (message: JsonObject) => void;
};
/** @internal */
const WS_CLOSE_NORMAL = 1000;
/** @internal */
const WS_CLOSE_GOING_AWAY = 1001;
/** @public */
export class SignalsClient implements SignalsApi {
export class SignalClient implements SignalApi {
static readonly DEFAULT_CONNECT_TIMEOUT_MS: number = 1000;
static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number = 5000;
private ws: WebSocket | null = null;
@@ -47,10 +44,10 @@ export class SignalsClient implements SignalsApi {
const {
identity,
discoveryApi,
connectTimeout = SignalsClient.DEFAULT_CONNECT_TIMEOUT_MS,
reconnectTimeout = SignalsClient.DEFAULT_RECONNECT_TIMEOUT_MS,
connectTimeout = SignalClient.DEFAULT_CONNECT_TIMEOUT_MS,
reconnectTimeout = SignalClient.DEFAULT_RECONNECT_TIMEOUT_MS,
} = options;
return new SignalsClient(
return new SignalClient(
identity,
discoveryApi,
connectTimeout,
@@ -16,7 +16,7 @@
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import WS from 'jest-websocket-mock';
import { SignalsClient } from './SignalsClient';
import { SignalClient } from './SignalClient';
describe('SignalsClient', () => {
const tokenFunction = jest.fn();
@@ -43,7 +43,7 @@ describe('SignalsClient', () => {
it('should handle single subscription correctly', async () => {
const messageMock = jest.fn();
const client = SignalsClient.create({ discoveryApi, identity });
const client = SignalClient.create({ discoveryApi, identity });
const sub = client.subscribe('topic', messageMock);
await server.connected;
@@ -64,8 +64,8 @@ describe('SignalsClient', () => {
it('should handle multiple subscription correctly', async () => {
const messageMock1 = jest.fn();
const messageMock2 = jest.fn();
const client1 = SignalsClient.create({ discoveryApi, identity });
const client2 = SignalsClient.create({ discoveryApi, identity });
const client1 = SignalClient.create({ discoveryApi, identity });
const client2 = SignalClient.create({ discoveryApi, identity });
const sub1 = client1.subscribe('topic', messageMock1);
const sub2 = client2.subscribe('topic', messageMock2);
@@ -94,7 +94,7 @@ describe('SignalsClient', () => {
it('should reconnect on error', async () => {
const messageMock = jest.fn();
const client = SignalsClient.create({
const client = SignalClient.create({
discoveryApi,
identity,
reconnectTimeout: 10,
+1 -1
View File
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './SignalsClient';
export * from './SignalClient';
+4 -4
View File
@@ -19,21 +19,21 @@ import {
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { signalsApiRef } from '@backstage/plugin-signals-react';
import { SignalsClient } from './api/SignalsClient';
import { signalApiRef } from '@backstage/plugin-signals-react';
import { SignalClient } from './api/SignalClient';
/** @public */
export const signalsPlugin = createPlugin({
id: 'signals',
apis: [
createApiFactory({
api: signalsApiRef,
api: signalApiRef,
deps: {
identity: identityApiRef,
discoveryApi: discoveryApiRef,
},
factory: ({ identity, discoveryApi }) =>
SignalsClient.create({
SignalClient.create({
identity,
discoveryApi,
}),
+1
View File
@@ -6203,6 +6203,7 @@ __metadata:
"@backstage/plugin-devtools-common": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-signals-node": "workspace:^"
"@backstage/types": "workspace:^"
"@manypkg/get-packages": ^1.1.3
"@types/express": "*"