From 30cd95746c8f130da097110b77f1e94d0b339142 Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Sat, 22 Nov 2025 12:45:18 +0000 Subject: [PATCH 1/6] feat: basic action button features --- app/locales/en.ts | 42 +++- app/root.tsx | 4 + app/routes/button-api.enroll.tsx | 24 +++ app/routes/button-api.get-config.tsx | 34 +++ app/routes/button-api.log.tsx | 34 +++ app/routes/button-api.ping.tsx | 37 ++++ app/routes/buttons.$button._index.tsx | 130 ++++++++++++ app/routes/buttons.$button.delete.tsx | 18 ++ app/routes/buttons.$button.edit.tsx | 200 ++++++++++++++++++ app/routes/buttons._index.tsx | 99 +++++++++ app/routes/buttons.add.tsx | 124 +++++++++++ app/routes/status.tsx | 6 +- .../migration.sql | 8 + .../migration.sql | 19 ++ .../migration.sql | 20 ++ .../migration.sql | 8 + .../migration.sql | 21 ++ .../migration.sql | 22 ++ prisma/schema.prisma | 29 +++ 19 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 app/routes/button-api.enroll.tsx create mode 100644 app/routes/button-api.get-config.tsx create mode 100644 app/routes/button-api.log.tsx create mode 100644 app/routes/button-api.ping.tsx create mode 100644 app/routes/buttons.$button._index.tsx create mode 100644 app/routes/buttons.$button.delete.tsx create mode 100644 app/routes/buttons.$button.edit.tsx create mode 100644 app/routes/buttons._index.tsx create mode 100644 app/routes/buttons.add.tsx create mode 100644 prisma/migrations/20251118144444_add_action_buttons/migration.sql create mode 100644 prisma/migrations/20251119092757_add_pin_and_duration_configuration_to_the_buttons/migration.sql create mode 100644 prisma/migrations/20251119115345_add_ip_to_button/migration.sql create mode 100644 prisma/migrations/20251119122451_add_action_button_log/migration.sql create mode 100644 prisma/migrations/20251119130829_add_enrolled_to_button/migration.sql create mode 100644 prisma/migrations/20251119204729_add_last_seen_to_action_button/migration.sql diff --git a/app/locales/en.ts b/app/locales/en.ts index 91a8c49..d6ed329 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -343,7 +343,47 @@ 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.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.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.edit.metaTitle': 'Edit {{name}}', + 'buttons.edit.pageTitle': 'Edit button {{name}}' } 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/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/buttons.$button._index.tsx b/app/routes/buttons.$button._index.tsx new file mode 100644 index 0000000..6ebfad0 --- /dev/null +++ b/app/routes/buttons.$button._index.tsx @@ -0,0 +1,130 @@ +import { + type LoaderFunctionArgs, + type MetaFunction, + redirect +} from '@remix-run/node' +import {useLoaderData, useNavigate} 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, + 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} +

+ {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..e5d8554 --- /dev/null +++ b/app/routes/buttons.$button.edit.tsx @@ -0,0 +1,200 @@ +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 = ({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 button = await prisma.actionButton.findFirstOrThrow({ + where: {id: params.button} + }) + + return {actions, button} +} + +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 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(ledPin) + invariant(buttonPin) + invariant(holdDuration) + invariant(cancelDuration) + + const key = makeKey() + + const button = await prisma.actionButton.update({ + where: {id: params.button}, + data: { + name, + ip, + actionId: action, + ledPin: parseInt(ledPin), + buttonPin: parseInt(buttonPin), + holdDuration: parseInt(holdDuration), + cancelDuration: parseInt(cancelDuration) + } + }) + + return redirect(`/buttons/${button.id}`) +} + +const EditButton = () => { + const {actions, button} = useLoaderData() + const navigate = useNavigate() + const {t} = useTranslation() + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + { + e.preventDefault() + navigate('/buttons') + } + }, + {label: t('buttons.add.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..7ab683c --- /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/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/schema.prisma b/prisma/schema.prisma index 5475afd..8560635 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,7 @@ model Action { audioId String? webhooks Webhook[] + buttons ActionButton[] } model Setting { @@ -146,3 +147,31 @@ 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 + + logs ActionButtonLog[] +} + +model ActionButtonLog { + id String @id @default(uuid()) + time DateTime @default(now()) + message String + + actionButton ActionButton @relation(fields: [actionButtonId], references: [id]) + actionButtonId String +} From 5fd329eb0005ccc56dc21ba0683539fb2d14e6d8 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sat, 22 Nov 2025 21:22:19 +0000 Subject: [PATCH 2/6] fix: bad path in link --- app/routes/buttons._index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/buttons._index.tsx b/app/routes/buttons._index.tsx index 7ab683c..bb3296d 100644 --- a/app/routes/buttons._index.tsx +++ b/app/routes/buttons._index.tsx @@ -59,7 +59,7 @@ const Sounders = () => { return ( - {name} + {name} {action.name} From c39a339ac6749712b6d0c57f56ad39d2c86c58f6 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sat, 22 Nov 2025 21:22:32 +0000 Subject: [PATCH 3/6] feat: button trigger --- app/routes/button-api.trigger.tsx | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/routes/button-api.trigger.tsx diff --git a/app/routes/button-api.trigger.tsx b/app/routes/button-api.trigger.tsx new file mode 100644 index 0000000..67a41f1 --- /dev/null +++ b/app/routes/button-api.trigger.tsx @@ -0,0 +1,47 @@ +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, zone} = (await request.json()) as { + key?: string + zone?: 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}) + } + + 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'}) +} From 7a3f594b13aec05b247586639f44ce38acc179b6 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 23 Nov 2025 17:49:06 +0000 Subject: [PATCH 4/6] feat: about page info for buttons --- app/lib/constants.ts | 3 ++- app/locales/en.ts | 3 ++- app/routes/_index.tsx | 41 +++++++++++++++++++++++++++++-- app/routes/about.tsx | 56 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) 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 d6ed329..da0f2b6 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -383,7 +383,8 @@ export const en = { 'buttons.detail.holdLabel': 'Hold Duration', 'buttons.detail.cancelLabel': 'Cancel Duration', 'buttons.edit.metaTitle': 'Edit {{name}}', - 'buttons.edit.pageTitle': 'Edit button {{name}}' + 'buttons.edit.pageTitle': 'Edit button {{name}}', + 'dashboard.buttons': 'Buttons' } as const export type EnMessages = typeof en 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} + + + ) + })} From 9eaff299cbf5a9528a323513da982d7584bed132 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 23 Nov 2025 20:37:41 +0000 Subject: [PATCH 5/6] feat: add button to screen api response --- app/routes/sounder-api.get-status.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 + ? '🟢' + : '🔴' + } }) }) } From 20e852201dd38af6b5ff331e1825a79b974af002 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 23 Nov 2025 20:53:26 +0000 Subject: [PATCH 6/6] feat: add zone ton buttons for actions that require a zone --- app/locales/en.ts | 5 +++ app/routes/button-api.trigger.tsx | 5 +-- app/routes/buttons.$button._index.tsx | 13 +++++++- app/routes/buttons.$button.edit.tsx | 32 +++++++++++++++---- .../migration.sql | 24 ++++++++++++++ prisma/schema.prisma | 9 ++++-- 6 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20251123203946_add_zone_to_button/migration.sql diff --git a/app/locales/en.ts b/app/locales/en.ts index da0f2b6..7af1c24 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -358,6 +358,8 @@ export const en = { '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', @@ -370,6 +372,7 @@ export const en = { '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', @@ -382,6 +385,8 @@ export const en = { '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' diff --git a/app/routes/button-api.trigger.tsx b/app/routes/button-api.trigger.tsx index 67a41f1..745fea7 100644 --- a/app/routes/button-api.trigger.tsx +++ b/app/routes/button-api.trigger.tsx @@ -5,9 +5,8 @@ import {broadcast} from '~/lib/broadcast.server' import {toggleLockdown} from '~/lib/lockdown.server' export const action = async ({request}: ActionFunctionArgs) => { - const {key, zone} = (await request.json()) as { + const {key} = (await request.json()) as { key?: string - zone?: string } if (!key || typeof key !== 'string') { @@ -25,6 +24,8 @@ export const action = async ({request}: ActionFunctionArgs) => { 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() === '') { diff --git a/app/routes/buttons.$button._index.tsx b/app/routes/buttons.$button._index.tsx index 6ebfad0..de3c7c1 100644 --- a/app/routes/buttons.$button._index.tsx +++ b/app/routes/buttons.$button._index.tsx @@ -3,7 +3,7 @@ import { type MetaFunction, redirect } from '@remix-run/node' -import {useLoaderData, useNavigate} from '@remix-run/react' +import {useLoaderData, useNavigate, Link} from '@remix-run/react' import {format} from 'date-fns' import {getPrisma} from '~/lib/prisma.server' @@ -37,6 +37,7 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { where: {id: params.sounder}, include: { action: true, + zone: true, logs: {orderBy: {time: 'desc'}, take: 10} } }) @@ -71,6 +72,16 @@ const Sounder = () => {

{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 ? (