diff --git a/app/lib/constants.ts b/app/lib/constants.ts index 55aa5cd..9f6a28e 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -4,7 +4,8 @@ export const RequiredVersions = { controller: VERSION, tts: '2.0.0', piper: '1.3.0', - sounder: '2.1.0' + sounder: '2.1.0', + button: '1.0.0' } export const DOCS_URL = `https://openschoolbell.co.uk` diff --git a/app/locales/en.ts b/app/locales/en.ts index 91a8c49..7af1c24 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -343,7 +343,53 @@ export const en = { 'zones.edit.pageTitle': 'Edit zone {{name}}', 'zones.detail.metaFallback': 'Zone', 'zones.detail.soundersTitle': 'Sounders', - 'zones.detail.editButton': 'Edit zone' + 'zones.detail.editButton': 'Edit zone', + 'buttons.titleWithCount': 'Buttons ({{count}})', + 'buttons.table.device': 'Button', + 'buttons.table.action': 'Action', + 'nav.buttons': 'Buttons', + 'buttons.addButton': 'Add Button', + 'buttons.metaTitle': 'Buttons', + 'buttons.add.pageTitle': 'Add Button', + 'buttons.form.name.label': 'Name', + 'buttons.form.name.helper': 'Descriptive name of the button.', + 'buttons.form.ip.label': 'IP address', + 'buttons.form.ip.helper': + 'IP address where the controller can reach the button.', + 'buttons.form.action.label': 'Action', + 'buttons.form.action.helper': 'The action to be triggered by this button', + 'buttons.form.zone.label': 'Zone', + 'buttons.form.zone.helper': 'The zone to use when triggering the action.', + 'buttons.form.ledPin.label': 'LED Pin', + 'buttons.form.ledPin.helper': 'The GPIO output pin connected to the LED.', + 'buttons.form.buttonPin.label': 'Button Pin', + 'buttons.form.buttonPin.helper': + 'The GPIO input pin connected to the Button.', + 'buttons.form.holdDuration.label': 'Hold Duration', + 'buttons.form.holdDuration.helper': + 'How long in seconds should the button be held to trigger the action.', + 'buttons.form.cancelDuration.label': 'Cancel Duration', + 'buttons.form.cancelDuration.helper': + 'How long in seconds does the user have to cancel the trigger.', + 'buttons.add.submit': 'Add Button', + 'buttons.edit.submit': 'Update Button', + 'buttons.deleteConfirmation': + 'Are you sure you want to delete the button {{name}}?', + 'buttons.detail.metaFallback': 'Button', + 'buttons.detail.infoTitle': 'About', + 'buttons.detail.ipLabel': 'IP', + 'buttons.detail.keyLabel': 'Key', + 'buttons.detail.logTitle': 'Log', + 'buttons.detail.editButton': 'Edit button', + 'buttons.detail.ledPinLabel': 'LED Pin', + 'buttons.detail.buttonPinLabel': 'Button Pin', + 'buttons.detail.holdLabel': 'Hold Duration', + 'buttons.detail.cancelLabel': 'Cancel Duration', + 'buttons.detail.actionLabel': 'Action', + 'buttons.detail.zoneLabel': 'Zone', + 'buttons.edit.metaTitle': 'Edit {{name}}', + 'buttons.edit.pageTitle': 'Edit button {{name}}', + 'dashboard.buttons': 'Buttons' } as const export type EnMessages = typeof en diff --git a/app/root.tsx b/app/root.tsx index d0f2b97..85d23d9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -28,6 +28,7 @@ import LockClosedIcon from '@heroicons/react/24/outline/LockClosedIcon' import MusicIcon from '@heroicons/react/24/outline/MusicalNoteIcon' import CodeIcon from '@heroicons/react/24/outline/CodeBracketIcon' import LogIcon from '@heroicons/react/24/outline/ClipboardDocumentCheckIcon' +import ButtonIcon from '@heroicons/react/24/outline/ArrowDownOnSquareIcon' import './tailwind.css' @@ -112,6 +113,9 @@ const AppContent = () => { {t('nav.actions')} + + {t('nav.buttons')} + {t('nav.webhooks')} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index d9aed0a..550e837 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -26,10 +26,11 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const prisma = getPrisma() const sounders = await prisma.sounder.findMany({orderBy: {name: 'asc'}}) + const buttons = await prisma.actionButton.findMany({orderBy: {name: 'asc'}}) const lockdownMode = await getSetting('lockdownMode') - return {sounders, lockdownMode} + return {sounders, lockdownMode, buttons} } export const meta: MetaFunction = ({matches}) => { @@ -38,7 +39,7 @@ export const meta: MetaFunction = ({matches}) => { } export default function Index() { - const {sounders, lockdownMode} = useLoaderData() + const {sounders, lockdownMode, buttons} = useLoaderData() const {t, locale} = useTranslation() const dateLocale = locale === 'pl' ? pl : enUS @@ -119,6 +120,42 @@ export default function Index() { +
+

{t('dashboard.buttons')}

+ + + + + + + + + + {buttons.map(({id, name, lastCheckIn}) => { + return ( + + + + + + ) + })} + +
{t('dashboard.table.name')}{t('dashboard.table.status')}{t('dashboard.table.lastSeen')}
+ {name} + + {new Date().getTime() / 1000 - + lastCheckIn.getTime() / 1000 < + 65 + ? '🟢' + : '🔴'} + + {formatDistance(lastCheckIn, new Date(), { + addSuffix: true, + locale: dateLocale + })} +
+
) diff --git a/app/routes/about.tsx b/app/routes/about.tsx index 91728f4..e0a4632 100644 --- a/app/routes/about.tsx +++ b/app/routes/about.tsx @@ -112,6 +112,25 @@ export const loader = async ({request}: LoaderFunctionArgs) => { .catch(() => resolve('error')) }) + const buttonLatest = await new Promise(resolve => { + fetch( + 'https://api.github.com/repos/Open-School-Bell/action-button/releases?per_page=1', + { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + } + ) + .then(response => { + response + .json() + .then(data => resolve(data[0].tag_name)) + .catch(() => resolve('error')) + }) + .catch(() => resolve('error')) + }) + const prisma = getPrisma() const redis = getRedis() @@ -128,6 +147,19 @@ export const loader = async ({request}: LoaderFunctionArgs) => { sounderVersions[id] = version ? version : '0.0.0' }) + const buttons = await prisma.actionButton.findMany({ + select: {id: true, name: true}, + orderBy: {name: 'asc'} + }) + + const buttonVersions: {[buttonId: string]: string} = {} + + await asyncForEach(buttons, async ({id}) => { + const version = await redis.get(`osb-button-version-${id}`) + + buttonVersions[id] = version ? version : '0.0.0' + }) + const license = ( await readFile(path.join(process.cwd(), 'LICENSE')) ).toString() @@ -137,6 +169,9 @@ export const loader = async ({request}: LoaderFunctionArgs) => { sounders, sounderVersions, sounderLatest, + buttons, + buttonVersions, + buttonLatest, ttsLatest, controllerLatest, license @@ -154,6 +189,9 @@ const About = () => { sounders, sounderVersions, sounderLatest, + buttons, + buttonVersions, + buttonLatest, ttsLatest, controllerLatest, license @@ -228,6 +266,24 @@ const About = () => { ) })} + {buttons.map(({id, name}) => { + return ( + + {`Button: ${name}`} + {buttonVersions[id]} + + {buttonLatest.replace('v', '')} + + + {RequiredVersions.button} + + + ) + })} diff --git a/app/routes/button-api.enroll.tsx b/app/routes/button-api.enroll.tsx new file mode 100644 index 0000000..0873df2 --- /dev/null +++ b/app/routes/button-api.enroll.tsx @@ -0,0 +1,24 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' + +export const action = async ({request}: ActionFunctionArgs) => { + const {key} = (await request.json()) as {key?: string} + + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + const prisma = getPrisma() + + const button = await prisma.actionButton.findFirstOrThrow({ + where: {key, enrolled: false} + }) + + await prisma.actionButton.update({ + where: {id: button.id}, + data: {enrolled: true} + }) + + return Response.json({id: button.id, name: button.name}) +} diff --git a/app/routes/button-api.get-config.tsx b/app/routes/button-api.get-config.tsx new file mode 100644 index 0000000..3804764 --- /dev/null +++ b/app/routes/button-api.get-config.tsx @@ -0,0 +1,34 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' +import {getSettings} from '~/lib/settings.server' + +export const action = async ({request}: ActionFunctionArgs) => { + const {key} = (await request.json()) as {key?: string} + + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + const prisma = getPrisma() + + const button = await prisma.actionButton.findFirst({ + where: {key, enrolled: true} + }) + + if (!button) { + return Response.json({error: 'invalid key'}, {status: 403}) + } + + const {lockdownMode} = await getSettings(['lockdownMode']) + + return Response.json({ + name: button.name, + id: button.id, + ledPin: button.ledPin, + buttonPin: button.buttonPin, + holdDuration: button.holdDuration, + cancelDuration: button.cancelDuration, + lockdown: lockdownMode === '1' + }) +} diff --git a/app/routes/button-api.log.tsx b/app/routes/button-api.log.tsx new file mode 100644 index 0000000..20352db --- /dev/null +++ b/app/routes/button-api.log.tsx @@ -0,0 +1,34 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' + +export const action = async ({request}: ActionFunctionArgs) => { + const {key, message} = (await request.json()) as { + key?: string + message?: string + } + + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + if (!message || typeof message !== 'string') { + return Response.json({error: 'missing message'}, {status: 400}) + } + + const prisma = getPrisma() + + const button = await prisma.actionButton.findFirst({ + where: {key, enrolled: true} + }) + + if (!button) { + return Response.json({error: 'invalid key'}, {status: 403}) + } + + await prisma.actionButtonLog.create({ + data: {message, actionButtonId: button.id} + }) + + return Response.json({status: 'ok'}) +} diff --git a/app/routes/button-api.ping.tsx b/app/routes/button-api.ping.tsx new file mode 100644 index 0000000..4d244fb --- /dev/null +++ b/app/routes/button-api.ping.tsx @@ -0,0 +1,37 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' +import {getRedis} from '~/lib/redis.server.mjs' + +export const action = async ({request}: ActionFunctionArgs) => { + const {key, version} = (await request.json()) as { + key?: string + version?: string + } + + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + const prisma = getPrisma() + const redis = getRedis() + + const button = await prisma.actionButton.findFirst({ + where: {key, enrolled: true} + }) + + if (!button) { + return Response.json({error: 'invalid key'}, {status: 403}) + } + + await prisma.actionButton.update({ + where: {id: button.id}, + data: {lastCheckIn: new Date()} + }) + + if (version) { + void redis.set(`osb-button-version-${button.id}`, version) + } + + return Response.json({ping: 'pong'}) +} diff --git a/app/routes/button-api.trigger.tsx b/app/routes/button-api.trigger.tsx new file mode 100644 index 0000000..745fea7 --- /dev/null +++ b/app/routes/button-api.trigger.tsx @@ -0,0 +1,48 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' +import {broadcast} from '~/lib/broadcast.server' +import {toggleLockdown} from '~/lib/lockdown.server' + +export const action = async ({request}: ActionFunctionArgs) => { + const {key} = (await request.json()) as { + key?: string + } + + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + const prisma = getPrisma() + + const button = await prisma.actionButton.findFirst({ + where: {key, enrolled: true}, + include: {action: true} + }) + + if (!button) { + return Response.json({error: 'sounder not found'}, {status: 401}) + } + + const zone = button.zoneId + + switch (button.action.action) { + case 'broadcast': + if (!zone || typeof zone !== 'string' || zone.trim() === '') { + return Response.json({error: 'missing zone'}, {status: 400}) + } + + if (button.action.audioId) { + const zoneId = zone.trim() + await broadcast(zoneId, JSON.stringify([button.action.audioId])) + } + break + case 'lockdown': + await toggleLockdown() + break + default: + break + } + + return Response.json({ping: 'pong'}) +} diff --git a/app/routes/buttons.$button._index.tsx b/app/routes/buttons.$button._index.tsx new file mode 100644 index 0000000..de3c7c1 --- /dev/null +++ b/app/routes/buttons.$button._index.tsx @@ -0,0 +1,141 @@ +import { + type LoaderFunctionArgs, + type MetaFunction, + redirect +} from '@remix-run/node' +import {useLoaderData, useNavigate, Link} from '@remix-run/react' +import {format} from 'date-fns' + +import {getPrisma} from '~/lib/prisma.server' +import {checkSession} from '~/lib/session' +import {INPUT_CLASSES, pageTitle} from '~/lib/utils' +import {Page, Actions} from '~/lib/ui' +import {getSetting} from '~/lib/settings.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const name = data + ? data.button.name + : translate(messages, 'buttons.detail.metaFallback') + + return [{title: pageTitle(translate(messages, 'buttons.metaTitle'), name)}] +} + +export const loader = async ({request, params}: LoaderFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const button = await prisma.actionButton.findFirstOrThrow({ + where: {id: params.sounder}, + include: { + action: true, + zone: true, + logs: {orderBy: {time: 'desc'}, take: 10} + } + }) + + const enrollUrl = await getSetting('enrollUrl') + + return {button, enrollUrl} +} + +const Sounder = () => { + const {button, enrollUrl} = useLoaderData() + const navigate = useNavigate() + const {t} = useTranslation() + + return ( + +
+
+

{t('buttons.detail.infoTitle')}

+

+ {t('buttons.detail.ipLabel')}: {button.ip} +

+

+ {t('buttons.detail.ledPinLabel')}: {button.ledPin} +

+

+ {t('buttons.detail.buttonPinLabel')}: {button.buttonPin} +

+

+ {t('buttons.detail.holdLabel')}: {button.holdDuration} +

+

+ {t('buttons.detail.cancelLabel')}: {button.cancelDuration} +

+

+ {t('buttons.detail.actionLabel')}:{' '} + {button.action.name} +

+

+ {t('buttons.detail.zoneLabel')}:{' '} + + {button.zone ? button.zone.name : 'None'} + +

+ {button.enrolled ? ( +
+ +
+ ) : ( + <> +

+ {t('buttons.detail.keyLabel')}: {button.key} +

+
+
+                  button --enroll {button.key} --controller {enrollUrl}
+                
+
+ + )} +
+
+

{t('buttons.detail.logTitle')}

+ + + + + + + + + {button.logs.map(({id, message, time}) => { + return ( + + + + + ) + })} + +
{t('log.columns.time')}{t('log.columns.message')}
+ {format(time, 'dd/MM/yy HH:mm')} + {message}
+
+
+ navigate(`/buttons/${button.id}/edit`) + } + ]} + /> +
+ ) +} + +export default Sounder diff --git a/app/routes/buttons.$button.delete.tsx b/app/routes/buttons.$button.delete.tsx new file mode 100644 index 0000000..f760cbf --- /dev/null +++ b/app/routes/buttons.$button.delete.tsx @@ -0,0 +1,18 @@ +import {type ActionFunctionArgs, redirect} from '@remix-run/node' + +import {getPrisma} from '~/lib/prisma.server' +import {checkSession} from '~/lib/session' + +export const action = async ({request, params}: ActionFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + await prisma.actionButton.delete({where: {id: params.button}}) + + return redirect('/buttons') +} diff --git a/app/routes/buttons.$button.edit.tsx b/app/routes/buttons.$button.edit.tsx new file mode 100644 index 0000000..3b31b61 --- /dev/null +++ b/app/routes/buttons.$button.edit.tsx @@ -0,0 +1,220 @@ +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction +} from '@remix-run/node' +import {useLoaderData, useNavigate} from '@remix-run/react' +import {invariant} from '@arcath/utils' + +import {getPrisma} from '~/lib/prisma.server' +import {INPUT_CLASSES, pageTitle} from '~/lib/utils' +import {checkSession} from '~/lib/session' +import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'buttons.metaTitle'), + data + ? translate(messages, 'buttons.edit.metaTitle', { + name: data.button.name + }) + : translate(messages, 'buttons.edit.metaTitle', {name: ''}) + ) + } + ] +} + +export const loader = async ({request, params}: LoaderFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const actions = await prisma.action.findMany({orderBy: {name: 'asc'}}) + const zones = await prisma.zone.findMany({orderBy: {name: 'asc'}}) + const button = await prisma.actionButton.findFirstOrThrow({ + where: {id: params.button} + }) + + return {actions, button, zones} +} + +export const action = async ({request, params}: ActionFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const formData = await request.formData() + + const name = formData.get('name') as string | undefined + const ip = formData.get('ip') as string | undefined + const action = formData.get('action') as string | undefined + const zone = formData.get('zone') as string | undefined + const ledPin = formData.get('ledpin') as string | undefined + const buttonPin = formData.get('buttonpin') as string | undefined + const holdDuration = formData.get('holdduration') as string | undefined + const cancelDuration = formData.get('cancelduration') as string | undefined + + invariant(name) + invariant(ip) + invariant(action) + invariant(zone) + invariant(ledPin) + invariant(buttonPin) + invariant(holdDuration) + invariant(cancelDuration) + + const button = await prisma.actionButton.update({ + where: {id: params.button}, + data: { + name, + ip, + actionId: action, + zoneId: zone, + ledPin: parseInt(ledPin), + buttonPin: parseInt(buttonPin), + holdDuration: parseInt(holdDuration), + cancelDuration: parseInt(cancelDuration) + } + }) + + return redirect(`/buttons/${button.id}`) +} + +const EditButton = () => { + const {actions, button, zones} = useLoaderData() + const navigate = useNavigate() + const {t} = useTranslation() + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + { + e.preventDefault() + navigate('/buttons') + } + }, + {label: t('buttons.edit.submit'), color: 'bg-green-300'} + ]} + /> + +
+ ) +} + +export default EditButton diff --git a/app/routes/buttons._index.tsx b/app/routes/buttons._index.tsx new file mode 100644 index 0000000..bb3296d --- /dev/null +++ b/app/routes/buttons._index.tsx @@ -0,0 +1,99 @@ +import { + type LoaderFunctionArgs, + type MetaFunction, + redirect +} from '@remix-run/node' +import {useLoaderData, Link, useNavigate} from '@remix-run/react' + +import {getPrisma} from '~/lib/prisma.server' +import {checkSession} from '~/lib/session' +import {pageTitle} from '~/lib/utils' +import {Page, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'buttons.metaTitle'))}] +} + +export const loader = async ({request}: LoaderFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const buttons = await prisma.actionButton.findMany({ + orderBy: {name: 'asc'}, + include: {action: true} + }) + + return {buttons} +} + +const Sounders = () => { + const {buttons} = useLoaderData() + const navigate = useNavigate() + const {t} = useTranslation() + + return ( + +
+ + + + + + + + + + {buttons.map(({id, name, action}) => { + return ( + + + + + + ) + })} + +
{t('buttons.table.device')}{t('buttons.table.action')}
+ {name} + + {action.name} + +
{ + if (!confirm(t('buttons.deleteConfirmation', {name}))) { + e.preventDefault() + } + }} + > + +
+
+
+ navigate('/buttons/add') + } + ]} + /> +
+ ) +} + +export default Sounders diff --git a/app/routes/buttons.add.tsx b/app/routes/buttons.add.tsx new file mode 100644 index 0000000..51b719e --- /dev/null +++ b/app/routes/buttons.add.tsx @@ -0,0 +1,124 @@ +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction +} from '@remix-run/node' +import {useLoaderData, useNavigate} from '@remix-run/react' +import {invariant} from '@arcath/utils' + +import {getPrisma} from '~/lib/prisma.server' +import {makeKey, INPUT_CLASSES, pageTitle} from '~/lib/utils' +import {checkSession} from '~/lib/session' +import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'buttons.metaTitle'), + translate(messages, 'buttons.add.pageTitle') + ) + } + ] +} + +export const loader = async ({request}: LoaderFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const actions = await prisma.action.findMany({orderBy: {name: 'asc'}}) + + return {actions} +} + +export const action = async ({request}: ActionFunctionArgs) => { + const result = await checkSession(request) + + if (!result) { + return redirect('/login') + } + + const prisma = getPrisma() + + const formData = await request.formData() + + const name = formData.get('name') as string | undefined + const ip = formData.get('ip') as string | undefined + const action = formData.get('action') as string | undefined + + invariant(name) + invariant(ip) + invariant(action) + + const key = makeKey() + + const button = await prisma.actionButton.create({ + data: {name, key, ip, actionId: action} + }) + + return redirect(`/buttons/${button.id}`) +} + +const AddButton = () => { + const {actions} = useLoaderData() + const navigate = useNavigate() + const {t} = useTranslation() + + return ( + +
+ + + + + + + + + + { + e.preventDefault() + navigate('/buttons') + } + }, + {label: t('buttons.add.submit'), color: 'bg-green-300'} + ]} + /> + +
+ ) +} + +export default AddButton diff --git a/app/routes/sounder-api.get-status.tsx b/app/routes/sounder-api.get-status.tsx index fcbd5b2..2a67fc8 100644 --- a/app/routes/sounder-api.get-status.tsx +++ b/app/routes/sounder-api.get-status.tsx @@ -22,6 +22,7 @@ export const action = async ({request}: ActionFunctionArgs) => { } const sounders = await prisma.sounder.findMany({orderBy: {name: 'asc'}}) + const buttons = await prisma.actionButton.findMany({orderBy: {name: 'asc'}}) return Response.json({ system: 'ok', @@ -38,6 +39,18 @@ export const action = async ({request}: ActionFunctionArgs) => { ? '🟢' : '🔴' } + }), + buttons: buttons.map(button => { + return { + ...button, + lastSeen: formatDistance(new Date(button.lastCheckIn), new Date(), { + addSuffix: true + }), + status: + new Date().getTime() / 1000 - button.lastCheckIn.getTime() / 1000 < 65 + ? '🟢' + : '🔴' + } }) }) } diff --git a/app/routes/status.tsx b/app/routes/status.tsx index 97f56a8..e50467e 100644 --- a/app/routes/status.tsx +++ b/app/routes/status.tsx @@ -13,6 +13,9 @@ export const loader = async ({}: LoaderFunctionArgs) => { select: {id: true, name: true, lastCheckIn: true, enrolled: true, ip: true} }) const zones = await prisma.zone.findMany() + const buttons = await prisma.actionButton.findMany({ + select: {id: true, name: true, lastCheckIn: true, enrolled: true, ip: true} + }) const date = new Date() date.setHours(0, 0, 0, 0) @@ -27,6 +30,7 @@ export const loader = async ({}: LoaderFunctionArgs) => { lockdown: lockdownMode === '1', sounders, version: VERSION, - zones + zones, + buttons }) } diff --git a/prisma/migrations/20251118144444_add_action_buttons/migration.sql b/prisma/migrations/20251118144444_add_action_buttons/migration.sql new file mode 100644 index 0000000..f4f5072 --- /dev/null +++ b/prisma/migrations/20251118144444_add_action_buttons/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "actionId" TEXT NOT NULL, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/prisma/migrations/20251119092757_add_pin_and_duration_configuration_to_the_buttons/migration.sql b/prisma/migrations/20251119092757_add_pin_and_duration_configuration_to_the_buttons/migration.sql new file mode 100644 index 0000000..3d60718 --- /dev/null +++ b/prisma/migrations/20251119092757_add_pin_and_duration_configuration_to_the_buttons/migration.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "ledPin" INTEGER NOT NULL DEFAULT 0, + "buttonPin" INTEGER NOT NULL DEFAULT 0, + "holdDuration" INTEGER NOT NULL DEFAULT 0, + "cancelDuration" INTEGER NOT NULL DEFAULT 0, + "actionId" TEXT NOT NULL, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_ActionButton" ("actionId", "id", "key", "name") SELECT "actionId", "id", "key", "name" FROM "ActionButton"; +DROP TABLE "ActionButton"; +ALTER TABLE "new_ActionButton" RENAME TO "ActionButton"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251119115345_add_ip_to_button/migration.sql b/prisma/migrations/20251119115345_add_ip_to_button/migration.sql new file mode 100644 index 0000000..42908c1 --- /dev/null +++ b/prisma/migrations/20251119115345_add_ip_to_button/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "ip" TEXT NOT NULL DEFAULT '', + "ledPin" INTEGER NOT NULL DEFAULT 0, + "buttonPin" INTEGER NOT NULL DEFAULT 0, + "holdDuration" INTEGER NOT NULL DEFAULT 0, + "cancelDuration" INTEGER NOT NULL DEFAULT 0, + "actionId" TEXT NOT NULL, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_ActionButton" ("actionId", "buttonPin", "cancelDuration", "holdDuration", "id", "key", "ledPin", "name") SELECT "actionId", "buttonPin", "cancelDuration", "holdDuration", "id", "key", "ledPin", "name" FROM "ActionButton"; +DROP TABLE "ActionButton"; +ALTER TABLE "new_ActionButton" RENAME TO "ActionButton"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251119122451_add_action_button_log/migration.sql b/prisma/migrations/20251119122451_add_action_button_log/migration.sql new file mode 100644 index 0000000..d3c727a --- /dev/null +++ b/prisma/migrations/20251119122451_add_action_button_log/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ActionButtonLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "message" TEXT NOT NULL, + "actionButtonId" TEXT NOT NULL, + CONSTRAINT "ActionButtonLog_actionButtonId_fkey" FOREIGN KEY ("actionButtonId") REFERENCES "ActionButton" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/prisma/migrations/20251119130829_add_enrolled_to_button/migration.sql b/prisma/migrations/20251119130829_add_enrolled_to_button/migration.sql new file mode 100644 index 0000000..d5edd63 --- /dev/null +++ b/prisma/migrations/20251119130829_add_enrolled_to_button/migration.sql @@ -0,0 +1,21 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "ip" TEXT NOT NULL DEFAULT '', + "enrolled" BOOLEAN NOT NULL DEFAULT false, + "ledPin" INTEGER NOT NULL DEFAULT 0, + "buttonPin" INTEGER NOT NULL DEFAULT 0, + "holdDuration" INTEGER NOT NULL DEFAULT 0, + "cancelDuration" INTEGER NOT NULL DEFAULT 0, + "actionId" TEXT NOT NULL, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_ActionButton" ("actionId", "buttonPin", "cancelDuration", "holdDuration", "id", "ip", "key", "ledPin", "name") SELECT "actionId", "buttonPin", "cancelDuration", "holdDuration", "id", "ip", "key", "ledPin", "name" FROM "ActionButton"; +DROP TABLE "ActionButton"; +ALTER TABLE "new_ActionButton" RENAME TO "ActionButton"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251119204729_add_last_seen_to_action_button/migration.sql b/prisma/migrations/20251119204729_add_last_seen_to_action_button/migration.sql new file mode 100644 index 0000000..64f2d49 --- /dev/null +++ b/prisma/migrations/20251119204729_add_last_seen_to_action_button/migration.sql @@ -0,0 +1,22 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "ip" TEXT NOT NULL DEFAULT '', + "enrolled" BOOLEAN NOT NULL DEFAULT false, + "lastCheckIn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ledPin" INTEGER NOT NULL DEFAULT 0, + "buttonPin" INTEGER NOT NULL DEFAULT 0, + "holdDuration" INTEGER NOT NULL DEFAULT 0, + "cancelDuration" INTEGER NOT NULL DEFAULT 0, + "actionId" TEXT NOT NULL, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_ActionButton" ("actionId", "buttonPin", "cancelDuration", "enrolled", "holdDuration", "id", "ip", "key", "ledPin", "name") SELECT "actionId", "buttonPin", "cancelDuration", "enrolled", "holdDuration", "id", "ip", "key", "ledPin", "name" FROM "ActionButton"; +DROP TABLE "ActionButton"; +ALTER TABLE "new_ActionButton" RENAME TO "ActionButton"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251123203946_add_zone_to_button/migration.sql b/prisma/migrations/20251123203946_add_zone_to_button/migration.sql new file mode 100644 index 0000000..f10b838 --- /dev/null +++ b/prisma/migrations/20251123203946_add_zone_to_button/migration.sql @@ -0,0 +1,24 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_ActionButton" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "ip" TEXT NOT NULL DEFAULT '', + "enrolled" BOOLEAN NOT NULL DEFAULT false, + "lastCheckIn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ledPin" INTEGER NOT NULL DEFAULT 0, + "buttonPin" INTEGER NOT NULL DEFAULT 0, + "holdDuration" INTEGER NOT NULL DEFAULT 0, + "cancelDuration" INTEGER NOT NULL DEFAULT 0, + "actionId" TEXT NOT NULL, + "zoneId" TEXT, + CONSTRAINT "ActionButton_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "Action" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ActionButton_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_ActionButton" ("actionId", "buttonPin", "cancelDuration", "enrolled", "holdDuration", "id", "ip", "key", "lastCheckIn", "ledPin", "name") SELECT "actionId", "buttonPin", "cancelDuration", "enrolled", "holdDuration", "id", "ip", "key", "lastCheckIn", "ledPin", "name" FROM "ActionButton"; +DROP TABLE "ActionButton"; +ALTER TABLE "new_ActionButton" RENAME TO "ActionButton"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5475afd..fcfdc5a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,8 +40,9 @@ model Zone { id String @id @default(uuid()) name String - sounders ZoneSounder[] - schedules Schedule[] + sounders ZoneSounder[] + schedules Schedule[] + ActionButton ActionButton[] } model ZoneSounder { @@ -110,6 +111,7 @@ model Action { audioId String? webhooks Webhook[] + buttons ActionButton[] } model Setting { @@ -146,3 +148,33 @@ model OutboundWebhook { event String key String } + +model ActionButton { + id String @id @default(uuid()) + name String + key String + ip String @default("") + enrolled Boolean @default(false) + lastCheckIn DateTime @default(now()) + + ledPin Int @default(0) + buttonPin Int @default(0) + holdDuration Int @default(0) + cancelDuration Int @default(0) + + action Action @relation(fields: [actionId], references: [id]) + actionId String + zone Zone? @relation(fields: [zoneId], references: [id]) + zoneId String? + + logs ActionButtonLog[] +} + +model ActionButtonLog { + id String @id @default(uuid()) + time DateTime @default(now()) + message String + + actionButton ActionButton @relation(fields: [actionButtonId], references: [id]) + actionButtonId String +}