Initial commit

This commit is contained in:
2025-10-22 22:06:16 +02:00
commit d8ca4e154f
141 changed files with 32231 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { Main } from './main';
export class Calibration {
private _Main: Main;
container: HTMLDivElement = document.querySelector('.ntsh_calibration');
image: HTMLImageElement = this.container.querySelector('img');
fullscreenButton: HTMLDivElement = this.container.querySelector(
'.ntsh_calibration-fullscreen'
);
constructor(Main: Main) {
this._Main = Main;
this.registerListeners();
}
private fetchClock: NodeJS.Timeout;
startFetchClock() {
this.image.src = `/calibrationImage?t=${Date.now()}`;
this.fetchClock = setInterval(() => {
this.image.src = `/calibrationImage?t=${Date.now()}`;
}, 1000);
}
stopFetchClock() {
clearInterval(this.fetchClock);
}
private registerListeners() {
this._Main.TabController.registerListener('calibration', (visible) => {
if (visible) this.startFetchClock();
else this.stopFetchClock();
});
this.fullscreenButton.addEventListener('click', () => {
this.image.requestFullscreen();
});
}
}

View File

@@ -0,0 +1,192 @@
import { MorphFeature } from 'morphux';
import { Main } from './main';
import {
formatUptime,
ServiceState,
setStatusState,
StatusType,
} from './utils';
const CELCIUS = 'ºC';
export class DashboardCameraRunner {
private _Main: Main;
container: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-camerarunner'
);
connectionStatus: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-connectionstatus'
);
connectionInfo: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-connectioninfo'
);
rebootButton: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-reboot'
);
processStatus: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-processstatus'
);
processInfo: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-processinfo'
);
restartButton: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-restart'
);
uptimeInfo: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-uptime'
);
errorContainer: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-error'
);
errorText: HTMLDivElement = this.container.querySelector(
'.ntsh_dashboard-camerarunner-errortext'
);
constructor(Main: Main) {
this._Main = Main;
this.registerListeners();
}
updateState(state: CameraRunnerStatus) {
// ----------- Connection -----------
setStatusState(
this.connectionStatus,
{
CONNECTING: 'yellow',
CONNECTED: 'green',
DISCONNECTED: 'red',
FAILED: 'red',
}[state.state] as StatusType
);
this.connectionInfo.innerText = state.message ?? '';
// ----------- Process -----------
if (state.state != 'CONNECTED') {
state.processStatus.state = 'STOPPED';
state.processStatus.message = 'Not connected';
state.processStatus.startTime = -1;
this.restartButton.style.display = 'none';
this.rebootButton.style.display = 'none';
} else {
this.rebootButton.style.display = 'flex';
if (state.processStatus.state == 'RUNNING')
this.restartButton.style.display = 'flex';
else this.restartButton.style.display = 'none';
}
setStatusState(
this.processStatus,
{
RUNNING: 'green',
STOPPED: 'gray',
STARTING: 'yellow',
PROBLEM: 'red',
}[state.processStatus.state] as StatusType
);
this.processInfo.innerText = state.processStatus.message ?? '';
// ----------- Uptime -----------
const uptimeSeconds =
state.processStatus.startTime == -1
? -1
: (Date.now() - state.processStatus.startTime) / 1000;
this.uptimeInfo.innerText = formatUptime(uptimeSeconds);
// ----------- Error -----------
const errors: string[] = [];
if ((state?.error ?? '').trim().length > 0) errors.push(state.error);
if ((state?.processStatus?.error ?? '').trim().length > 0)
errors.push(state.processStatus.error);
if (errors.length > 0) {
this.errorText.innerText = errors.join('\n');
this.errorContainer.style.display = 'block';
} else {
this.errorContainer.style.display = 'none';
this.errorText.innerText = '';
}
this._Main.Logs.setCameraLogs(state.processStatus.output.current);
}
registerListeners() {
this._Main.socket.on(
'cameraRunnerState',
(state: CameraRunnerStatus) => {
this.updateState(state);
}
);
this.restartButton.addEventListener('click', async () => {
this.executeCommand(
'restart',
'Are you sure you want to restart the Camera Runner process?'
);
});
this.rebootButton.addEventListener('click', async () => {
this.executeCommand(
'reboot',
'Are you sure you want to reboot the Camera Runner machine?'
);
});
}
private async executeCommand(command: string, message: string) {
const confirmed = await MorphFeature.Confirm({
title: 'Are you sure?',
message,
});
if (!confirmed) return;
MorphFeature.Loader({
active: true,
message: `Requesting Camera Runner ${command}...`,
});
this._Main.socket.emit(
'cameraRunner',
command,
(response: { succeed: boolean; message?: string }) => {
MorphFeature.Loader({ active: false });
if (!response.succeed)
return MorphFeature.Alert({
title: 'Error',
message: response.message,
});
MorphFeature.Notification({
level: 'success',
message: `Camera Runner is ${command}ing...`,
});
}
);
}
}
interface CameraRunnerStatus {
state: ServiceState;
message?: string;
error?: string;
processStatus: ProcessStatus;
}
export type ProcessStatusState = 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
interface ProcessStatusSimple {
state: ProcessStatusState;
message?: string;
error?: string;
}
interface ProcessStatus extends ProcessStatusSimple {
startTime: number;
output: { current: string[]; last: string[] };
}

View File

@@ -0,0 +1,353 @@
import { ce, MorphComponent, MorphFeature } from 'morphux';
import { Main } from './main';
import {
createProgress,
formatUptime,
ServiceState,
setProgressState,
setStatusState,
StatusType,
} from './utils';
export class DashboardUnity {
private _Main: Main;
container: HTMLDivElement = document.querySelector('.ntsh_dashboard-unity');
processStatus: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-processstatus'
);
processInfo: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-processinfo'
);
restartButton: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-restart'
);
uptimeInfo: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-uptime'
);
webSocketStatus: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-websocketstatus'
);
webSocketInfo: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-websocketinfo'
);
zedStreamStatus: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-zedstreamstatus'
);
zedStreamInfo: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-zedstreaminfo'
);
zedStreamFps: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-zedstreamfps'
);
zedStreamPath: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-zedstreampath'
);
timelineWatching: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-timeline-watching'
);
timelineStanding: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-timeline-standing'
);
timelineProgress: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-timeline-progress'
);
parametersTable: HTMLTableElement = document.querySelector(
'.ntsh_dashboard-unity-parameters'
);
errorContainer: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-error'
);
errorText: HTMLDivElement = document.querySelector(
'.ntsh_dashboard-unity-errortext'
);
constructor(Main: Main) {
this._Main = Main;
this.registerListeners();
}
private runnerError: string;
updateRunnerState(state: UnityRunnerStatus) {
// ----------- Process -----------
if (state.state != 'RUNNING') {
state.startTime = -1;
this.restartButton.style.display = 'none';
} else {
this.restartButton.style.display = 'flex';
}
setStatusState(
this.processStatus,
{
RUNNING: 'green',
STOPPED: 'red',
STARTING: 'yellow',
PROBLEM: 'red',
}[state.state] as StatusType
);
this.processInfo.innerText = state.message ?? '';
// ----------- Uptime -----------
const uptimeSeconds =
state.startTime == -1 ? -1 : (Date.now() - state.startTime) / 1000;
this.uptimeInfo.innerText = formatUptime(uptimeSeconds);
// ----------- Error -----------
if ((state?.error ?? '').trim().length > 0)
this.runnerError = state.error;
else this.runnerError = null;
this.updateError();
this._Main.Logs.setUnityLogs(state.output.current);
}
private webSocketError: string;
updateWebSocketState(state: UnityWebSocketStatus) {
// ----------- WebSocket -----------
setStatusState(
this.webSocketStatus,
{
CONNECTING: 'yellow',
CONNECTED: 'green',
DISCONNECTED: 'gray',
FAILED: 'red',
}[state.state] as StatusType
);
this.webSocketInfo.innerText = state.message ?? '';
// ----------- ZED Stream -----------
setStatusState(
this.zedStreamStatus,
state.parameters.zedReady ? 'green' : 'red'
);
this.zedStreamInfo.innerText = state.parameters.zedReady
? `Connected to ${state.parameters.zedPath}`
: 'Not ready';
this.zedStreamFps.innerText =
state.parameters.zedFPS == '-' ? '' : state.parameters.zedFPS;
// ----------- Timeline -----------
this.timelineWatching.innerText = state.parameters.timelineWatching
? 'Yes'
: 'No';
this.timelineStanding.innerText = state.parameters.timelineStanding
? 'Yes'
: 'No';
setProgressState(
this.timelineProgress,
state.parameters.timelineProgress
);
// ----------- Parameters -----------
this.renderParameterSliders(state.parameters.parameters);
// ----------- Error -----------
if ((state?.error ?? '').trim().length > 0)
this.webSocketError = state.error;
else this.webSocketError = null;
this.updateError();
}
private renderParameterSliders(parameters: UnityParameters['parameters']) {
if (parameters.length === 0) return;
const existingSliders = this.parametersTable.querySelectorAll(
'.ntsh_dashboard-unity-parameter-row'
);
if (existingSliders.length !== parameters.length) {
this.parametersTable.innerHTML = '';
parameters.forEach((param) => {
const row = ce('tr', 'ntsh_dashboard-unity-parameter-row');
const nameCell = ce('td');
nameCell.appendChild(
ce('div', 'mux_text', null, param.sliderName)
);
row.appendChild(nameCell);
const progressCell = ce('td', 'no-service');
progressCell.appendChild(createProgress(param.outputValue));
row.appendChild(progressCell);
const sliderCell = ce('td', 'only-service');
const sliderProgress = createProgress(param.outputValue);
const sliderValue: HTMLDivElement =
sliderProgress.querySelector('.ntsh_progress-value');
sliderValue.classList.add('mux_resizer');
sliderCell.appendChild(sliderProgress);
const resizer = new MorphComponent.Resizer({
existingContainer: sliderValue,
direction: 'right',
relative: true,
min: 0,
max: () => sliderProgress.clientWidth,
});
let lastPercentage: number = -1;
resizer.on('resized', (size) => {
const percentage =
Math.round((size / sliderProgress.clientWidth) * 100) /
100;
if (percentage === lastPercentage) return;
lastPercentage = percentage;
this._Main.socket.emit(
'unityWebSocket',
'parameterValue',
param.sliderIndex,
percentage
);
setProgressState(sliderProgress, percentage);
});
row.appendChild(sliderCell);
this.parametersTable.appendChild(row);
});
} else {
existingSliders.forEach((row, index) => {
const value = parameters[index].outputValue;
const progressElement: HTMLDivElement = row.querySelector(
'.no-service .ntsh_progress'
);
setProgressState(progressElement, value);
const sliderElement: HTMLDivElement = row.querySelector(
'.only-service .ntsh_progress'
);
if (sliderElement.querySelector('.mux_resizer-moving') == null)
setProgressState(sliderElement, value);
});
}
}
private updateError() {
const errors: string[] = [];
if (this.runnerError != null) errors.push(this.runnerError);
if (this.webSocketError != null) errors.push(this.webSocketError);
if (errors.length > 0) {
this.errorText.innerText = errors.join('\n');
this.errorContainer.style.display = 'block';
} else {
this.errorContainer.style.display = 'none';
this.errorText.innerText = '';
}
}
registerListeners() {
this._Main.socket.on('unityRunnerState', (state: UnityRunnerStatus) =>
this.updateRunnerState(state)
);
this._Main.socket.on(
'unityWebSocketState',
(state: UnityWebSocketStatus) => this.updateWebSocketState(state)
);
this.restartButton.addEventListener('click', async () => {
this.executeCommand(
'restart',
'Are you sure you want to restart the Unity Runner process?'
);
});
}
private async executeCommand(command: string, message: string) {
const confirmed = await MorphFeature.Confirm({
title: 'Are you sure?',
message,
});
if (!confirmed) return;
MorphFeature.Loader({
active: true,
message: `Requesting Unity Runner ${command}...`,
});
this._Main.socket.emit(
'unityRunner',
command,
(response: { succeed: boolean; message?: string }) => {
MorphFeature.Loader({ active: false });
if (!response.succeed)
return MorphFeature.Alert({
title: 'Error',
message: response.message,
});
MorphFeature.Notification({
level: 'success',
message: `Unity Runner is ${command}ing...`,
});
}
);
}
}
interface UnityRunnerStatus {
state: 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM';
message?: string;
error?: string;
startTime: number;
output: { current: string[]; last: string[] };
}
interface UnityWebSocketStatus {
state: ServiceState;
message?: string;
error?: string;
parameters: UnityParameters;
}
interface UnityParameters {
timelineWatching: boolean;
timelineStanding: boolean;
timelineProgress: number;
zedPath: string;
zedReady: boolean;
zedFPS: string;
parameters: UnitySocketMessageHeartbeat['heartbeat']['dataSliders'];
}
interface UnitySocketMessageBase {
type: string;
timestamp: number;
}
interface UnitySocketMessageHeartbeat extends UnitySocketMessageBase {
type: 'heartbeat_data';
heartbeat: {
dataSliders: {
sliderIndex: number;
sliderName: string;
outputValue: number;
}[];
dataTimeline: {
isStanding: boolean;
isWatching: boolean;
timelineProgress: number;
};
zedCamera: {
cameraFPS: string;
isZedReady: boolean;
streamInputIP: string;
streamInputPort: number;
zedGrabError: number;
};
};
}

View File

@@ -0,0 +1,96 @@
import { ce } from 'morphux';
import { delay } from './utils';
import { Main } from './main';
import { AnsiUp } from 'ansi_up';
export class LogsHandler {
private _Main: Main;
ansiUp = new AnsiUp();
cameraLogsContainer: HTMLDivElement = document.querySelector(
'.ntsh_tab[tabid="cameralogs"] .ntsh_logs'
);
unityLogsContainer: HTMLDivElement = document.querySelector(
'.ntsh_tab[tabid="unitylogs"] .ntsh_logs'
);
constructor(Main: Main) {
this._Main = Main;
this.registerListeners();
}
setCameraLogs(logs: string[]) {
this.applyLogs(this.cameraLogsContainer, logs);
}
setUnityLogs(logs: string[]) {
this.applyLogs(this.unityLogsContainer, logs);
}
private firstTime: Set<HTMLDivElement> = new Set();
private async applyLogs(container: HTMLDivElement, logs: string[]) {
let logCount = container.querySelectorAll(
'.ntsh_log.ntsh_log-normal'
).length;
if (logCount === logs.length) return;
if (logCount > logs.length) logCount = 0;
const isAtBottom =
!this.firstTime.has(container) ||
container.scrollTop + container.clientHeight >=
container.scrollHeight;
container.innerHTML = '';
logs.forEach((log, i) => {
const element = ce(
'div',
['ntsh_log', 'ntsh_log-normal'],
null,
null,
this.ansiUp.ansi_to_html(log)
);
if (this.firstTime.has(container) && i > logCount - 1) {
element.style.display = 'none';
const spawnDelay = (i - logCount) * 10;
setTimeout(() => {
element.style.display = 'block';
if (isAtBottom)
container.scrollTop = container.scrollHeight;
}, spawnDelay);
}
container.appendChild(element);
if (isAtBottom) container.scrollTop = container.scrollHeight;
});
if (!this.firstTime.has(container)) {
container.scrollTop = container.scrollHeight;
this.firstTime.add(container);
}
}
private registerListeners() {
this._Main.TabController.registerListener('cameralogs', (visible) => {
if (!visible) return;
setTimeout(() => {
this.cameraLogsContainer.scrollTop =
this.cameraLogsContainer.scrollHeight;
}, 10);
});
this._Main.TabController.registerListener('unitylogs', (visible) => {
if (!visible) return;
setTimeout(() => {
this.unityLogsContainer.scrollTop =
this.unityLogsContainer.scrollHeight;
}, 10);
});
}
}

View File

@@ -0,0 +1,37 @@
import { io } from 'socket.io-client';
import { MenuBar } from './menuBar';
import { TabController } from './tabController';
import { MorphFeature } from 'morphux';
import { DashboardCameraRunner } from './dashboard.camerarunner';
import { LogsHandler } from './logsHandler';
import { Calibration } from './calibration';
import { DashboardUnity } from './dashboard.unity';
const socket = io('/');
export class Main {
socket = socket;
MenuBar = new MenuBar(this);
TabController = new TabController(this);
Logs = new LogsHandler(this);
Calibration = new Calibration(this);
DashboardCameraRunner = new DashboardCameraRunner(this);
DashboardUnityRunner = new DashboardUnity(this);
constructor() {
if (window.location.search.includes('debug')) {
(window as any).SN = this;
console.log('Debug mode enabled');
}
}
}
MorphFeature.Loader({ active: true, message: 'Connecting to server...' });
socket.on('connect', () => {
console.log('Connected to server');
MorphFeature.Loader({ active: false });
});
const _Main = new Main();

View File

@@ -0,0 +1,142 @@
import { MorphComponent, MorphFeature } from 'morphux';
import { Main } from './main';
import { ComponentMenuBar } from 'morphux/dist/Components/MenuBar/Component.MenuBar';
export class MenuBar {
private _Main: Main;
container: HTMLDivElement = document.querySelector('.ntsh_menubar');
menubar: ComponentMenuBar;
constructor(main: Main) {
this._Main = main;
this.build();
setTimeout(() => {
if (localStorage?.getItem('serviceMode') === 'true')
this.toggleServiceMode(true, true);
}, 10);
}
build() {
this.menubar = new ComponentMenuBar({
left: [
{
type: 'image',
url: '/img/morphix_logo_white.png',
},
{
type: 'normal',
text: 'Dashboard',
materialIcon: 'dashboard',
uniqueIdentifier: 'dashboard',
selected: true,
click: () => this._Main.TabController.showTab('dashboard'),
},
{
type: 'normal',
text: 'Calibration',
uniqueIdentifier: 'calibration',
materialIcon: 'crop_free',
click: () =>
this._Main.TabController.showTab('calibration'),
},
{
type: 'normal',
text: 'Unity Logs',
uniqueIdentifier: 'unitylogs',
materialIcon: 'deployed_code',
click: () => this._Main.TabController.showTab('unitylogs'),
},
{
type: 'normal',
text: 'Camera Logs',
uniqueIdentifier: 'cameralogs',
materialIcon: 'photo_camera',
click: () => this._Main.TabController.showTab('cameralogs'),
},
],
right: [
{
type: 'normal',
text: document.body.classList.contains('ntsh_service')
? 'Exit Service'
: 'Service Mode',
uniqueIdentifier: 'serviceMode',
materialIcon: document.body.classList.contains(
'ntsh_service'
)
? 'logout'
: 'engineering',
click: async () => {
const mobileMenu: HTMLDivElement =
document.querySelector('.mux_mobilemenu');
mobileMenu?.click();
this.toggleServiceMode();
},
},
],
});
this.container.innerHTML = '';
this.container.appendChild(this.menubar.container);
}
async toggleServiceMode(
mode?: boolean,
skipPin?: boolean
): Promise<boolean> {
const newMode =
mode ?? !document.body.classList.contains('ntsh_service');
if (newMode) {
if (skipPin !== true) {
const servicePin: string = await MorphFeature.Prompt({
title: 'Service Mode',
message: 'Enter the service PIN:',
type: 'number',
canBeEmpty: false,
placeholder: '****',
});
if (servicePin !== '4252') {
MorphFeature.Alert({
title: 'Error',
message: 'Incorrect PIN provided.',
});
return false;
}
}
document.body.classList.add('ntsh_service');
localStorage.setItem('serviceMode', 'true');
MorphFeature.Notification({
level: 'success',
message: 'Service mode activated.',
});
} else {
document.body.classList.remove('ntsh_service');
this._Main.TabController.showTab('dashboard');
localStorage.setItem('serviceMode', 'false');
MorphFeature.Notification({
level: 'success',
message: 'Service mode deactivated.',
});
}
this.build();
return true;
}
}

View File

@@ -0,0 +1,88 @@
import { MorphFeature } from 'morphux';
import { Main } from './main';
export type Tabs = 'dashboard' | 'calibration' | 'cameralogs' | 'unitylogs';
export class TabController {
private _Main: Main;
container: HTMLDivElement = document.querySelector('.ntsh_tabs');
currentTabId: Tabs;
constructor(main: Main) {
this._Main = main;
this.registerNavigationListener();
}
private tabListeners: Map<Tabs, (visible: boolean) => void> = new Map();
registerListener(tabId: Tabs, callback: (visible: boolean) => void) {
if (this.tabListeners.has(tabId))
throw new Error(
`Listener for tab id ${tabId} has already been registered!`
);
this.tabListeners.set(tabId, callback);
callback(this.currentTabId == tabId);
}
async showTab(tabId: Tabs) {
if (this.currentTabId == tabId) return;
if (
tabId !== 'dashboard' &&
!document.body.classList.contains('ntsh_service')
) {
const confirmed = await MorphFeature.Confirm({
title: 'Service Mode Required',
message: `You need to be in service mode to access tab ${tabId}, switch to Service Mode now?`,
});
if (!confirmed) return this.showTab('dashboard');
const succeed = await this._Main.MenuBar.toggleServiceMode(true);
if (!succeed) return this.showTab('dashboard');
}
this._Main.MenuBar.menubar.setSelected(tabId);
if (this.tabListeners.has(this.currentTabId))
this.tabListeners.get(this.currentTabId)(false);
if (this.tabListeners.has(tabId)) this.tabListeners.get(tabId)(true);
this.currentTabId = tabId;
this.container
.querySelectorAll('.ntsh_tab')
.forEach((tab) => tab.classList.remove('ntsh_tab-visible'));
this.container
.querySelector(`.ntsh_tab[tabid="${tabId}"]`)
.classList.add('ntsh_tab-visible');
window.history.pushState(
{ tab: tabId },
'',
`/${tabId}${window.location.search}`
);
var tabName = {
dashboard: 'Dashboard',
calibration: 'Calibration',
cameralogs: 'Camera Logs',
unitylogs: 'Unity Logs',
}[tabId];
document.title = `NTSH Control - ${tabName}`;
}
private registerNavigationListener() {
window.addEventListener('popstate', (event) => {
const state = event.state;
if (state && state.tab) this.showTab(state.tab);
});
var startTab = window.location.pathname
.replace('/', '')
.split('/')
.shift() as Tabs;
this.showTab(startTab.length > 0 ? startTab : 'dashboard');
}
}

View File

@@ -0,0 +1,81 @@
import { ce } from 'morphux';
/**
* A linear interpolation helper function.
* @param a The start value.
* @param b The end value.
* @param t The interpolation factor (0 to 1).
* @returns The interpolated value.
*/
export const lerp = (a: number, b: number, t: number): number =>
a + (b - a) * t;
export const setStatusState = (
statusElement: HTMLDivElement,
state: StatusType
) => {
statusElement.classList.remove(
'ntsh_status-green',
'ntsh_status-yellow',
'ntsh_status-red',
'ntsh_status-gray'
);
statusElement.classList.add(`ntsh_status-${state}`);
};
export type StatusType = 'green' | 'yellow' | 'red' | 'gray';
export const setProgressState = (
progressElement: HTMLDivElement,
percentage: number
) => {
const value: HTMLDivElement = progressElement.querySelector(
'.ntsh_progress-value'
);
value.style.width = `${percentage * 100}%`;
const label: HTMLDivElement = progressElement.querySelector(
'.ntsh_progress-label'
);
label.innerText = `${Math.round(percentage * 100)}%`;
};
export const createProgress = (value: number) => {
const progress = ce('div', 'ntsh_progress');
progress.appendChild(ce('div', 'ntsh_progress-value'));
progress.appendChild(ce('div', 'ntsh_progress-label'));
setProgressState(progress, value);
return progress;
};
export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export type ServiceState =
| 'CONNECTING'
| 'CONNECTED'
| 'DISCONNECTED'
| 'FAILED';
export function formatUptime(seconds: number): string {
if (seconds < 0) return '';
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
parts.push(
`${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
);
return parts.join(' ');
}
export function delay(duration: number) {
return new Promise((resolve) => setTimeout(resolve, duration));
}