initial commit

This commit is contained in:
Alex Zinn
2025-08-31 16:52:20 -04:00
commit b6717e0cb1
61 changed files with 4192 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
save-exact=true
package-lock=false
@techniker-me:registry=https://registry-node.techniker.me

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 160,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname
}
// other options...
}
}
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname
}
// other options...
}
}
]);
```

10
bunfig.toml Normal file
View File

@@ -0,0 +1,10 @@
[install]
exact = true
saveTextLockfile = false
frozenLockfile = true
[install.lockfile]
save = false
[install.scopes]
"@techniker-me"="https://registry-node.techniker.me"

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import {globalIgnores} from 'eslint/config';
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
'argsIgnorePattern': '^ignored',
'varsIgnorePattern': '^ignored',
'caughtErrorsIgnorePattern': '^ignored'
}
]
}
}
]);

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Phenix Customer Portal" />
<script>
__phenixPageLoadTime = new Date().getTime();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phenix Customer Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "webcontrolcenter",
"private": true,
"version": "2025.2.0",
"type": "module",
"scripts": {
"generate-version": "node scripts/generate-version.js",
"dev": "vite",
"format": "prettier --write ./",
"prelint": "bun run format",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "2.8.2",
"@techniker-me/pcast-api": "2025.1.4",
"@techniker-me/tools": "2025.0.16",
"@types/node": "24.3.0",
"phenix-web-closest-endpoint-resolver": "2020.0.3",
"phenix-web-detect-browser": "2021.0.1",
"phenix-web-proto": "2020.0.3",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-redux": "9.2.0"
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react-swc": "4.0.1",
"babel-plugin-transform-amd-to-commonjs": "1.6.0",
"commander": "14.0.0",
"eslint": "9.34.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.3.0",
"prettier": "3.6.2",
"typescript": "5.9.2",
"typescript-eslint": "8.41.0",
"vite": "7.1.3",
"vite-plugin-babel": "1.3.2",
"vite-plugin-commonjs": "0.10.4"
},
"trustedDependencies": [
"@swc/core"
]
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {writeFileSync} from 'fs';
import {join} from 'path';
import {program} from 'commander';
import packageJson from './../package.json' with {type: 'json'};
program.option('-e --environment <environment>', 'Specific environment (optional)');
program.parse(process.argv);
const {environment} = program.opts();
const prefix = environment ? `${environment}-` : 'local-';
const version = `${prefix}${new Date().toISOString()} (${packageJson.version})`;
const fileLocation = join(process.cwd(), 'src', 'config', 'version.json');
const controlVersion = {version};
writeFileSync(fileLocation, JSON.stringify(controlVersion, null, 2));
console.log(`Generated control center version [${version}]`);

47
src/App.tsx Normal file
View File

@@ -0,0 +1,47 @@
import {useAppSelector, useAppDispatch} from 'hooks/store';
import {WebSocketStatusViewComponent, LoginForm} from './components';
import AuthenticationService from './services/authentication.service';
import {useEffect, useState} from 'react';
import {authenticationActions, selectError} from 'store/slices/Authentication.slice';
import hostService from 'services/url.service';
import LoggerFactory from './services/logger/LoggerFactory';
export default function App() {
const dispatch = useAppDispatch();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Initialize logger after all modules are loaded to avoid circular dependencies
LoggerFactory.applyLoggerConfig();
// Set WebSocket URI only once during initialization
if (!isInitialized) {
AuthenticationService.setWebSocketUri(hostService.getWebSocketUrl());
setIsInitialized(true);
}
// Subscribe to WebSocket status changes
AuthenticationService.status?.subscribe(status =>
dispatch(authenticationActions.setStatus(status))
);
}, [dispatch, isInitialized]);
const error = useAppSelector(selectError);
return (
<>
{error && <div>{error}</div>}
<div
style={{
width: '200px',
height: '100px',
display: 'flex',
flexDirection: 'column',
margin: 'auto'
}}>
<WebSocketStatusViewComponent />
<LoginForm />
</div>
</>
);
}

View File

@@ -0,0 +1,16 @@
import {PropsWithChildren} from 'react';
export interface IFlexContainerProps {
style?: Record<string, string>;
children: PropsWithChildren['children'];
}
export function FlexContainer({style, children}: IFlexContainerProps) {
const flexContainerStyle = {
display: 'flex',
width: '100%',
height: '100%',
...style
};
return <div style={flexContainerStyle}>{children}</div>;
}

View File

@@ -0,0 +1 @@
export * from './FlexContainerComponent';

View File

@@ -0,0 +1,34 @@
import {JSX} from 'react';
export interface IInputFieldProps {
label?: string;
type: 'text' | 'password' | 'number';
placeholder?: string;
style?: Record<string, string>;
disabled?: boolean;
value: string;
onValueChange: (value: string) => void;
isRequired?: boolean;
}
export function InputField({label, type, value, onValueChange, placeholder, style, disabled, isRequired}: IInputFieldProps): JSX.Element {
return (
<>
<label
style={{
display: 'block',
...(style || {})
}}>
{label}
<input
type={type}
value={value}
onChange={e => onValueChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || false}
required={isRequired}
/>
</label>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './InputField';

View File

@@ -0,0 +1,61 @@
import {JSX, useState} from 'react';
import {useAppSelector, useAppDispatch} from '../../hooks';
import {authenticateCredentialsThunk, selectIsAuthenticating, selectStatus} from '../../store/slices/Authentication.slice';
import {InputField} from '../InputField';
export function LoginForm(): 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 isAuthenticating = useAppSelector(selectIsAuthenticating);
const connectionStatus = useAppSelector(selectStatus);
// Only allow authentication when WebSocket is Online
const isConnectionReady = connectionStatus === 'Online';
const isDisabled = isAuthenticating || !isConnectionReady;
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isConnectionReady) {
alert('Please wait for WebSocket connection to be established before attempting to authenticate.');
return;
}
dispatch(authenticateCredentialsThunk({applicationId, secret}));
setApplicationId('');
setSecret('');
};
return (
<>
<form onSubmit={handleSubmit}>
<InputField
type="text"
placeholder="Application Id"
value={applicationId}
onValueChange={value => setApplicationId(value)}
disabled={isDisabled}
isRequired={true}
/>
<InputField
type="password"
placeholder="secret"
value={secret}
onValueChange={value => setSecret(value)}
disabled={isDisabled}
isRequired={true}
/>
<button type="submit" disabled={isDisabled}>
{isConnectionReady ? 'Login' : 'Connecting...'}
</button>
{!isConnectionReady && (
<div style={{color: 'orange', fontSize: '12px', marginTop: '5px'}}>
Waiting for WebSocket connection...
</div>
)}
</form>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './LoginForm';

View File

@@ -0,0 +1,9 @@
import {useAppSelector} from 'hooks/store';
import {selectStatus} from 'store/slices/Authentication.slice';
export function WebSocketStatusViewComponent() {
const storeStatus = useAppSelector(selectStatus);
const displayStatus = storeStatus ?? 'unknown';
return <>Status: {displayStatus}</>;
}

View File

@@ -0,0 +1 @@
export * from './WebSocketStatusViewComponent';

4
src/components/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './InputField';
export * from './LoginForm';
export * from './FlexContainer';
export * from './WebSocketStatusView';

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 'config/version.json';
export default {controlCenterVersion: controlVersion['version']};

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

@@ -0,0 +1,3 @@
{
"version": "local-2025-08-31T19:58:27.686Z (2025.2.0)"
}

1
src/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './store';

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

@@ -0,0 +1,10 @@
import {useDispatch, useSelector} from 'react-redux';
import {store} from '../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>();

74
src/index.css Normal file
View File

@@ -0,0 +1,74 @@
: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;
}
#root {
width: 100vw;
height: 100vh;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
text-align: 'center';
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}]`);
}

11
src/main.tsx Normal file
View File

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

32
src/routes/index.ts Normal file
View File

@@ -0,0 +1,32 @@
export default {
fetchChannelsUrl: () => '/pcast/channels',
createChannelUrl: () => '/pcast/channel',
deleteChannelUrl: () => '/pcast/channel',
fetchChannelUrl: (channelId: string): string => `/pcast/channel/${channelId}`,
fetchChannelMembersUrl: (channelId: string): string => `/pcast/channel/${channelId}/members`,
fetchRoomsUrl: () => '/pcast/rooms',
deleteRoomUrl: () => '/pcast/room',
createRoomUrl: () => '/pcast/room',
fetchRoomUrl: (roomId: string): string => `/pcast/room/${roomId}`,
fetchRoomMembersUrl: (roomId: string): string => `/pcast/room/${roomId}/members`,
stateOfChannelUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/publishers/count`,
stateOfRoomUrl: (roomId: string): string => `/pcast/room/${encodeURIComponent(roomId)}/publishers/count`,
sendChannelMessageUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/message`,
getChannelMessagesUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/messages`,
getRoomMessagesUrl: (roomId: string): string => `/pcast/room/${encodeURIComponent(roomId)}/messages`,
playlistInformation: () => '/pcast/stream/playlist',
killChannel: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/kill`,
forkChannel: (destinationChannelId: string, sourceChannelId: string): string =>
`/pcast/channel/${encodeURIComponent(destinationChannelId)}/fork/${encodeURIComponent(sourceChannelId)}`,
viewReporting: () => '/pcast/reporting/viewing',
forkHistoryReporting: () => '/pcast/analytics/fork/history',
publishReporting: () => '/pcast/reporting/publishing',
concurrentViewers: () => '/pcast/streams/concurrent',
ingestReports: () => '/pcast/ingest/buffer',
terminateStream: () => '/pcast/stream',
edgeAuth: () => '/pcast/edgeAuth',
authTokenUrl: () => '/pcast/auth',
publishUri: (tenancy: string, publicationType: string): string => `/pcast/${tenancy}/stream/publish/uri/${publicationType}`,
networkThroughput: () => '/pcast/network/throughput',
fetchPlaylist: () => '/pcast/stream/playlist'
};

View File

@@ -0,0 +1,49 @@
import PlatformDetectionService from './platform-detection.service';
import {Strings, Subject, ReadOnlySubject} from '@techniker-me/tools';
import {PhenixWebSocket, PhenixWebSocketStatus, PhenixWebSocketMessage} from 'services/net/PhenixWebSocket';
import config from 'config';
class AuthenticationService {
private static readonly _instance: AuthenticationService = new AuthenticationService();
private readonly _phenixWebSocket: Subject<PhenixWebSocket | null> = new Subject(null);
private readonly _sessionId: string = Strings.random(10);
public static getInstance(): AuthenticationService {
return AuthenticationService._instance;
}
get status(): ReadOnlySubject<PhenixWebSocketStatus> | undefined {
return this._phenixWebSocket.value?.status || undefined;
}
public setWebSocketUri(webSocketUri: string): void {
this._phenixWebSocket.value?.dispose();
this._phenixWebSocket.value = new PhenixWebSocket(webSocketUri);
}
public async authenticateCredentials(applicationId: string, secret: string): Promise<{error: unknown; response: unknown}> {
const authenicate = {
applicationId,
secret,
clientVersion: config.controlCenterVersion,
deviceId: '',
platform: PlatformDetectionService.platform,
platformVersion: PlatformDetectionService.platformVersion,
sessionId: this._sessionId,
authenticationToken: secret
};
return this._phenixWebSocket.value?.sendMessage(PhenixWebSocketMessage.Authenticate, authenicate) as Promise<{error: unknown; response: unknown}>;
}
public async signout(): Promise<{error: unknown; response: unknown}> {
const {error, response} = await (this._phenixWebSocket.value?.sendMessage(PhenixWebSocketMessage.Bye, {sessionId: this._sessionId}) as Promise<{
error: unknown;
response: unknown;
}>);
return {error, response};
}
}
export default AuthenticationService.getInstance();

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: IAppender): 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,197 @@
/**
* 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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args = args.reduce((accumulator: any, currentValue: any, index: number, array: any[]) => {
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,86 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import PlatformDetectionService from 'services/platform-detection.service';
import hostService from 'services/url.service';
import TelemetryConfiguration from 'services/telemetry/TelemetryConfiguration';
import TelemetryAppender from 'services/telemetry/TelemetryApender';
// 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();
private static _initialized: boolean = false;
static get telemetryConfiguration(): TelemetryConfiguration {
return this._telemetryConfiguration;
}
static applyLoggerConfig(): void {
if (this._initialized) return;
LoggerFactory.applyConsoleLogger(LoggingLevel['All']);
LoggerFactory.applyLoggingLevel();
LoggerFactory.applyTelemetryLogger();
this._initialized = true;
}
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> {
this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel;
this._telemetryConfiguration.url = hostService.getTelemetryUrl();
this._telemetryConfiguration.environment = hostService.getBaseUrlOrigin();
// this._telemetryConfiguration.tenancy = await userStore.get('applicationId');
// this._telemetryConfiguration.userId = await userStore.get('applicationId');
// this._telemetryConfiguration.sessionId = await userStore.get('sessionId');
// Lazy access to PlatformDetectionService to avoid circular dependency
try {
this._telemetryConfiguration.browser = PlatformDetectionService.browser
? `${PlatformDetectionService.browser}/${PlatformDetectionService.version}`
: 'unknown';
} catch (error) {
console.warn('Failed to get platform detection info for telemetry:', error);
this._telemetryConfiguration.browser = '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');
}
}

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 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,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,140 @@
import {MQWebSocket} from 'phenix-web-proto';
import {ILogger} from 'services/logger/LoggerInterface';
import LoggerFactory from 'services/logger/LoggerFactory';
import assertUnreachable from 'lang/assertUnreachable';
import {Subject, ReadOnlySubject, IDisposable} from '@techniker-me/tools';
import pcastProtocol from 'services/messaging/pcast.proto.json';
import analyticsProtocol from 'services/messaging/analytics.proto.json';
export enum PhenixWebSocketStatus {
Offline = 0,
Connecting = 1,
Online = 2,
Reconnecting = 3,
Error = 4
}
export type PhenixWebSocketStatusType = 'Offline' | 'Connecting' | 'Online' | 'Reconnecting' | 'Error';
export class PhenixWebSocketStatusMapping {
public static convertPhenixWebSocketStatusToPhenixWebSocketStatusType(status: PhenixWebSocketStatus): PhenixWebSocketStatusType {
switch (status) {
case PhenixWebSocketStatus.Error:
return 'Error';
case PhenixWebSocketStatus.Offline:
return 'Offline';
case PhenixWebSocketStatus.Connecting:
return 'Connecting';
case PhenixWebSocketStatus.Online:
return 'Online';
case PhenixWebSocketStatus.Reconnecting:
return 'Reconnecting';
default:
assertUnreachable(status);
}
}
public static convertPhenixWebSocketStatusTypeToPhenixWebSocketStatus(statusType: PhenixWebSocketStatusType): PhenixWebSocketStatus {
switch (statusType) {
case 'Error':
return PhenixWebSocketStatus.Error;
case 'Offline':
return PhenixWebSocketStatus.Offline;
case 'Connecting':
return PhenixWebSocketStatus.Connecting;
case 'Online':
return PhenixWebSocketStatus.Online;
case 'Reconnecting':
return PhenixWebSocketStatus.Reconnecting;
default:
assertUnreachable(statusType);
}
}
}
export enum PhenixWebSocketMessage {
Authenticate = 'pcast.Authenticate',
Bye = 'pcast.Bye'
}
export class PhenixWebSocket implements IDisposable {
private readonly _logger: ILogger = LoggerFactory.getLogger('PhenixWebSocket');
private readonly _status: Subject<PhenixWebSocketStatus> = new Subject(PhenixWebSocketStatus.Offline);
private readonly _readOnlyStatus: ReadOnlySubject<PhenixWebSocketStatus> = new ReadOnlySubject(this._status);
private readonly _websocketMQ: MQWebSocket;
constructor(uri: string) {
console.log('PhenixWebSocket constructor', uri);
this._websocketMQ = new MQWebSocket(uri, this._logger, [pcastProtocol, analyticsProtocol]);
this.initialize();
}
get status(): ReadOnlySubject<PhenixWebSocketStatus> {
return this._readOnlyStatus;
}
public isConnected(): boolean {
return this._status.value === PhenixWebSocketStatus.Online;
}
public isConnecting(): boolean {
return this._status.value === PhenixWebSocketStatus.Connecting;
}
public isReconnecting(): boolean {
return this._status.value === PhenixWebSocketStatus.Reconnecting;
}
public sendMessage<T>(message: PhenixWebSocketMessage, data: T): Promise<unknown> {
if (!Object.values(PhenixWebSocketMessage).includes(message)) {
throw new Error(`Invalid message: ${message}`);
}
// Check if WebSocket is in a valid state for sending messages
const currentStatus = this._status.value;
if (currentStatus !== PhenixWebSocketStatus.Online) {
return Promise.reject(new Error(`WebSocket is not ready. Current status: ${PhenixWebSocketStatus[currentStatus]}`));
}
return new Promise((resolve, reject) => {
return this._websocketMQ.sendRequest(message, data, (error: unknown, response: unknown) => {
if (error) {
reject(error);
} else {
resolve({error, response});
}
});
});
}
public dispose(): void {
this._websocketMQ.disconnect();
}
private initialize(): void {
this._websocketMQ.onEvent('connecting', () => {
this._logger.info('WebSocket connecting...');
this._status.value = PhenixWebSocketStatus.Connecting;
});
this._websocketMQ.onEvent('connected', () => {
this._logger.info('WebSocket connected successfully');
this._status.value = PhenixWebSocketStatus.Online;
});
this._websocketMQ.onEvent('disconnected', () => {
this._logger.info('WebSocket disconnected');
this._status.value = PhenixWebSocketStatus.Offline;
});
this._websocketMQ.onEvent('reconnecting', () => {
this._logger.info('WebSocket reconnecting...');
this._status.value = PhenixWebSocketStatus.Reconnecting;
});
this._websocketMQ.onEvent('error', (error: unknown) => {
this._logger.error('WebSocket error occurred:', error);
this._status.value = PhenixWebSocketStatus.Error;
});
}
}

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,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,137 @@
/**
* 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();
});
}
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export type UrlType = 'pcast' | 'telemetry' | 'websocket';
export default class UrlService {
private static getBaseUrlOrigin(url: string = window?.location?.href, urlType: UrlType = 'pcast'): string {
if (!url) {
throw new Error('Invalid URL');
}
const baseURL = new URL(url);
const backendUrlParam = baseURL.searchParams.get('backend');
if (backendUrlParam) {
const backendUrl = new URL(backendUrlParam);
if (urlType === 'websocket') {
backendUrl.protocol = 'wss:';
backendUrl.pathname = '/ws';
}
return backendUrl.toString();
}
const segments = baseURL.hostname.split('.');
const prefix = urlType === 'telemetry' ? 'telemetry' : 'pcast';
// Normalize WebSocket protocols to HTTP
switch (baseURL.protocol) {
case 'ws:':
baseURL.protocol = 'http:';
break;
case 'wss:':
baseURL.protocol = 'https:';
break;
default:
break;
}
// Apply routing logic based on URL structure
if (segments.length === 2 || (segments.length === 3 && segments[segments.length - 2].length <= 2 && segments[segments.length - 1].length <= 3)) {
segments.unshift(prefix);
} else if (segments[0].startsWith('stg-') || segments[0].endsWith('-stg') || segments[0].includes('-stg-') || segments[0] === 'stg') {
segments[0] = `${prefix}-stg`;
} else if (segments[0].startsWith('local') || segments[0].endsWith('-local')) {
// Leave URL unchanged for local development
} else {
segments[0] = prefix;
}
baseURL.hostname = segments.join('.');
return baseURL.origin;
}
public static getPcastBaseUrlOrigin(url: string = window?.location?.href): string {
return this.getBaseUrlOrigin(url, 'pcast');
}
public static getTelemetryUrl(url: string = window?.location?.href): string {
try {
const baseUrlOrigin = this.getBaseUrlOrigin(url, 'telemetry');
return `${baseUrlOrigin}/telemetry`;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return url;
}
}
public static getWebSocketUrl(url: string = window?.location?.href): string {
return this.getBaseUrlOrigin(url, 'websocket');
}
}

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

@@ -0,0 +1 @@
export * from './store';

View File

@@ -0,0 +1,12 @@
import { Middleware } from '@reduxjs/toolkit';
const phenixWebSocketMiddleware: Middleware = (storeApi) => (next) => (action) => {
console.log('phenixWebSocketMiddleware', action);
if (typeof action === 'function') {
return action(storeApi.getState(), storeApi.dispatch);
}
return next(action);
};
export default phenixWebSocketMiddleware;

8
src/store/reducer.ts Normal file
View File

@@ -0,0 +1,8 @@
import {combineReducers} from '@reduxjs/toolkit';
import {authenticationReducer} from './slices';
const rootReducer = combineReducers({
authentication: authenticationReducer
});
export default rootReducer;

View File

@@ -0,0 +1,110 @@
import {createSlice, createAsyncThunk, createSelector} from '@reduxjs/toolkit';
import AuthenticationService from 'services/authentication.service';
import {PhenixWebSocketStatusMapping, PhenixWebSocketStatusType} from 'services/net/PhenixWebSocket';
export interface IAuthenticationState {
applicationId: string | null;
secret: string | null;
isAuthenticated: boolean;
isAuthenticating: boolean;
error: string | null;
status: PhenixWebSocketStatusType;
}
const initialAuthenticationState: IAuthenticationState = {
applicationId: null,
secret: null,
isAuthenticated: false,
isAuthenticating: false,
error: null,
status: 'Offline'
};
// Memoized selectors
export const selectAuthentication = (state: {authentication: IAuthenticationState}) => state.authentication;
export const selectIsAuthenticating = createSelector([selectAuthentication], authentication => authentication.isAuthenticating);
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 => ({
applicationId: authentication.applicationId,
secret: authentication.secret
}));
const authenticateCredentialsThunk = createAsyncThunk(
'authentication/authenticate',
async (credentials: {applicationId: string; secret: string}, {rejectWithValue}) => {
return AuthenticationService.authenticateCredentials(credentials.applicationId, credentials.secret).catch(rejectWithValue);
}
);
const authenticationSlice = createSlice({
name: 'authentication',
initialState: {...initialAuthenticationState},
reducers: {
setStatus: (state, action) => {
state.status = PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(action.payload);
},
signout: state => {
state.isAuthenticated = false;
state.isAuthenticating = false;
state.applicationId = null;
state.secret = null;
state.error = null;
},
error: (state, action) => {
// Store only the error message string, not the Error object
if (typeof action.payload === 'string') {
state.error = action.payload;
} else if (action.payload instanceof Error) {
state.error = action.payload.message;
} else if (action.payload && typeof action.payload === 'object' && 'message' in action.payload) {
state.error = String((action.payload as {message: unknown}).message);
} else {
state.error = action.payload ? String(action.payload) : null;
}
}
},
extraReducers: builder => {
builder
.addCase(authenticateCredentialsThunk.pending, state => {
state.isAuthenticating = true;
state.error = null;
})
.addCase(authenticateCredentialsThunk.fulfilled, state => {
state.isAuthenticating = false;
state.error = null;
state.isAuthenticated = true;
})
.addCase(authenticateCredentialsThunk.rejected, (state, action) => {
state.isAuthenticating = false;
// Extract error message string instead of creating new Error object
let errorMessage = 'Authentication failed';
if (action.payload) {
if (action.payload instanceof Error) {
errorMessage = action.payload.message;
} else if (typeof action.payload === 'object' && action.payload !== null && 'message' in action.payload) {
errorMessage = String((action.payload as {message: unknown}).message);
} else if (typeof action.payload === 'string') {
errorMessage = action.payload;
} else {
errorMessage = String(action.payload);
}
}
state.error = errorMessage;
state.isAuthenticated = false;
state.applicationId = null;
state.secret = null;
});
}
});
export const {reducer: authenticationReducer, actions: authenticationActions} = authenticationSlice;
export {authenticateCredentialsThunk};

View File

@@ -0,0 +1 @@
export * from './Authentication.slice';

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

@@ -0,0 +1,7 @@
import {configureStore} from '@reduxjs/toolkit';
import reducer from './reducer';
import phenixWebSocketMiddleware from './middlewares/PhenixWebSocket.middleware';
export const store = configureStore({reducer, middleware: getDefaultMiddleware => getDefaultMiddleware().concat(phenixWebSocketMiddleware)});
export type RootState = ReturnType<typeof store.getState>;

22
src/types/phenix-web-proto.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
declare module 'phenix-web-proto' {
import { ILogger } from 'services/logger/LoggerInterface';
export class MQWebSocket {
constructor(uri: string, logger: ILogger, protocols: any[], apiVersion?: string);
onEvent(eventName: string, handler: (event: unknown) => void): void;
onRequest(requestName: string, handler: (request: unknown) => void): void;
sendRequest(type: string, message: unknown, callback?: (error: unknown, response: unknown) => void, settings?: unknown): void;
sendResponse(requestId: string, type: string, message: unknown, callback?: (error: unknown, response: unknown) => void): void;
disconnect(): void;
disposeOfPendingRequests(): void;
getApiVersion(): string;
}
export class BatchHttpProto {
// Add methods as needed
}
export class MQService {
// Add methods as needed
}
}

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

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

46
tsconfig.app.json Normal file
View File

@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"sourceMap": true,
"noEmit": true,
"noEmitHelpers": true,
"importHelpers": true,
"strictNullChecks": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitAny": true, // TODO(AZ): Fix types to enable setting this to true
"lib": ["dom", "dom.iterable", "es2022"],
"baseUrl": "./src",
"paths": {
// Relative to baseUrl https://www.typescriptlang.org/tsconfig/#paths
"assets/*": ["./assets/*"],
"components/*": ["./components/*"],
"config/*": ["./config/*"],
"constant-data/*": ["./constant-data/*"],
"hooks/*": ["./hooks/*"],
"interfaces/*": ["./interfaces/*"],
"routers/*": ["./routers/*"],
"store/*": ["./store/*"],
"services/*": ["./services/*"],
"lang/*": ["./lang/*"],
"utility/*": ["./utility/*"],
"views/*": ["./views/*"]
},
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", "public"],
"include": ["src", "test"]
}

27
tsconfig.app.json.orig Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

1
tsconfig.app.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/index.ts","./src/components/flexcontainer/flexcontainercomponent.tsx","./src/components/flexcontainer/index.ts","./src/components/inputfield/inputfield.tsx","./src/components/inputfield/index.ts","./src/components/loginform/loginform.tsx","./src/components/loginform/index.ts","./src/components/websocketstatusview/websocketstatusviewcomponent.tsx","./src/components/websocketstatusview/index.ts","./src/config/index.ts","./src/hooks/index.ts","./src/hooks/store.ts","./src/lang/strings.ts","./src/lang/assertunreachable.ts","./src/routes/index.ts","./src/services/authentication.service.ts","./src/services/host-url.service.ts","./src/services/platform-detection.service.ts","./src/services/logger/appenders.ts","./src/services/logger/consoleappender.ts","./src/services/logger/iappender.ts","./src/services/logger/logger.ts","./src/services/logger/loggerdefaults.ts","./src/services/logger/loggerfactory.ts","./src/services/logger/loggerinterface.ts","./src/services/logger/logginglevelmapping.ts","./src/services/logger/loggingthreshold.ts","./src/services/net/phenixwebsocket.ts","./src/services/telemetry/telemetryapender.ts","./src/services/telemetry/telemetryconfiguration.ts","./src/services/telemetry/telemetryservice.ts","./src/store/index.ts","./src/store/reducer.ts","./src/store/store.ts","./src/store/middlewares/phenixwebsocket.middleware.ts","./src/store/slices/authentication.slice.ts","./src/store/slices/index.ts","./src/types/phenix-web-proto.d.ts"],"version":"5.9.2"}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{"path": "./tsconfig.app.json"}, {"path": "./tsconfig.node.json"}]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

33
vite.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import path from 'path';
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react-swc';
import babel from 'vite-plugin-babel';
console.log('process.cwd()', process.cwd());
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
babel({
babelConfig: {
plugins: ['transform-amd-to-commonjs']
}
})
],
resolve: {
alias: {
assets: path.resolve(process.cwd(), 'src', 'assets'),
components: path.resolve(process.cwd(), 'src/components'),
config: path.resolve(process.cwd(), 'src', 'config'),
'constant-data': path.resolve(process.cwd(), 'src', 'constant-data'),
hooks: path.resolve(process.cwd(), 'src', 'hooks'),
interfaces: path.resolve(process.cwd(), 'src', 'interfaces'),
routers: path.resolve(process.cwd(), 'src', 'routers'),
store: path.resolve(process.cwd(), 'src', 'store'),
services: path.resolve(process.cwd(), 'src', 'services'),
lang: path.resolve(process.cwd(), 'src', 'lang'),
utility: path.resolve(process.cwd(), 'src', 'utility'),
views: path.resolve(process.cwd(), 'src', 'views')
}
}
});