tr]:last:border-b-0', className)}
28 | {...props}
29 | />
30 | )
31 | }
32 |
33 | function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
34 | return (
35 |
40 | )
41 | }
42 |
43 | function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
44 | return (
45 | [role=checkbox]]:translate-y-[2px]',
49 | className
50 | )}
51 | {...props}
52 | />
53 | )
54 | }
55 |
56 | function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
57 | return (
58 | | [role=checkbox]]:translate-y-[2px]',
62 | className
63 | )}
64 | {...props}
65 | />
66 | )
67 | }
68 |
69 | function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
70 | return (
71 |
72 | )
73 | }
74 |
75 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
76 |
--------------------------------------------------------------------------------
/src/client/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TabsPrimitive from '@radix-ui/react-tabs'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | function Tabs({ className, ...props }: React.ComponentProps) {
9 | return
10 | }
11 |
12 | function TabsList({ className, ...props }: React.ComponentProps) {
13 | return (
14 |
22 | )
23 | }
24 |
25 | function TabsTrigger({ className, ...props }: React.ComponentProps) {
26 | return (
27 |
35 | )
36 | }
37 |
38 | function TabsContent({ className, ...props }: React.ComponentProps) {
39 | return (
40 |
48 | )
49 | }
50 |
51 | export { Tabs, TabsList, TabsTrigger, TabsContent }
52 |
--------------------------------------------------------------------------------
/src/client/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ))
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
31 |
32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
33 |
--------------------------------------------------------------------------------
/src/client/context/language.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { createContext } from 'react'
4 | import { getOptions, languages, resources } from '@/client/i18n'
5 | import i18next from 'i18next'
6 | import LanguageDetector from 'i18next-browser-languagedetector'
7 | import { initReactI18next } from 'react-i18next'
8 |
9 | const runsOnServerSide = typeof window === 'undefined'
10 |
11 | i18next
12 | .use(initReactI18next)
13 | .use(LanguageDetector)
14 | .use(resources)
15 | .init({
16 | ...getOptions(),
17 | lng: undefined, // let detect the language on client side
18 | preload: runsOnServerSide ? languages : [],
19 | })
20 |
21 | export const LanguageContext = createContext(i18next.language)
22 |
23 | export default function LanguageProvider({ children }: { readonly children: React.ReactNode }) {
24 | return {children}
25 | }
26 |
--------------------------------------------------------------------------------
/src/client/context/query-client.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5 |
6 | const queryClient = new QueryClient()
7 |
8 | export default function QueryWrapper({ children }: { readonly children: React.ReactNode }) {
9 | return {children}
10 | }
11 |
--------------------------------------------------------------------------------
/src/client/context/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
5 |
6 | export function ThemeProvider({ children, ...props }: Readonly>) {
7 | return {children}
8 | }
9 |
--------------------------------------------------------------------------------
/src/client/i18n/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import resourcesToBackend from 'i18next-resources-to-backend'
4 |
5 | export const fallbackLng = 'en'
6 | export const languages = [fallbackLng, 'es', 'de', 'fr', 'it', 'ro']
7 | export const defaultNS = 'translation'
8 | export const cookieName = 'i18next'
9 | export const localStorageName = 'i18nextLng'
10 |
11 | export function getOptions(lng = fallbackLng, ns = defaultNS) {
12 | return {
13 | // debug: true,
14 | supportedLngs: languages,
15 | fallbackLng,
16 | lng,
17 | fallbackNS: defaultNS,
18 | defaultNS,
19 | ns,
20 | }
21 | }
22 |
23 | export const resources = resourcesToBackend(
24 | (language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)
25 | )
26 |
--------------------------------------------------------------------------------
/src/client/i18n/locales/en/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "noDevicesError": "No devices found. Add a new device in settings.",
3 | "updateAvailable": "Update Available",
4 | "device": "Device",
5 | "manufacturer": "Manufacturer",
6 | "model": "Model",
7 | "serial": "Serial",
8 | "currentLoad": "Current Load",
9 | "batteryCharge": "Battery Charge",
10 | "batteryRuntime": "Battery Runtime",
11 | "lastUpdated": "Last Updated",
12 | "docs": "API Docs",
13 | "voltsChart": {
14 | "inputVoltage": "Input Voltage",
15 | "outputVoltage": "Output Voltage",
16 | "nominalInputVoltage": "Nominal Input Voltage",
17 | "nominalOutputVoltage": "Nominal Output Voltage",
18 | "nominalVoltage": "Nominal Voltage"
19 | },
20 | "wattsChart": {
21 | "realpower": "Realpower",
22 | "nominalRealpower": "Nominal Realpower"
23 | },
24 | "grid": {
25 | "key": "Key",
26 | "value": "Value"
27 | },
28 | "sidebar": {
29 | "settings": "Settings",
30 | "disconnect": "Disconnect",
31 | "refresh": "Refresh",
32 | "language": "Language"
33 | },
34 | "settings": {
35 | "manageServers": "Manage Servers",
36 | "influxDb": "InfluxDB",
37 | "influxNotice": "This will only work with Influxdb 2.x",
38 | "influxInterval": "Influx Interval (s)",
39 | "addServer": "Add Server",
40 | "apply": "Apply",
41 | "saved": "Changes saved",
42 | "configExport": "YAML Configuration",
43 | "configExportNotice": "Manually edit the YAML configuration file.",
44 | "download": "Download",
45 | "save": "Save",
46 | "viewConfig": "Click to view configuration.",
47 | "serversNotice": "Username and password are required for commands",
48 | "terminal": "Terminal",
49 | "terminalNotice": "Type \"help\" for a list of commands.",
50 | "selectServer": "Select a server"
51 | },
52 | "connect": {
53 | "server": "Server Address",
54 | "port": "Port",
55 | "username": "Username",
56 | "password": "Password",
57 | "token": "Token",
58 | "org": "Organization",
59 | "bucket": "Bucket",
60 | "connect": "Connect",
61 | "clear": "Clear",
62 | "test": "Test",
63 | "success": "Connection successful",
64 | "error": "Connection failed. Check the logs for more info.",
65 | "testing": "Testing connection..."
66 | },
67 | "theme": {
68 | "dark": "Dark",
69 | "light": "Light",
70 | "system": "System",
71 | "title": "Theme"
72 | },
73 | "serverNotFound": "Host not found. Check your settings.",
74 | "serverRefused": "Connection refused. Is NUT server running?",
75 | "selectDevice": "Select a device",
76 | "loading": "Loading...",
77 | "cancel": "Cancel",
78 | "continue": "Continue",
79 | "actions": {
80 | "title": "Actions",
81 | "batteryTestQuick": {
82 | "title": "Perform Quick Battery Test",
83 | "description": "Would you like to perform a quick battery test on the UPS?",
84 | "successMessage": "Quick battery test started",
85 | "actionText": "Run quick test"
86 | },
87 | "batteryTestDeep": {
88 | "title": "Perform Deep Battery Test",
89 | "description": "Would you like to perform a deep battery test on the UPS?",
90 | "actionText": "Run deep test",
91 | "successMessage": "Deep battery test started"
92 | },
93 | "restart": {
94 | "title": "Restart",
95 | "description": "Would you like to restart the UPS?",
96 | "actionText": "Restart",
97 | "successMessage": "Restarting"
98 | },
99 | "shutdown": {
100 | "title": "Shutdown",
101 | "description": "Would you like to shutdown the UPS?",
102 | "actionText": "Shutdown",
103 | "successMessage": "Shutting down"
104 | },
105 | "beeperDisable": {
106 | "title": "Disable Beeper",
107 | "description": "Would you like to disable the beeper?",
108 | "actionText": "Disable beeper",
109 | "successMessage": "Beeper disabled"
110 | },
111 | "beeperEnable": {
112 | "title": "Enable Beeper",
113 | "description": "Would you like to enable the beeper?",
114 | "actionText": "Enable beeper",
115 | "successMessage": "Beeper enabled"
116 | },
117 | "beeperMute": {
118 | "title": "Mute Beeper",
119 | "description": "Would you like to mute the beeper?",
120 | "actionText": "Mute beeper",
121 | "successMessage": "Beeper muted"
122 | }
123 | },
124 | "voltAmpsChart": {
125 | "nominalPower": "Nominal Power",
126 | "power": "Power"
127 | },
128 | "volts-chart": "Voltage",
129 | "watts-chart": "Real Power",
130 | "volt-amps-chart": "Apparent Power",
131 | "accordion-grid": "UPS Data",
132 | "login": {
133 | "title": "Please log in to continue.",
134 | "username": "Username",
135 | "usernamePlaceholder": "Enter your username",
136 | "password": "Password",
137 | "passwordPlaceholder": "Enter your password",
138 | "submit": "Log in"
139 | },
140 | "logout": "Logout",
141 | "description": "Description",
142 | "status": "Status",
143 | "view": "View",
144 | "details": "Details"
145 | }
146 |
--------------------------------------------------------------------------------
/src/common/constants.ts:
--------------------------------------------------------------------------------
1 | export const upsStatus = {
2 | OL: 'Online',
3 | 'OL CHRG': 'Online Charging',
4 | OB: 'On Battery',
5 | LB: 'Low Battery',
6 | HB: 'High Battery',
7 | RB: 'Battery Needs Replacement',
8 | CHRG: 'Battery Charging',
9 | DISCHRG: 'Battery Discharging',
10 | BYPASS: 'Bypass Active',
11 | CAL: 'Runtime Calibration',
12 | OFF: 'Offline',
13 | OVER: 'Overloaded',
14 | TRIM: 'Trimming Voltage',
15 | BOOST: 'Boosting Voltage',
16 | FSD: 'Forced Shutdown',
17 | ALARM: 'Alarm',
18 | DEVICE_UNREACHABLE: 'Device Unreachable',
19 | }
20 |
21 | const COMMAND_BEEPER_DISABLE = 'beeper.disable'
22 | const COMMAND_BEEPER_ENABLE = 'beeper.enable'
23 | const COMMAND_BEEPER_MUTE = 'beeper.mute'
24 | const COMMAND_BEEPER_TOGGLE = 'beeper.toggle'
25 | const COMMAND_BYPASS_START = 'bypass.start'
26 | const COMMAND_BYPASS_STOP = 'bypass.stop'
27 | const COMMAND_CALIBRATE_START = 'calibrate.start'
28 | const COMMAND_CALIBRATE_STOP = 'calibrate.stop'
29 | const COMMAND_LOAD_OFF = 'load.off'
30 | const COMMAND_LOAD_ON = 'load.on'
31 | const COMMAND_RESET_INPUT_MINMAX = 'reset.input.minmax'
32 | const COMMAND_RESET_WATCHDOG = 'reset.watchdog'
33 | const COMMAND_SHUTDOWN_REBOOT = 'shutdown.reboot'
34 | const COMMAND_SHUTDOWN_REBOOT_GRACEFUL = 'shutdown.reboot.graceful'
35 | const COMMAND_SHUTDOWN_RETURN = 'shutdown.return'
36 | const COMMAND_SHUTDOWN_STAYOFF = 'shutdown.stayoff'
37 | const COMMAND_SHUTDOWN_STOP = 'shutdown.stop'
38 | const COMMAND_TEST_BATTERY_START = 'test.battery.start'
39 | const COMMAND_TEST_BATTERY_START_DEEP = 'test.battery.start.deep'
40 | const COMMAND_TEST_BATTERY_START_QUICK = 'test.battery.start.quick'
41 | const COMMAND_TEST_BATTERY_STOP = 'test.battery.stop'
42 | const COMMAND_TEST_FAILURE_START = 'test.failure.start'
43 | const COMMAND_TEST_FAILURE_STOP = 'test.failure.stop'
44 | const COMMAND_TEST_PANEL_START = 'test.panel.start'
45 | const COMMAND_TEST_PANEL_STOP = 'test.panel.stop'
46 | const COMMAND_TEST_SYSTEM_START = 'test.system.start'
47 | const COMMAND_DRIVER_RELOAD = 'driver.reload'
48 |
49 | export const SUPPORTED_COMMANDS = {
50 | COMMAND_BEEPER_DISABLE,
51 | COMMAND_BEEPER_ENABLE,
52 | COMMAND_BEEPER_MUTE,
53 | COMMAND_BEEPER_TOGGLE,
54 | COMMAND_BYPASS_START,
55 | COMMAND_BYPASS_STOP,
56 | COMMAND_CALIBRATE_START,
57 | COMMAND_CALIBRATE_STOP,
58 | COMMAND_LOAD_OFF,
59 | COMMAND_LOAD_ON,
60 | COMMAND_RESET_INPUT_MINMAX,
61 | COMMAND_RESET_WATCHDOG,
62 | COMMAND_SHUTDOWN_REBOOT,
63 | COMMAND_SHUTDOWN_REBOOT_GRACEFUL,
64 | COMMAND_SHUTDOWN_RETURN,
65 | COMMAND_SHUTDOWN_STAYOFF,
66 | COMMAND_SHUTDOWN_STOP,
67 | COMMAND_TEST_BATTERY_START,
68 | COMMAND_TEST_BATTERY_START_DEEP,
69 | COMMAND_TEST_BATTERY_START_QUICK,
70 | COMMAND_TEST_BATTERY_STOP,
71 | COMMAND_TEST_FAILURE_START,
72 | COMMAND_TEST_FAILURE_STOP,
73 | COMMAND_TEST_PANEL_START,
74 | COMMAND_TEST_PANEL_STOP,
75 | COMMAND_TEST_SYSTEM_START,
76 | COMMAND_DRIVER_RELOAD,
77 | }
78 |
79 | export const DEFAULT_INFLUX_INTERVAL = 10
80 |
--------------------------------------------------------------------------------
/src/common/types.ts:
--------------------------------------------------------------------------------
1 | export type VAR = {
2 | value: string | number
3 | description?: string
4 | type?: string
5 | enum?: Array
6 | range?: Array
7 | }
8 |
9 | export type VARS = {
10 | [x: string]: VAR
11 | }
12 |
13 | export type DEVICE = {
14 | name: string
15 | description: string
16 | vars: VARS
17 | rwVars: Array
18 | commands: Array
19 | clients: Array
20 | }
21 |
22 | export type DevicesData = {
23 | devices: Array | undefined
24 | updated: Date
25 | failedServers: Array | undefined
26 | }
27 |
28 | export type DeviceData = {
29 | device: DEVICE
30 | updated: Date
31 | }
32 |
33 | export type server = {
34 | HOST: string
35 | PORT: number
36 | USERNAME?: string
37 | PASSWORD?: string
38 | }
39 |
40 | export type VarDescription = {
41 | data: { [x: string]: string } | undefined
42 | error: string | undefined
43 | }
44 |
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | export async function register() {
4 | if (process.env.NEXT_RUNTIME === 'nodejs') {
5 | await import('./server/scheduler')
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth'
2 | import { authConfig } from './auth.config'
3 |
4 | export default NextAuth(authConfig).auth
5 |
6 | export const config = {
7 | // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
8 | matcher: ['/((?!_next/static|_next/image|.*\\.png$).*)'],
9 | }
10 |
--------------------------------------------------------------------------------
/src/server/auth-config.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto'
2 |
3 | export function ensureAuthSecret() {
4 | if (!process.env.AUTH_SECRET) {
5 | // Generate a random 32-byte string and encode it as base64
6 | const randomBytes = crypto.randomBytes(32)
7 | const authSecret = randomBytes.toString('base64')
8 | process.env.AUTH_SECRET = authSecret
9 | console.log(`Generated new AUTH_SECRET: ${authSecret}`)
10 | return authSecret
11 | }
12 | console.log(`AUTH_SECRET already exists: ${process.env.AUTH_SECRET}`)
13 | return process.env.AUTH_SECRET
14 | }
15 |
--------------------------------------------------------------------------------
/src/server/influxdb.ts:
--------------------------------------------------------------------------------
1 | import { InfluxDB, Point, HttpError } from '@influxdata/influxdb-client'
2 | import { PingAPI } from '@influxdata/influxdb-client-apis'
3 | import { DEVICE } from '@/common/types'
4 | import { upsStatus } from '@/common/constants'
5 |
6 | export default class InfluxWriter {
7 | private readonly writeApi: ReturnType
8 | private readonly url: string
9 | private readonly token: string
10 |
11 | constructor(url: string, token: string, org: string, bucket: string) {
12 | this.url = url
13 | this.token = token
14 | this.writeApi = new InfluxDB({ url, token }).getWriteApi(org, bucket, 's')
15 | }
16 |
17 | async testConnection() {
18 | const influx = new InfluxDB({ url: this.url, token: this.token })
19 | const ping = new PingAPI(influx)
20 | return ping.getPing()
21 | }
22 |
23 | writePoint(device: DEVICE, timestamp?: Date | number) {
24 | // Skip writing if device is unreachable
25 | if (device.vars.status?.value === upsStatus.DEVICE_UNREACHABLE) {
26 | return
27 | }
28 |
29 | // float fields
30 | for (const key of Object.keys(device.vars).filter((key) => typeof device.vars[key].value === 'number')) {
31 | const point = new Point(device.name)
32 | .tag('description', device.description)
33 | .floatField(key, device.vars[key].value)
34 | if (timestamp) {
35 | point.timestamp(timestamp)
36 | }
37 |
38 | try {
39 | this.writeApi.writePoint(point)
40 | } catch (e) {
41 | console.error(`Failed to write float field ${key} for device ${device.name}:`, e)
42 | }
43 | }
44 |
45 | // string fields
46 | for (const key of Object.keys(device.vars).filter((key) => typeof device.vars[key].value === 'string')) {
47 | const point = new Point(device.name)
48 | .tag('description', device.description)
49 | .stringField(key, device.vars[key].value)
50 | if (timestamp) {
51 | point.timestamp(timestamp)
52 | }
53 |
54 | try {
55 | this.writeApi.writePoint(point)
56 | } catch (e) {
57 | console.error(`Failed to write string field ${key} for device ${device.name}:`, e)
58 | }
59 | }
60 | }
61 |
62 | async close() {
63 | try {
64 | await this.writeApi.close()
65 | } catch (e) {
66 | console.error(e)
67 | if (e instanceof HttpError && e.statusCode === 401) {
68 | console.error('Could not connect to InfluxDB database.')
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/server/promise-socket.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'net'
2 |
3 | const TIMEOUT = 10000
4 | const MAX_LISTENERS = 9
5 |
6 | export default class PromiseSocket {
7 | private readonly innerSok: Socket = new Socket()
8 |
9 | public isConnected(): boolean {
10 | return this.innerSok && !this.innerSok.destroyed && this.innerSok.readable && this.innerSok.writable
11 | }
12 |
13 | private setTimeoutPromise(timeout: number): Promise {
14 | return new Promise((_, reject) => {
15 | setTimeout(() => {
16 | reject(new Error('Operation timeout'))
17 | }, timeout)
18 | })
19 | }
20 |
21 | private raceWithTimeout(promise: Promise, timeout: number): Promise {
22 | return Promise.race([promise, this.setTimeoutPromise(timeout)])
23 | }
24 |
25 | private addErrorHandler(reject: (reason?: any) => void): void {
26 | if (this.innerSok.listenerCount('error') < MAX_LISTENERS) {
27 | this.innerSok.once('error', reject)
28 | }
29 | }
30 |
31 | public connect(port: number, host: string, timeout = TIMEOUT): Promise {
32 | return this.raceWithTimeout(
33 | new Promise((resolve, reject) => {
34 | this.addErrorHandler(reject)
35 | this.innerSok.connect(port, host, resolve)
36 | }),
37 | timeout
38 | )
39 | }
40 |
41 | async write(data: string, timeout = TIMEOUT): Promise {
42 | return this.raceWithTimeout(
43 | new Promise((resolve, reject) => {
44 | this.addErrorHandler(reject)
45 | this.innerSok.write(`${data}\n`, (err) => {
46 | if (err) reject(new Error(`${err}`))
47 | else resolve()
48 | })
49 | }),
50 | timeout
51 | )
52 | }
53 |
54 | async readAll(command: string, until: string = `END ${command}`, timeout = TIMEOUT): Promise {
55 | return this.raceWithTimeout(
56 | new Promise((resolve, reject) => {
57 | let buf = ''
58 |
59 | const onData = (data: Buffer) => {
60 | buf += data.toString()
61 | if (buf.includes(until)) {
62 | cleanup()
63 | resolve(buf)
64 | }
65 | }
66 |
67 | const onEnd = () => {
68 | cleanup()
69 | if (!buf.includes(until)) {
70 | reject(new Error('Connection closed before receiving complete data'))
71 | } else {
72 | resolve(buf)
73 | }
74 | }
75 |
76 | const cleanup = () => {
77 | this.innerSok.off('data', onData)
78 | this.innerSok.off('end', onEnd)
79 | this.innerSok.off('error', reject)
80 | }
81 |
82 | this.innerSok.on('data', onData)
83 | this.innerSok.on('end', onEnd)
84 | this.addErrorHandler(reject)
85 | }),
86 | timeout
87 | )
88 | }
89 |
90 | async close(timeout = TIMEOUT): Promise {
91 | return this.raceWithTimeout(
92 | new Promise((resolve, reject) => {
93 | const cleanup = () => {
94 | this.innerSok.off('end', onEnd)
95 | this.innerSok.off('error', onError)
96 | }
97 |
98 | const onEnd = () => {
99 | cleanup()
100 | resolve()
101 | }
102 |
103 | const onError = (error: Error) => {
104 | cleanup()
105 | reject(error)
106 | }
107 |
108 | this.innerSok.end()
109 | this.innerSok.on('end', onEnd)
110 | this.innerSok.once('error', onError)
111 | }),
112 | timeout
113 | )
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/server/scheduler.ts:
--------------------------------------------------------------------------------
1 | import { ToadScheduler, SimpleIntervalJob, Task } from 'toad-scheduler'
2 | import chokidar from 'chokidar'
3 | import { YamlSettings } from '@/server/settings'
4 | import { getDevices } from '@/app/actions'
5 | import InfluxWriter from '@/server/influxdb'
6 | import { DEFAULT_INFLUX_INTERVAL } from '@/common/constants'
7 |
8 | const settingsFile = './config/settings.yml'
9 |
10 | // Initialize settings and scheduler
11 | const settings = new YamlSettings(settingsFile)
12 | const scheduler = new ToadScheduler()
13 |
14 | // Get the current interval from settings or default to DEFAULT_INFLUX_INTERVAL seconds
15 | const influxInterval = settings.get('INFLUX_INTERVAL') || DEFAULT_INFLUX_INTERVAL
16 |
17 | // Define the task to write data to InfluxDB
18 | const createTask = () =>
19 | new Task('influx writer', () => {
20 | const taskSettings = new YamlSettings(settingsFile)
21 | const influxHost = taskSettings.get('INFLUX_HOST')
22 | const influxToken = taskSettings.get('INFLUX_TOKEN')
23 | const influxOrg = taskSettings.get('INFLUX_ORG')
24 | const influxBucket = taskSettings.get('INFLUX_BUCKET')
25 |
26 | // Check if all required InfluxDB settings are available
27 | if (influxHost && influxToken && influxOrg && influxBucket) {
28 | getDevices().then(({ devices }) => {
29 | const influxdata = new InfluxWriter(influxHost, influxToken, influxOrg, influxBucket)
30 | const writePromises = (devices || []).map((device) => influxdata.writePoint(device, new Date()))
31 | return Promise.all(writePromises)
32 | .then(() => influxdata.close())
33 | .catch((error) => {
34 | console.error('Error writing to InfluxDB:', error)
35 | })
36 | })
37 | }
38 | })
39 |
40 | const addOrUpdateJob = (interval: number) => {
41 | if (scheduler.existsById('id_1')) {
42 | scheduler.removeById('id_1')
43 | }
44 | scheduler.addSimpleIntervalJob(
45 | new SimpleIntervalJob({ seconds: interval, runImmediately: true }, createTask(), {
46 | id: 'id_1',
47 | preventOverrun: true,
48 | })
49 | )
50 | }
51 |
52 | addOrUpdateJob(influxInterval)
53 |
54 | // Define the task to check and update the interval
55 | const watcher = chokidar.watch(settingsFile)
56 |
57 | watcher.on('change', () => {
58 | const newSettings = new YamlSettings(settingsFile)
59 | const newInfluxHost = newSettings.get('INFLUX_HOST')
60 | const newInfluxToken = newSettings.get('INFLUX_TOKEN')
61 | const newInfluxOrg = newSettings.get('INFLUX_ORG')
62 | const newInfluxBucket = newSettings.get('INFLUX_BUCKET')
63 | const newInterval = newSettings.get('INFLUX_INTERVAL') || DEFAULT_INFLUX_INTERVAL
64 |
65 | if (newInfluxHost && newInfluxToken && newInfluxOrg && newInfluxBucket) {
66 | addOrUpdateJob(newInterval)
67 | }
68 | })
69 |
--------------------------------------------------------------------------------
/src/server/settings.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { load, dump } from 'js-yaml'
4 | import { server } from '../common/types'
5 | import { DEFAULT_INFLUX_INTERVAL } from '@/common/constants'
6 |
7 | const ISettings = {
8 | NUT_SERVERS: [] as Array,
9 | INFLUX_HOST: '',
10 | INFLUX_TOKEN: '',
11 | INFLUX_ORG: '',
12 | INFLUX_BUCKET: '',
13 | INFLUX_INTERVAL: DEFAULT_INFLUX_INTERVAL,
14 | }
15 |
16 | export type SettingsType = { [K in keyof typeof ISettings]: (typeof ISettings)[K] }
17 |
18 | export class YamlSettings {
19 | private readonly filePath: string
20 | private data: SettingsType
21 | private readonly envVars: Record
22 |
23 | constructor(filePath: string) {
24 | this.filePath = filePath
25 | this.data = { ...ISettings }
26 | // Cache environment variables
27 | this.envVars = { ...process.env }
28 | this.loadFromEnvVars()
29 | this.load()
30 | }
31 |
32 | private loadFromEnvVars(): void {
33 | let key: keyof SettingsType
34 | for (key in ISettings) {
35 | const envValue = this.envVars[key]
36 | if (envValue === undefined || this.data[key] !== ISettings[key]) continue
37 |
38 | try {
39 | if (key === 'NUT_SERVERS') {
40 | this.data[key] = JSON.parse(envValue) as server[]
41 | } else if (key === 'INFLUX_INTERVAL') {
42 | const parsed = Number(envValue)
43 | if (isNaN(parsed)) throw new Error(`Invalid number for ${key}`)
44 | this.data[key] = parsed
45 | } else {
46 | this.data[key] = envValue
47 | }
48 | } catch (error) {
49 | console.error(`Error parsing environment variable ${key}: ${error}`)
50 | }
51 | }
52 |
53 | // Backwards compatibility for NUT_HOST and NUT_PORT
54 | const { NUT_HOST: nutHost, NUT_PORT: nutPort, USERNAME: username, PASSWORD: password } = this.envVars
55 |
56 | if (nutHost && nutPort) {
57 | const port = Number(nutPort)
58 | if (isNaN(port)) {
59 | console.error('Invalid NUT_PORT value')
60 | return
61 | }
62 |
63 | const serverExists = this.data.NUT_SERVERS.some((server) => server.HOST === nutHost && server.PORT === port)
64 |
65 | if (!serverExists) {
66 | this.data.NUT_SERVERS.push({
67 | HOST: nutHost,
68 | PORT: port,
69 | USERNAME: username,
70 | PASSWORD: password,
71 | })
72 | }
73 | }
74 | }
75 |
76 | private load(): void {
77 | // Create directory if it doesn't exist
78 | fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
79 |
80 | try {
81 | if (fs.existsSync(this.filePath)) {
82 | const fileContents = fs.readFileSync(this.filePath, 'utf8')
83 | const fileData = load(fileContents) as SettingsType
84 | // Merge settings, giving priority to file data
85 | this.data = { ...this.data, ...fileData }
86 | } else {
87 | this.save()
88 | }
89 | } catch (error) {
90 | console.error(`Error loading settings file: ${error instanceof Error ? error.message : error}`)
91 | }
92 |
93 | // Ensure NUT_SERVERS is always an array using nullish coalescing
94 | this.data.NUT_SERVERS ??= []
95 | }
96 |
97 | private save(): void {
98 | const yamlStr = dump(this.data)
99 | fs.writeFileSync(this.filePath, yamlStr, 'utf8')
100 | }
101 |
102 | public get(key: K): SettingsType[K] {
103 | return this.data[key]
104 | }
105 |
106 | public set(key: K, value: SettingsType[K]): void {
107 | this.data[key] = value
108 | this.save()
109 | }
110 |
111 | public delete(key: keyof SettingsType): void {
112 | delete this.data[key]
113 | this.save()
114 | }
115 |
116 | public getAll(): SettingsType {
117 | return this.data
118 | }
119 |
120 | public export(): string {
121 | return dump(this.data)
122 | }
123 |
124 | public import(contents: string): void {
125 | try {
126 | const fileData = load(contents) as SettingsType
127 | this.data = { ...ISettings, ...fileData }
128 | this.save()
129 | } catch (error) {
130 | throw new Error(`Failed to import settings: ${error instanceof Error ? error.message : error}`)
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "tsBuildInfoFile": ".next/tsbuilder.tsbuildinfo",
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules", "scripts"]
28 | }
29 |
--------------------------------------------------------------------------------
/types/swagger.d.ts:
--------------------------------------------------------------------------------
1 | // swagger.d.ts
2 | import {} from 'react'
3 |
4 | declare module 'react' {
5 | namespace JSX {
6 | interface IntrinsicAttributes {
7 | spec?: object
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
|