Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65a43b13c | ||
|
|
440b8683f7 | ||
|
|
1043f26b3e |
@@ -2,8 +2,6 @@ name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
@@ -32,9 +30,8 @@ jobs:
|
||||
with:
|
||||
images: git.lzstealth.com/LZStealth/pulse-signage
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
docker-compose.dev.yml
|
||||
.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -63,33 +63,17 @@ The app reads its settings from environment variables.
|
||||
|
||||
- `PLAYER_PORT` - player port, default `3001`
|
||||
|
||||
## Using Docker
|
||||
## Docker Compose
|
||||
|
||||
The recommended way to run the project is with Docker Compose.
|
||||
The repository includes a `docker-compose.yml` file that starts three services:
|
||||
|
||||
```bash
|
||||
npm run docker:up
|
||||
```
|
||||
- `webui` - the admin app on port `3000`
|
||||
- `player` - the signage player on port `3001`
|
||||
- `mysql` - the database on port `3306`
|
||||
|
||||
The main Compose file now uses the published Docker image and still reads values from the repository `.env` file. `DB_HOST` comes from that file for the app containers, so you can point the web UI and player at an external database host or change it to `mysql` if you want to use the bundled MySQL service.
|
||||
By default, `webui` and `player` use the published image from `git.lzstealth.com/LZStealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/LZStealth/pulse-signage:v1.0.0`.
|
||||
|
||||
If you only want to test the image build from the current checkout without using Compose, run:
|
||||
|
||||
```bash
|
||||
npm run docker:build
|
||||
```
|
||||
|
||||
To publish the Docker image to the Gitea container registry, push to `main` or create a `v*` tag. The workflow in [.gitea/workflows/docker-publish.yml](.gitea/workflows/docker-publish.yml) builds `git.lzstealth.com/LZStealth/pulse-signage` and pushes `latest`, `sha`, and tagged releases.
|
||||
|
||||
This starts:
|
||||
|
||||
- the web admin app on port `3000`
|
||||
- the player app on port `3001`
|
||||
- MySQL on port `3306`
|
||||
|
||||
The web app and player app share the same uploaded media volume, so files uploaded in the admin UI are available to the player.
|
||||
|
||||
If you want to stop the stack later, run `npm run docker:down`. To follow logs, run `npm run docker:logs`.
|
||||
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
+8
-2
@@ -7,12 +7,18 @@ function slugify(value) {
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
async function uniqueScreenSlug(pool, baseSlug) {
|
||||
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
const start = baseSlug || `screen-${Date.now()}`;
|
||||
let candidate = start;
|
||||
let counter = 2;
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM screens WHERE slug = ?', [candidate]);
|
||||
const params = [candidate];
|
||||
let sql = 'SELECT id FROM screens WHERE slug = ?';
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
const [rows] = await pool.query(sql, params);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
+15
-10
@@ -396,27 +396,32 @@ async function start() {
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
||||
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screenRows = [];
|
||||
if (!isRedirectCommand) {
|
||||
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
}
|
||||
|
||||
const commandPayload = command === 'blackout' && blackoutValue !== undefined
|
||||
? {
|
||||
command: command,
|
||||
blackout: blackoutValue
|
||||
}
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: command;
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const sent = connectionId
|
||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: broadcastCommand(req.params.slug, commandPayload);
|
||||
|
||||
res.json({
|
||||
screen: screenRows[0],
|
||||
screen: screenRows[0] || null,
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
|
||||
@@ -488,6 +488,11 @@
|
||||
case 'refresh':
|
||||
refresh();
|
||||
return;
|
||||
case 'redirect':
|
||||
if (payload.url) {
|
||||
window.location.replace(String(payload.url));
|
||||
}
|
||||
return;
|
||||
case 'pause':
|
||||
setPaused(!isPaused);
|
||||
return;
|
||||
|
||||
+12
-2
@@ -1457,8 +1457,9 @@ async function start() {
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(name));
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen created.'));
|
||||
@@ -1477,8 +1478,17 @@ async function start() {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
await pool.query('UPDATE screens SET name = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, playlistId, getAuditUserId(req), screen.id]);
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
res.redirect('/admin/screens?edit=' + screen.id + '&message=' + encodeURIComponent('Screen updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -538,6 +538,7 @@
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved slide.');
|
||||
window.location.href = response.url || slideForm.action;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="/admin/screens" data-async-save>
|
||||
<form method="post" action="/admin/screens" data-async-save data-async-save-reload>
|
||||
<label>Name
|
||||
<input name="name" placeholder="Front desk TV" required />
|
||||
</label>
|
||||
<label>Slug
|
||||
<input name="slug" placeholder="front-desk-tv" />
|
||||
</label>
|
||||
<label>Playlist
|
||||
<select name="playlist_id">
|
||||
<option value="">-- not assigned --</option>
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
|
||||
<div class="card">
|
||||
<h3>Edit screen</h3>
|
||||
<form id="screen-edit-form" class="screen-edit-form" method="post" action="/admin/screens/{{screen.id}}" data-async-save>
|
||||
<form id="screen-edit-form" class="screen-edit-form" method="post" action="/admin/screens/{{screen.id}}" data-async-save data-async-save-reload>
|
||||
<label>Name
|
||||
<input name="name" value="{{screen.name}}" required />
|
||||
</label>
|
||||
<label>Slug
|
||||
<input name="slug" value="{{screen.slug}}" />
|
||||
</label>
|
||||
<label>Playlist
|
||||
<select name="playlist_id">
|
||||
<option value="">-- not assigned --</option>
|
||||
|
||||
Reference in New Issue
Block a user