Add authentication and assets

This commit is contained in:
2025-09-03 01:38:47 -04:00
parent c8a9e9329a
commit 04488c43c5
48 changed files with 3710 additions and 0 deletions

26
src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import {JSX, useState} from 'react';
import {useAppDispatch} from './store';
import {authenticateCredentialsThunk} from './store/slices/Authentication.slice';
export default function App(): JSX.Element {
const dispatch = useAppDispatch();
const [applicationId, setApplicationId] = useState<string>('phenixrts.com-alex.zinn');
const [secret, setSecret] = useState<string>('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg');
const handleAuthenticate = async () => {
const response = await dispatch(authenticateCredentialsThunk({applicationId, secret}));
console.log(`${new Date().toISOString()} AuthenticationResponse [%o]`, response.payload);
};
return (
<>
<h1>Hello World</h1>
<div>
<input type="text" value={applicationId} onChange={e => setApplicationId(e.target.value)} />
<br />
<input type="text" value={secret} onChange={e => setSecret(e.target.value)} />
</div>
<button onClick={handleAuthenticate}>Authenticate</button>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 29 14" height="6px" id="Layer_1" version="1.1" viewBox="0 0 29 14" width="29px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon fill="#F9F9F9" points="0.15,0 14.5,14.35 28.85,0 "/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 29 14" height="8px" id="Layer_1" version="1.1" viewBox="0 0 29 14" width="29px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon fill="#f9f9f9" points="0.15,14 14.5,-0.35 28.85,14 "/></svg>

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 206.751 186.399" width="206.751pt" height="186.399pt"><defs><clipPath id="_clipPath_Wy0NWkkA4mKqtolphxnGK0Cf0TrWZqlo"><rect width="206.751" height="186.399"/></clipPath></defs><g clip-path="url(#_clipPath_Wy0NWkkA4mKqtolphxnGK0Cf0TrWZqlo)"><g><path d=" M 179.102 178.898 L 103.306 178.559 L 27.51 178.22 C 9.581 178.14 2.36 165.502 11.394 150.015 L 49.585 84.544 L 87.777 19.072 C 96.811 3.586 111.366 3.651 120.261 19.218 L 157.865 85.028 L 195.469 150.839 C 204.364 166.406 197.03 178.979 179.102 178.898 Z " fill="none" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/><line x1="103.306" y1="47.028" x2="103.306" y2="120.028" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/><line x1="103.306" y1="138.028" x2="103.306" y2="143.028" vector-effect="non-scaling-stroke" stroke-width="5" stroke="rgb(247,13,26)" stroke-linejoin="miter" stroke-linecap="square" stroke-miterlimit="3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="19px" height="18px" viewBox="0 0 19 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="01-home-copy-7" transform="translate(-1214.000000, -84.000000)" fill="#FFFFFF" fill-rule="nonzero">
<g id="icon/white/add-channel" transform="translate(1211.000000, 80.000000)">
<path d="M18,14 C18.5522847,14 19,14.4477153 19,15 L18.999068,17 L21,17 C21.5522847,17 22,17.4477153 22,18 C22,18.5522847 21.5522847,19 21,19 L18.999068,19 L19,21 C19,21.5522847 18.5522847,22 18,22 C17.4477153,22 17,21.5522847 17,21 L16.999068,18.999 L15,19 C14.4477153,19 14,18.5522847 14,18 C14,17.4477153 14.4477153,17 15,17 L16.999068,16.999 L17,15 C17,14.4477153 17.4477153,14 18,14 Z M14,4 C14.4287818,4 14.8254386,4.13754318 15.1482567,4.37091574 C15.4794061,4.13462978 15.8829334,4 16.3080311,4 L18,4 C19.0836085,4 19.9620467,4.87843824 19.9620467,5.96204672 C19.9620467,6.23272939 19.9060382,6.5004841 19.7975435,6.748472 L19.687068,6.999 L19.763932,7 C20.8685015,7 21.763932,7.8954305 21.763932,9 C21.763932,9.31049019 21.6916418,9.61671632 21.5527864,9.89442719 L20.5527864,11.8944272 C20.3949049,12.2101902 20.1601982,12.4717535 19.8778882,12.6610569 C19.3634108,12.2470293 18.7105538,12 18,12 C17.1114527,12 16.3131288,12.3862919 15.7638055,13.0000983 L15.083068,13 L15.916068,11 L18.381966,11 C18.7607381,11 19.1070012,10.7859976 19.2763932,10.4472136 L19.5,10 C19.6706654,9.65866925 19.5323138,9.24361439 19.190983,9.07294902 C19.0950363,9.02497564 18.9892377,9 18.881966,9 L16.750068,9 L17.6153846,6.92307692 C17.7569961,6.58320938 17.5962778,6.19289353 17.2564103,6.05128205 C17.1751635,6.01742923 17.0880173,6 17,6 L16.6666667,6 C16.2629658,6 15.8988593,6.24273768 15.7435897,6.61538462 L14.749068,9 L12.750068,9 L13.6153846,6.92307692 C13.7569961,6.58320938 13.5962778,6.19289353 13.2564103,6.05128205 C13.1751635,6.01742923 13.0880173,6 13,6 L12.6666667,6 C12.2629658,6 11.8988593,6.24273768 11.7435897,6.61538462 L10.749068,9 L8.61803399,9 C8.23926193,9 7.89299881,9.21400238 7.7236068,9.5527864 L7.5,10 C7.32933463,10.3413307 7.46768625,10.7563856 7.80901699,10.927051 C7.90496374,10.9750244 8.01076227,11 8.11803399,11 L9.91606798,11 L9.08206798,13 L6.61803399,13 C6.23926193,13 5.89299881,13.2140024 5.7236068,13.5527864 L5.5,14 C5.32933463,14.3413307 5.46768625,14.7563856 5.80901699,14.927051 C5.90496374,14.9750244 6.01076227,15 6.11803399,15 L8.24906798,15 L7.38461538,17.0769231 C7.24300391,17.4167906 7.40372221,17.8071065 7.74358974,17.9487179 C7.82483652,17.9825708 7.91198265,18 8,18 L8.33333333,18 C8.73703418,18 9.1011407,17.7572623 9.25641026,17.3846154 L10.249068,15 L12.249068,15 L11.3846154,17.0769231 C11.2430039,17.4167906 11.4037222,17.8071065 11.7435897,17.9487179 C11.8248365,17.9825708 11.9119827,18 12,18 L12.0354373,18.0000497 C12.0120835,18.1633354 12,18.3302566 12,18.5 C12,19.037207 12.1210295,19.5461461 12.3373087,20.0010375 L11,20 C10.5712182,20 10.1745614,19.8624568 9.85174329,19.6290843 C9.52059393,19.8653702 9.11706659,20 8.69196885,20 L7,20 C5.91639152,20 5.03795328,19.1215618 5.03795328,18.0379533 C5.03795328,17.7672706 5.09396179,17.4995159 5.2024565,17.251528 L5.31206798,17 L5.23606798,17 C4.13149848,17 3.23606798,16.1045695 3.23606798,15 C3.23606798,14.6895098 3.30835816,14.3832837 3.4472136,14.1055728 L4.4472136,12.1055728 C4.62888297,11.7422341 4.91227191,11.4506579 5.25285994,11.2583596 C5.24175599,11.1747423 5.23606798,11.0880354 5.23606798,11 C5.23606798,10.6895098 5.30835816,10.3832837 5.4472136,10.1055728 L6.4472136,8.10557281 C6.78599762,7.42800475 7.47852386,7 8.23606798,7 L9.68706798,6.999 L10.4757165,5.19836233 C10.7942054,4.47038768 11.5134355,4 12.3080311,4 L14,4 Z M15,15 L14.9990501,15.0355804 C14.6965332,15.0789368 14.406503,15.160979 14.1341327,15.276534 L14.249068,15 L15,15 Z M13.916068,11 L13.082068,13 L11.083068,13 L11.916068,11 L13.916068,11 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,96H10.667C4.779,96,0,100.779,0,106.667s4.779,10.667,10.667,10.667h490.667c5.888,0,10.667-4.779,10.667-10.667
S507.221,96,501.333,96z"/>
</g>
</g>
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,245.333H10.667C4.779,245.333,0,250.112,0,256s4.779,10.667,10.667,10.667h490.667
c5.888,0,10.667-4.779,10.667-10.667S507.221,245.333,501.333,245.333z"/>
</g>
</g>
<g>
<g>
<path style="fill:#F9F9F9" d="M501.333,394.667H10.667C4.779,394.667,0,399.445,0,405.333C0,411.221,4.779,416,10.667,416h490.667
c5.888,0,10.667-4.779,10.667-10.667C512,399.445,507.221,394.667,501.333,394.667z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg fill="#00bc8c" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="250px" height="250px"><path fill="none" stroke="#00bc8c" stroke-miterlimit="10" stroke-width="2" d="M25,3C12.85,3,3,12.85,3,25s9.85,22,22,22 s22-9.85,22-22S37.15,3,25,3z"/><path fill="none" stroke="#00bc8c" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M16,24.444L24.143,32L35,16"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg width="19px" height="18px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 341.333 341.333" style="enable-background:new 0 0 341.333 341.333;" xml:space="preserve">
<g>
<g>
<path fill="#ffffff" d="M341.227,149.333V0l-50.133,50.133C260.267,19.2,217.707,0,170.56,0C76.267,0,0.107,76.373,0.107,170.667
s76.16,170.667,170.453,170.667c79.467,0,146.027-54.4,164.907-128h-44.373c-17.6,49.707-64.747,85.333-120.533,85.333
c-70.72,0-128-57.28-128-128s57.28-128,128-128c35.307,0,66.987,14.72,90.133,37.867l-68.8,68.8H341.227z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

6
src/config/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import controlVersion from './version.json' with {type: 'json'};
export default {controlCenterVersion: controlVersion['version']};

3
src/config/version.json Normal file
View File

@@ -0,0 +1,3 @@
{
"version": "local-2025-08-31T10:16:53.841Z (2024.3.2)"
}

68
src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

11
src/lang/Strings.ts Normal file
View File

@@ -0,0 +1,11 @@
export default class Strings {
public static randomString(length: number): string {
return Math.random()
.toString(36)
.substring(2, 2 + length);
}
private constructor() {
throw new Error('Strings is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,3 @@
export default function assertUnreachable(x: never): never {
throw new Error(`Error: Reached un-reachable code with [${x}]`);
}

12
src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import {createRoot} from 'react-dom/client';
import {Provider} from 'react-redux';
import store from './store';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<App />
</Provider>
);

View File

@@ -0,0 +1,66 @@
import LoggerFactory from './logger/LoggerFactory';
import ILogger from './logger/LoggerInterface';
import PlatformDetectionService from './PlatformDetection.service';
import {AuthenticationResponse, PhenixWebSocket} from './net/websockets/PhenixWebSocket';
import {PhenixWebSocketMessage} from './net/websockets/PhenixWebSocketMessage';
//TEMPORARY
import config from '../config';
class AuthenticationService {
private static readonly _instance = new AuthenticationService();
private readonly _logger: ILogger = LoggerFactory.getLogger('AuthenticationService');
private _phenixWebSocket: PhenixWebSocket;
public static getInstance(): AuthenticationService {
return AuthenticationService._instance;
}
constructor() {
const backenUrl = 'wss://pcast-stg.phenixrts.com/ws';
this._phenixWebSocket = new PhenixWebSocket(backenUrl);
}
get sessionId(): string | null {
return this._phenixWebSocket.sessionId;
}
async authenticate(applicationId: string, secret: string): Promise<AuthenticationResponse> {
const authenticate = {
// @ts-expect-error TODO(AZ): phenix-web-proto does not have Typescript types defined definition
apiVersion: this._phenixWebSocket.getApiVersion(), // TODO(AZ): add types for phenix-proto-web
clientVersion: config.controlCenterVersion,
deviceId: '',
platform: PlatformDetectionService.platform,
platformVersion: PlatformDetectionService.platformVersion,
browser: PlatformDetectionService.browser,
browserVersion: PlatformDetectionService.version,
applicationId,
authenticationToken: secret,
sessionId: this.sessionId
};
try {
const authenticationResponse = await this._phenixWebSocket.sendMessage(PhenixWebSocketMessage.Authenticate, authenticate);
if (authenticationResponse.status === 'ok') {
this._phenixWebSocket.sessionId = authenticationResponse.sessionId;
}
return authenticationResponse;
} catch (error) {
this._logger.error('Authentication failed [%s]', error);
throw error;
}
}
public async signout(): Promise<void> {
await this._phenixWebSocket.sendMessage(PhenixWebSocketMessage.Bye, {
sessionId: this.sessionId,
reason: 'signout'
});
}
}
export default AuthenticationService.getInstance();

View File

@@ -0,0 +1,165 @@
export default class PlatformDetectionService {
private static _userAgent: string = navigator.userAgent;
private static _areClientHintsSupported: boolean = 'userAgentData' in navigator;
private static _platform: string = '?';
private static _platformVersion: string = '?';
private static _browser: string = 'Unknown';
private static _version: string | number = '?';
private static _isWebview: boolean = false;
private static _initialized: boolean = false;
private constructor() {
throw new Error('PlatformDetectionService is a static class that may not be instantiated');
}
static get platform(): string {
this.initializeIfNeeded();
return this._platform;
}
static get platformVersion(): string {
this.initializeIfNeeded();
return this._platformVersion;
}
static get userAgent(): string {
return this._userAgent;
}
static get browser(): string {
this.initializeIfNeeded();
return this._browser;
}
static get version(): string | number {
this.initializeIfNeeded();
return this._version;
}
static get isWebview(): boolean {
this.initializeIfNeeded();
return this._isWebview;
}
static get areClientHintsSupported(): boolean {
return this._areClientHintsSupported;
}
private static initializeIfNeeded(): void {
if (this._initialized) return;
this.initialize();
}
private static initialize(): void {
try {
const browserVersionMatch = this._userAgent.match(/(Chrome|Chromium|Firefox|Opera|Safari|Edge|OPR)\/([0-9]+)/);
if (browserVersionMatch) {
const [, browser, version] = browserVersionMatch;
PlatformDetectionService._browser = browser === 'OPR' ? 'Opera' : browser;
PlatformDetectionService._version = parseInt(version, 10)?.toString() || '?';
} else if (this._userAgent.match(/^\(?Mozilla/)) {
PlatformDetectionService._browser = 'Mozilla';
// Check for IE/Edge
if (this._userAgent.match(/MSIE/) || this._userAgent.match(/; Trident\/.*rv:[0-9]+/)) {
PlatformDetectionService._browser = 'IE';
const ieVersionMatch = this._userAgent.match(/rv:([0-9]+)/);
if (ieVersionMatch) {
PlatformDetectionService._version = parseInt(ieVersionMatch[1], 10)?.toString() || '?';
}
} else if (this._userAgent.match(/Edge\//)) {
PlatformDetectionService._browser = 'Edge';
const edgeVersionMatch = this._userAgent.match(/Edge\/([0-9]+)/);
if (edgeVersionMatch) {
PlatformDetectionService._version = parseInt(edgeVersionMatch[1], 10)?.toString() || '?';
}
}
}
// Handle Opera masquerading as other browsers
if (this._userAgent.match(/OPR\//)) {
PlatformDetectionService._browser = 'Opera';
const operaVersionMatch = this._userAgent.match(/OPR\/([0-9]+)/);
if (operaVersionMatch) {
PlatformDetectionService._version = parseInt(operaVersionMatch[1], 10)?.toString() || '?';
}
}
// Safari and iOS webviews
if (this._userAgent.match(/AppleWebKit/i)) {
if (this._userAgent.match(/iphone|ipod|ipad/i)) {
PlatformDetectionService._browser = 'Safari';
PlatformDetectionService._isWebview = true;
const iosVersionMatch = this._userAgent.match(/OS\s([0-9]+)/);
if (iosVersionMatch) {
PlatformDetectionService._version = parseInt(iosVersionMatch[1], 10)?.toString() || '?';
}
} else if (this._userAgent.match(/Safari\//) && !this._userAgent.match(/Chrome/)) {
PlatformDetectionService._browser = 'Safari';
const safariVersionMatch = this._userAgent.match(/Version\/([0-9]+)/);
if (safariVersionMatch) {
PlatformDetectionService._version = parseInt(safariVersionMatch[1], 10)?.toString() || '?';
}
}
}
// Android webviews
if (this._userAgent.match(/; wv/) || (this._userAgent.match(/Android/) && this._userAgent.match(/Version\/[0-9].[0-9]/))) {
PlatformDetectionService._isWebview = true;
}
// React Native
if (globalThis.navigator.product === 'ReactNative') {
PlatformDetectionService._browser = 'ReactNative';
PlatformDetectionService._version = navigator.productSub || '?';
}
// platform information
if (this._userAgent.match(/Windows/)) {
PlatformDetectionService._platform = 'Windows';
const windowsVersionMatch = this._userAgent.match(/Windows NT ([0-9.]+)/);
if (windowsVersionMatch) {
PlatformDetectionService._platformVersion = windowsVersionMatch[1];
}
} else if (this._userAgent.match(/Mac OS X/)) {
PlatformDetectionService._platform = 'macOS';
const macVersionMatch = this._userAgent.match(/Mac OS X ([0-9._]+)/);
if (macVersionMatch) {
PlatformDetectionService._platformVersion = macVersionMatch[1].replace(/_/g, '.');
}
} else if (this._userAgent.match(/Linux/)) {
PlatformDetectionService._platform = 'Linux';
} else if (this._userAgent.match(/Android/)) {
PlatformDetectionService._platform = 'Android';
const androidVersionMatch = this._userAgent.match(/Android ([0-9.]+)/);
if (androidVersionMatch) {
PlatformDetectionService._platformVersion = androidVersionMatch[1];
}
} else if (this._userAgent.match(/iPhone|iPad|iPod/)) {
PlatformDetectionService._platform = 'iOS';
const iosVersionMatch = this._userAgent.match(/OS ([0-9_]+)/);
if (iosVersionMatch) {
PlatformDetectionService._platformVersion = iosVersionMatch[1].replace(/_/g, '.');
}
}
this._initialized = true;
} catch (error) {
console.warn('Failed to initialize PlatformDetectionService:', error);
// fallback values
this._browser = 'Unknown';
this._version = '?';
this._platform = '?';
this._platformVersion = '?';
this._isWebview = false;
this._initialized = true;
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {IAppender} from './IAppender';
export default class Appenders {
private _appenders: Array<IAppender> = [];
get value(): Array<IAppender> {
return this._appenders;
}
add(appender: IAppender): void {
this._appenders.push(appender);
}
remove(appender): void {
this._appenders = this._appenders.reduce((items, item) => {
if (!(item === appender)) {
items.push(item);
}
return items;
}, [] as Array<IAppender>);
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {IAppender} from './IAppender';
import {LoggingLevel} from './Logger';
export default class ConsoleAppender implements IAppender {
private readonly _threshold: LoggingLevel;
log(logLevel: LoggingLevel, message: string, category: string, date: Date): void {
if (logLevel < this._threshold) {
return;
}
const fullMessage = `${date.toISOString()} [${category}] [${LoggingLevel[logLevel]}] ${message}`;
if (logLevel < LoggingLevel.Warn) {
console.log(fullMessage);
return;
}
console.error(fullMessage);
}
constructor(threshold: LoggingLevel) {
this._threshold = threshold;
}
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {LoggingLevel} from './Logger';
export interface IAppender {
log: (logLevel: LoggingLevel, message: string, category: string, date: Date) => void;
}

View File

@@ -0,0 +1,195 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {IAppender} from './IAppender';
import Appenders from './Appenders';
import LoggingThreshold from './LoggingThreshold';
export enum LoggingLevel {
All = -1,
Trace = 10,
Debug = 20,
Info = 30,
Warn = 40,
Error = 50,
Fatal = 60,
Off = 100
}
export type LoggingLevelType = 'Off' | 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error' | 'Fatal' | 'All';
export default class Logger {
private readonly _category: string;
private readonly _appenders: Appenders;
private readonly _threshold: LoggingThreshold;
get category(): string {
return this._category;
}
get appenders(): Appenders {
return this._appenders;
}
get threshold(): LoggingThreshold {
return this._threshold;
}
trace(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Trace) {
return;
}
this.log(LoggingLevel.Trace, args);
}
debug(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Debug) {
return;
}
this.log(LoggingLevel.Debug, args);
}
info(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Info) {
return;
}
this.log(LoggingLevel.Info, args);
}
warn(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Warn) {
return;
}
this.log(LoggingLevel.Warn, args);
}
error(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Error) {
return;
}
this.log(LoggingLevel.Error, args);
}
fatal(...args: any): void {
if (!this._threshold.value || this._threshold.value > LoggingLevel.Fatal) {
return;
}
this.log(LoggingLevel.Fatal, args);
}
private log(level: number, args: any): void {
const date = new Date();
const message = this.replacePlaceholders(args);
this._appenders.value.forEach((appender: IAppender) => {
appender.log(level, message, this.category, date);
});
}
private replacePlaceholders(args: any): string {
let replacePlaceholdersString = args[0];
let index = 0;
while (replacePlaceholdersString.indexOf && args.length > 1 && index >= 0) {
index = replacePlaceholdersString.indexOf('%', index);
if (index > 0) {
const type = replacePlaceholdersString.substring(index + 1, index + 2);
switch (type) {
case '%':
// Escaped '%%' turns into '%'
replacePlaceholdersString = replacePlaceholdersString.substring(0, index) + replacePlaceholdersString.substring(index + 1);
index++;
break;
case 's':
case 'd':
// Replace '%d' or '%s' with the argument
args[0] = replacePlaceholdersString = this.replaceArgument(this.toString(args[1]), replacePlaceholdersString, index);
args.splice(1, 1);
break;
case 'j':
// Replace %j' with the argument
args[0] = replacePlaceholdersString = this.replaceArgument(this.stringify(args[1]), replacePlaceholdersString, index);
args.splice(1, 1);
break;
default:
return args.toString();
}
}
}
if (args.length > 1) {
args = args.reduce((accumulator, currentValue, index, array) => {
if (index + 1 === array.length && currentValue instanceof Error) {
return accumulator + '\n' + this.toString(currentValue.stack);
}
return accumulator + `[${this.toString(currentValue)}]`;
});
}
return args.toString();
}
private stringify(arg: any): string {
try {
return JSON.stringify(arg instanceof Error ? this.toString(arg) : arg, null, 2);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return '[object invalid JSON.stringify]';
}
}
private replaceArgument(argument: any, replacePlaceholdersString: string, index: number): string {
return replacePlaceholdersString.substring(0, index) + this.toString(argument) + replacePlaceholdersString.substring(index + 2);
}
private toString(data: any): string {
if (typeof data === 'string') {
return data;
}
if (typeof data === 'boolean') {
return data ? 'true' : 'false';
}
if (typeof data === 'number') {
return data.toString();
}
let toStringStr = '';
if (data) {
if (typeof data === 'function') {
toStringStr = data.toString();
} else if (data instanceof Object) {
try {
toStringStr = data.toString();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
toStringStr = '[object invalid toString()]';
}
}
}
return toStringStr;
}
constructor(category: string, appenders: Appenders, threshold: LoggingThreshold) {
this._category = category;
this._appenders = appenders;
this._threshold = threshold;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {LoggingLevel, LoggingLevelType} from '../logger/Logger';
declare const __FEATURES__: {
sendLogs: LoggingLevelType;
logToConsole: LoggingLevelType;
};
export class BuildFeatures {
private static _sendLogs: LoggingLevelType;
private static _logToConsole: LoggingLevelType;
static get sendLogs(): LoggingLevelType {
return this._sendLogs;
}
static get logToConsole(): LoggingLevelType {
return this._logToConsole;
}
static applyFeatures(): void {
try {
const features = __FEATURES__;
this._sendLogs = 'sendLogs' in features ? features.sendLogs : 'All';
this._logToConsole = 'logToConsole' in features ? features.logToConsole : 'All';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
this._sendLogs = 'All';
this._logToConsole = 'All';
}
}
}
BuildFeatures.applyFeatures();
export default class LoggerDefaults {
static get defaultLoggingLevel(): LoggingLevel {
return LoggingLevel[BuildFeatures.sendLogs];
}
static get defaultConsoleLoggingLevel(): LoggingLevel {
return LoggingLevel[BuildFeatures.logToConsole];
}
static get defaultTelemetryLoggingLevel(): LoggingLevel {
return LoggingLevel.Info;
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
// import TelemetryUrl from '/telemetry/TelemetryUrl';
import PlatformDetectionService from '../PlatformDetection.service';
import TelemetryConfiguration from '../telemetry/TelemetryConfiguration';
import TelemetryAppender from '../telemetry/TelemetryApender';
// import hostService from 'services/host-url.service';
// import userStore from 'services/user-store';
import ILogger from './LoggerInterface';
import Logger, {LoggingLevel} from './Logger';
import Appenders from './Appenders';
import LoggingThreshold from './LoggingThreshold';
import ConsoleAppender from './ConsoleAppender';
import LoggerDefaults from './LoggerDefaults';
export default class LoggerFactory {
private static _loggers: {[category: string]: ILogger} = {};
private static _appenders: Appenders = new Appenders();
private static _threshold: LoggingThreshold = new LoggingThreshold();
private static _telemetryConfiguration: TelemetryConfiguration = new TelemetryConfiguration();
static get telemetryConfiguration(): TelemetryConfiguration {
return this._telemetryConfiguration;
}
static applyLoggerConfig(): void {
LoggerFactory.applyConsoleLogger(LoggingLevel['All']);
LoggerFactory.applyLoggingLevel();
LoggerFactory.applyTelemetryLogger();
}
static getLogger(category: string): ILogger {
if (typeof category !== 'string') {
category = 'portal';
}
const logger = LoggerFactory._loggers[category];
if (logger) {
return logger;
}
return (LoggerFactory._loggers[category] = new Logger(category, this._appenders, this._threshold));
}
static applyLoggingLevel(): void {
this._threshold.setThreshold(LoggingLevel['All']);
}
static applyConsoleLogger(level: LoggingLevel): void {
this._appenders.add(new ConsoleAppender(level || LoggerDefaults.defaultConsoleLoggingLevel));
}
static async applyTelemetryConfiguration(level: LoggingLevel): Promise<void> {
const browser = PlatformDetectionService.browser;
const applicationId = 'phenixrts.com-alex.zinn'; // TEMPORARY --> FOR DEVELOPMENT ONLY
this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel;
this._telemetryConfiguration.url = 'https://pcast-stg.phenixrts.com/telemetry'; //TelemetryUrl.getTelemetryUrl();
this._telemetryConfiguration.environment = 'https://pcast-stg.phenixrts.com'; // TODO(AZ): hostService.getHostUrl();
this._telemetryConfiguration.tenancy = applicationId; // TODO(AZ): await userStore.get('applicationId');
this._telemetryConfiguration.userId = applicationId;
this._telemetryConfiguration.sessionId = 'some-session-id'; // TODOD(AZ): await userStore.get('sessionId');
this._telemetryConfiguration.browser = browser ? `${browser}/${PlatformDetectionService.version}` : 'unknown';
}
private static applyTelemetryLogger(): void {
LoggerFactory.applyTelemetryConfiguration(LoggingLevel['Info']);
this._appenders.add(new TelemetryAppender(this._telemetryConfiguration));
}
private constructor() {
throw new Error('LoggerFactory is a static class that may not be instantiated');
}
}
LoggerFactory.applyLoggerConfig();

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import Appenders from './Appenders';
import LoggingThreshold from './LoggingThreshold';
export default interface ILogger {
readonly category: string;
readonly appenders: Appenders;
readonly threshold: LoggingThreshold;
trace: (...args: any) => void;
debug: (...args: any) => void;
info: (...args: any) => void;
warn: (...args: any) => void;
error: (...args: any) => void;
fatal: (...args: any) => void;
}
/* eslint-enable */

View File

@@ -0,0 +1,56 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {LoggingLevel, LoggingLevelType} from './Logger';
function assertUnreachable(x: never): never {
throw new Error(`Unexpected value [${x}]. This should never be reached`);
}
export default class LoggingLevelMapping {
static convertLoggingLevelToLoggingLevelType(loggingLevel: LoggingLevel): LoggingLevelType {
switch (loggingLevel) {
case LoggingLevel.Off:
return 'Off';
case LoggingLevel.Trace:
return 'Trace';
case LoggingLevel.Debug:
return 'Debug';
case LoggingLevel.Info:
return 'Trace';
case LoggingLevel.Warn:
return 'Warn';
case LoggingLevel.Error:
return 'Error';
case LoggingLevel.Fatal:
return 'Fatal';
case LoggingLevel.All:
return 'All';
default:
assertUnreachable(loggingLevel);
}
}
static convertLoggingLevelTypeToLoggingLevel(loggingLevelType: LoggingLevelType): LoggingLevel {
switch (loggingLevelType) {
case 'Off':
return LoggingLevel.Off;
case 'Trace':
return LoggingLevel.Trace;
case 'Debug':
return LoggingLevel.Debug;
case 'Info':
return LoggingLevel.Info;
case 'Warn':
return LoggingLevel.Warn;
case 'Error':
return LoggingLevel.Error;
case 'Fatal':
return LoggingLevel.Fatal;
case 'All':
return LoggingLevel.All;
default:
assertUnreachable(loggingLevelType);
}
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import LoggerDefaults from './LoggerDefaults';
import {LoggingLevel} from './Logger';
export default class LoggingThreshold {
private _threshold: LoggingLevel = LoggerDefaults.defaultLoggingLevel;
get value(): LoggingLevel {
return this._threshold;
}
setThreshold(threshold: LoggingLevel): void {
this._threshold = threshold;
}
}

View File

@@ -0,0 +1,115 @@
import Strings from 'lang/Strings';
import ILogger from '../../logger/LoggerInterface';
import LoggerFactory from '../../logger/LoggerFactory';
import {Subject, ReadOnlySubject} from '@techniker-me/tools';
import {MQWebSocket} from 'phenix-web-proto'; //TODO(AZ): add types
import PCastProtobuf from './proto/pcast.proto.json' with {type: 'json'};
import AnalyticsProtobuf from './proto/Analytics.proto.json' with {type: 'json'};
import {PhenixWebSocketStatus, PhenixWebSocketStatusMapping} from './PhenixWebSocketStatus';
import {PhenixWebSocketMessage, PhenixWebSocketMessageMapping} from './PhenixWebSocketMessage';
export type AuthenticationResponse = {
status: string;
applicationId?: string;
sessionId?: string;
};
export interface IPhenixWebSocketResponse {
status: 'ok';
applicationId: string;
sessionId: string;
redirect: string;
roles: string[];
}
export class PhenixWebSocket extends MQWebSocket {
private readonly _logger: ILogger;
private readonly _status: Subject<PhenixWebSocketStatus> = new Subject<PhenixWebSocketStatus>(PhenixWebSocketStatus.Offline);
private readonly _readOnlySubject: ReadOnlySubject<PhenixWebSocketStatus> = new ReadOnlySubject<PhenixWebSocketStatus>(this._status);
private readonly _socketId: string = Strings.randomString(10);
private _sessionId: string | null = null;
private _pendingRequests: number = 0;
constructor(url: string) {
const logger = LoggerFactory.getLogger('PhenixWebSocket');
super(url, logger, [PCastProtobuf, AnalyticsProtobuf]);
this._logger = logger;
this.initialize();
}
get status(): ReadOnlySubject<PhenixWebSocketStatus> {
return this._readOnlySubject;
}
get pendingRequests(): number {
return this._pendingRequests;
}
get socketId(): string {
return this._socketId;
}
get sessionId(): string | null {
return this._sessionId;
}
set sessionId(sessionId: string | null) {
this._sessionId = sessionId;
}
public async sendMessage<T>(kind: PhenixWebSocketMessage, message: T): Promise<IPhenixWebSocketResponse> {
if (this._status.value !== PhenixWebSocketStatus.Online) {
throw new Error(`Unable to send message, web socket is not [Online] WebSocket status [${PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(this._status.value)}]`);
}
this._pendingRequests++;
const messageKind = PhenixWebSocketMessageMapping.convertPhenixWebSocketMessageToPhenixWebSocketMessageType(kind);
this._logger.debug(`Sending [${messageKind}] message [%j]`, message);
return new Promise<IPhenixWebSocketResponse>((resolve, reject) => {
super.sendRequest(messageKind, message, (error: unknown, response: IPhenixWebSocketResponse) => {
this._pendingRequests--;
if (error) {
reject(error);
} else {
resolve(response);
}
});
});
}
private setStatus(status: PhenixWebSocketStatus): void {
this._status.value = status;
}
private initialize(): void {
super.onEvent('connected', () => {
this.setStatus(PhenixWebSocketStatus.Online);
})
super.onEvent('disconnected', () => {
this.setStatus(PhenixWebSocketStatus.Offline);
})
super.onEvent('error', (error: unknown) => {
this._logger.error('Error [%s]', error);
this.setStatus(PhenixWebSocketStatus.Error);
})
super.onEvent('reconnecting', () => {
this.setStatus(PhenixWebSocketStatus.Reconnecting);
})
super.onEvent('reconnected', () => {
this.setStatus(PhenixWebSocketStatus.Online);
})
super.onEvent('timeout', () => {
this.setStatus(PhenixWebSocketStatus.Error);
})
}
}

View File

@@ -0,0 +1,33 @@
import assertUnreachable from 'lang/assertUnreachable';
export enum PhenixWebSocketMessage {
Authenticate = 0,
Bye = 1
}
export type PhenixWebSocketMessageType = 'pcast.Authenticate' | 'pcast.Bye';
export class PhenixWebSocketMessageMapping {
public static convertPhenixWebSocketMessageToPhenixWebSocketMessageType(message: PhenixWebSocketMessage): PhenixWebSocketMessageType {
switch (message) {
case PhenixWebSocketMessage.Authenticate:
return 'pcast.Authenticate';
case PhenixWebSocketMessage.Bye:
return 'pcast.Bye';
default:
assertUnreachable(message);
}
}
public static convertPhenixWebSocketMessageTypeToPhenixWebSocketMessage(messageType: PhenixWebSocketMessageType): PhenixWebSocketMessage {
switch (messageType) {
case 'pcast.Authenticate':
return PhenixWebSocketMessage.Authenticate;
case 'pcast.Bye':
return PhenixWebSocketMessage.Bye;
default:
assertUnreachable(messageType);
}
}
}

View File

@@ -0,0 +1,44 @@
import assertUnreachable from '../../../lang/assertUnreachable';
export enum PhenixWebSocketStatus {
Offline = 0,
Online = 1,
Reconnecting = 2,
Error = 3
}
export type PhenixWebSocketStatusType = 'Offline' | 'Online' | 'Reconnecting' | 'Error';
export class PhenixWebSocketStatusMapping {
public static convertPhenixWebSocketStatusToPhenixWebSocketStatusType(status: PhenixWebSocketStatus): PhenixWebSocketStatusType {
switch (status) {
case PhenixWebSocketStatus.Offline:
return 'Offline';
case PhenixWebSocketStatus.Online:
return 'Online';
case PhenixWebSocketStatus.Reconnecting:
return 'Reconnecting';
case PhenixWebSocketStatus.Error:
return 'Error';
default:
assertUnreachable(status);
}
}
public static convertPhenixWebSocketStatusTypeToPhenixWebSocketStatus(statusType: PhenixWebSocketStatusType): PhenixWebSocketStatus {
switch (statusType) {
case 'Offline':
return PhenixWebSocketStatus.Offline;
case 'Online':
return PhenixWebSocketStatus.Online;
case 'Reconnecting':
return PhenixWebSocketStatus.Reconnecting;
case 'Error':
return PhenixWebSocketStatus.Error;
default:
assertUnreachable(statusType);
}
}
}

View File

@@ -0,0 +1,391 @@
{
"package": "analytics",
"messages": [
{
"name": "Usage",
"fields": [
{
"rule": "required",
"type": "uint64",
"name": "streams",
"id": 1
},
{
"rule": "required",
"type": "uint64",
"name": "users",
"id": 2
},
{
"rule": "required",
"type": "uint64",
"name": "devices",
"id": 3
},
{
"rule": "required",
"type": "uint64",
"name": "minutes",
"id": 4
}
]
},
{
"name": "UsageByType",
"fields": [
{
"rule": "required",
"type": "string",
"name": "type",
"id": 1
},
{
"rule": "required",
"type": "string",
"name": "subtype",
"id": 2
},
{
"rule": "required",
"type": "Usage",
"name": "usage",
"id": 3
}
]
},
{
"name": "UsageByCountry",
"fields": [
{
"rule": "required",
"type": "string",
"name": "continent",
"id": 1
},
{
"rule": "required",
"type": "string",
"name": "country",
"id": 2
},
{
"rule": "repeated",
"type": "UsageByType",
"name": "usageByType",
"id": 3
}
]
},
{
"name": "GetGeographicUsage",
"fields": [
{
"rule": "repeated",
"type": "string",
"name": "applicationIds",
"id": 1
},
{
"rule": "required",
"type": "uint64",
"name": "start",
"id": 2
},
{
"rule": "required",
"type": "uint64",
"name": "end",
"id": 3
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 4
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 5
}
]
},
{
"name": "GetGeographicUsageResponse",
"fields": [
{
"rule": "required",
"type": "string",
"name": "status",
"id": 1
},
{
"rule": "optional",
"type": "Usage",
"name": "usage",
"id": 2
},
{
"rule": "repeated",
"type": "UsageByType",
"name": "usageByType",
"id": 3
},
{
"rule": "repeated",
"type": "UsageByCountry",
"name": "usageByCountry",
"id": 4
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 5
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 6
}
]
},
{
"name": "CDF",
"fields": [
{
"rule": "repeated",
"type": "double",
"name": "data",
"id": 1
}
]
},
{
"name": "GetTimeToFirstFrameCDF",
"fields": [
{
"rule": "repeated",
"type": "string",
"name": "applicationIds",
"id": 1
},
{
"rule": "required",
"type": "uint64",
"name": "start",
"id": 2
},
{
"rule": "required",
"type": "uint64",
"name": "end",
"id": 3
},
{
"rule": "required",
"type": "Kind",
"name": "kind",
"id": 4
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 5
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 6
}
],
"enums": [
{
"name": "Kind",
"values": [
{
"name": "All",
"id": 0
},
{
"name": "RealTime",
"id": 1
},
{
"name": "Live",
"id": 2
},
{
"name": "Dash",
"id": 3
},
{
"name": "Hls",
"id": 4
},
{
"name": "PeerAssisted",
"id": 5
}
]
}
]
},
{
"name": "GetTimeToFirstFrameCDFResponse",
"fields": [
{
"rule": "required",
"type": "string",
"name": "status",
"id": 1
},
{
"rule": "optional",
"type": "uint64",
"name": "count",
"id": 2
},
{
"rule": "optional",
"type": "double",
"name": "average",
"id": 3
},
{
"rule": "optional",
"type": "CDF",
"name": "cdf",
"id": 4
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 5
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 6
}
]
},
{
"name": "GetActiveUsers",
"fields": [
{
"rule": "repeated",
"type": "string",
"name": "applicationIds",
"id": 1
},
{
"rule": "required",
"type": "uint64",
"name": "snapshotTime",
"id": 2
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 3
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 4
}
]
},
{
"name": "UsersAndSessionsGrouped",
"fields": [
{
"rule": "required",
"type": "string",
"name": "groupName",
"id": 1
},
{
"rule": "required",
"type": "uint64",
"name": "users",
"id": 2
},
{
"rule": "required",
"type": "uint64",
"name": "sessions",
"id": 3
}
]
},
{
"name": "GetActiveUsersResponse",
"fields": [
{
"rule": "required",
"type": "string",
"name": "status",
"id": 1
},
{
"rule": "optional",
"type": "uint64",
"name": "users",
"id": 2
},
{
"rule": "optional",
"type": "uint64",
"name": "sessions",
"id": 3
},
{
"rule": "repeated",
"type": "UsersAndSessionsGrouped",
"name": "byPlatform",
"id": 4
},
{
"rule": "repeated",
"type": "UsersAndSessionsGrouped",
"name": "byManufacturer",
"id": 5
},
{
"rule": "repeated",
"type": "UsersAndSessionsGrouped",
"name": "byCity",
"id": 6
},
{
"rule": "repeated",
"type": "UsersAndSessionsGrouped",
"name": "byCountry",
"id": 7
},
{
"rule": "optional",
"type": "string",
"name": "continuationId",
"id": 8
},
{
"rule": "optional",
"type": "string",
"name": "routeKey",
"id": 9
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import TelemetryService from './TelemetryService';
import TelemetryConfiguration from './TelemetryConfiguration';
import {IAppender} from '../logger/IAppender';
import {LoggingLevel} from '../logger/Logger';
export default class TelemetryAppender implements IAppender {
private readonly _telemetryService: TelemetryService;
private readonly _threshold: LoggingLevel;
constructor(telemetryConfiguration: TelemetryConfiguration) {
this._threshold = telemetryConfiguration.threshold;
this._telemetryService = new TelemetryService(telemetryConfiguration);
}
async log(logLevel: LoggingLevel, message: string, category: string, date: Date): Promise<void> {
if (logLevel < this._threshold) {
return;
}
this._telemetryService.push(logLevel, message, category, date);
}
}

View File

@@ -0,0 +1,75 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {LoggingLevel} from '../logger/Logger';
import LoggerDefaults from '../logger/LoggerDefaults';
export default class TelemetryConfiguration {
private _url = 'https://telemetry.phenixrts.com/telemetry/logs';
private _tenancy = '';
private _userId = '';
private _sessionId = '';
private _environment = '';
private _threshold = LoggerDefaults.defaultTelemetryLoggingLevel;
private _browser = '';
get url(): string {
return this._url;
}
set url(url: string) {
const telemetryUrl = new URL(url);
telemetryUrl.pathname = telemetryUrl.pathname + '/logs';
this._url = telemetryUrl.toString();
}
get environment(): string {
return this._environment;
}
set environment(environment: string) {
this._environment = environment;
}
get browser(): string {
return this._browser;
}
set browser(browser: string) {
this._browser = browser;
}
get tenancy(): string {
return this._tenancy;
}
set tenancy(tenancy: string) {
this._tenancy = tenancy;
}
get userId(): string {
return this._userId;
}
set userId(userId: string) {
this._userId = userId;
}
get sessionId(): string {
return this._sessionId;
}
set sessionId(sessionId: string) {
this._sessionId = sessionId;
}
get threshold(): LoggingLevel {
return this._threshold;
}
set threshold(threshold: LoggingLevel) {
this._threshold = threshold;
}
}

View File

@@ -0,0 +1,131 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import config from '../../config';
import {LoggingLevel} from '../logger/Logger';
import TelemetryConfiguration from './TelemetryConfiguration';
// Extend Window interface to include custom properties
declare global {
interface Window {
__phenixPageLoadTime?: number;
__pageLoadTime?: number;
}
}
const requestSizeLimit = 8192;
const pageLoadTime = window.__phenixPageLoadTime || window.__pageLoadTime || Date.now();
interface ILogItem {
timestamp: string;
tenancy: string;
level: string;
category: string;
message: string;
sessionId: string;
userId: string;
version: string;
environment: string;
fullQualifiedName: string;
source: string;
runtime: number;
}
export default class TelemetryService {
private readonly _telemetryConfiguration: TelemetryConfiguration;
private _logs: Array<ILogItem> = [];
private _isSending: boolean = false;
private _domain = location.hostname;
constructor(telemetryConfiguration: TelemetryConfiguration) {
this._telemetryConfiguration = telemetryConfiguration;
}
push(logLevel: LoggingLevel, message: string, category: string, timestamp: Date): void {
const now = Date.now();
const runtime = (now - pageLoadTime) / 1000;
const logRecord = {
timestamp: timestamp.toISOString(),
tenancy: this._telemetryConfiguration.tenancy,
userId: this._telemetryConfiguration.userId,
level: LoggingLevel[logLevel],
runtime,
category,
message,
sessionId: this._telemetryConfiguration.sessionId,
version: config.controlCenterVersion,
environment: this._telemetryConfiguration.environment,
fullQualifiedName: this._domain,
source: `Portal (${this._telemetryConfiguration.browser})`
} as ILogItem;
if (logLevel < LoggingLevel.Error) {
this._logs.push(logRecord);
} else {
this._logs.unshift(logRecord);
}
// @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble();
}
private async sendLogs(logMessages: Array<ILogItem>): Promise<Response | void> {
const formData = new FormData();
formData.append('jsonBody', JSON.stringify({records: logMessages}));
return await fetch(this._telemetryConfiguration.url, {
method: 'POST',
body: formData
});
}
private async sendLogsIfAble(): Promise<Response | void> {
if (this._logs.length <= 0 || this._isSending) {
return;
}
let numberOfLogsToSend = 0;
let sizeOfLogsToSend = 0;
this._isSending = true;
const getLogSize = (log: ILogItem): number => Object.values(log).reduce((sum, item) => sum + (item ? `${item}`.length : 0), 0);
while (this._logs.length > numberOfLogsToSend && getLogSize(this._logs[numberOfLogsToSend]) + sizeOfLogsToSend < requestSizeLimit) {
sizeOfLogsToSend += getLogSize(this._logs[numberOfLogsToSend]);
numberOfLogsToSend++;
}
if (!numberOfLogsToSend) {
this._logs[numberOfLogsToSend].message = this._logs[numberOfLogsToSend].message.substring(
0,
getLogSize(this._logs[numberOfLogsToSend]) + (requestSizeLimit - getLogSize(this._logs[numberOfLogsToSend]))
);
numberOfLogsToSend = 1;
}
const logMessages = this._logs.slice(0, numberOfLogsToSend);
this._logs = this._logs.slice(numberOfLogsToSend);
return this.sendLogs(logMessages)
.then(response => {
this._isSending = false;
// @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble();
return response;
})
.catch(() => {
this._isSending = false;
// @ts-expect-error: Unused variable intentionally
const ignored = this.sendLogsIfAble();
});
}
}

13
src/store/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import {useDispatch, useSelector} from 'react-redux';
import store from './store';
export default store;
// Use throughout the app instead of plain `useDispatch` and `useSelector`
// Infer the `RootState`, `AppDispatch`, and `AppStore` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: C
export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store;
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

View File

@@ -0,0 +1,169 @@
import {createSlice, PayloadAction, createAsyncThunk, createSelector} from '@reduxjs/toolkit';
import AuthenticationService from '../../services/Authentication.service';
import {PhenixWebSocketStatusType} from 'services/net/websockets/PhenixWebSocketStatus';
import {IPhenixWebSocketResponse} from 'services/net/websockets/PhenixWebSocket';
export interface IAuthenticationState {
applicationId: string | null;
secret: string | null;
isAuthenticated: boolean;
isLoading: boolean;
status: PhenixWebSocketStatusType;
error: string | null;
sessionId: string | null;
roles: string[];
}
const initialAuthenticationState: IAuthenticationState = {
applicationId: null,
sessionId: null,
isAuthenticated: false,
isLoading: false,
status: 'Offline',
roles: [],
error: null,
secret: null
};
// Memoized selectors
export const selectAuthentication = (state: {authentication: IAuthenticationState}) => state.authentication;
export const selectIsLoading = createSelector([selectAuthentication], authentication => authentication.isLoading);
export const selectIsAuthenticated = createSelector([selectAuthentication], authentication => authentication.isAuthenticated);
export const selectError = createSelector([selectAuthentication], authentication => authentication.error);
export const selectStatus = createSelector([selectAuthentication], authentication => authentication.status);
export const selectCredentials = createSelector([selectAuthentication], authentication => ({
id: authentication.applicationId,
secret: authentication.secret
}));
export const selectSessionInfo = createSelector([selectAuthentication], authentication => ({
sessionId: authentication.sessionId,
roles: authentication.roles
}));
const authenticateCredentialsThunk = createAsyncThunk<IPhenixWebSocketResponse, {applicationId: string; secret: string}>(
'authentication/authenticate',
async (credentials, {rejectWithValue}) => {
try {
const response = await AuthenticationService.authenticate(credentials.applicationId, credentials.secret);
return response as IPhenixWebSocketResponse;
} catch (error) {
// Convert error to serializable format
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
return rejectWithValue(errorMessage);
}
}
);
const signoutThunk = createAsyncThunk('authentication/signout', async (_, {rejectWithValue}) => {
try {
return await AuthenticationService.signout();
} catch (error) {
// Convert error to serializable format
const errorMessage = error instanceof Error ? error.message : 'Signout failed';
return rejectWithValue(errorMessage);
}
});
const authenticationSlice = createSlice({
name: 'authentication',
initialState: {...initialAuthenticationState},
reducers: {
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setCredentials: (state, action: PayloadAction<{applicationId: string; secret: string}>) => {
state.applicationId = action.payload.applicationId;
state.secret = action.payload.secret;
},
clearState: state => {
state.applicationId = null;
state.sessionId = null;
state.isAuthenticated = false;
state.isLoading = false;
state.error = null;
state.secret = null;
state.status = 'Offline';
state.roles = [];
},
setSessionId: (state, action: PayloadAction<string>) => {
state.sessionId = action.payload;
},
setIsAuthenticated: (state, action: PayloadAction<boolean>) => {
state.isAuthenticated = action.payload;
},
setRoles: (state, action: PayloadAction<string[]>) => {
state.roles = action.payload;
},
setApplicationId: (state, action: PayloadAction<string>) => {
state.applicationId = action.payload;
}
},
extraReducers: builder => {
builder
.addCase(authenticateCredentialsThunk.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(authenticateCredentialsThunk.fulfilled, (state, action) => {
const authenticationResponse = action.payload;
if (authenticationResponse.status === 'ok') {
state.applicationId = authenticationResponse.applicationId ?? null;
state.sessionId = authenticationResponse.sessionId ?? null;
state.isAuthenticated = true;
state.roles = authenticationResponse.roles ?? [];
} else {
state.applicationId = null;
state.sessionId = null;
state.isAuthenticated = false;
state.secret = null;
state.roles = [];
}
state.status = 'Online';
state.isLoading = false;
state.error = null;
})
.addCase(authenticateCredentialsThunk.rejected, (state, action) => {
state.applicationId = null;
state.sessionId = null;
state.isAuthenticated = false;
state.isLoading = false;
state.error = (action.payload as string) || 'Authentication failed';
state.secret = null;
state.status = 'Offline';
state.roles = [];
})
.addCase(signoutThunk.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(signoutThunk.fulfilled, state => {
state.isAuthenticated = false;
state.isLoading = false;
state.error = null;
state.secret = null;
state.status = 'Offline';
state.roles = [];
})
.addCase(signoutThunk.rejected, (state, action) => {
state.isAuthenticated = false;
state.isLoading = false;
state.error = (action.payload as string) || 'Signout failed';
state.secret = null;
state.status = 'Offline';
state.roles = [];
});
}
});
export const {setIsLoading, setCredentials, clearState, setSessionId, setIsAuthenticated, setRoles, setApplicationId} = authenticationSlice.actions;
export {authenticateCredentialsThunk};
export default authenticationSlice.reducer;

10
src/store/store.ts Normal file
View File

@@ -0,0 +1,10 @@
import {configureStore} from '@reduxjs/toolkit';
import AuthenticationState from './slices/Authentication.slice';
const store = configureStore({
reducer: {
authentication: AuthenticationState
}
});
export default store;

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />