Kiosk Container
Kiosk Webserver Setup Guide (Ubuntu 22.04 LXC)
This guide details the setup of a dedicated web server within an Ubuntu 22.04 LXC container. The primary purpose of this kiosk is to display a rotating slideshow of local pictures. Additionally, it provides real-time status updates for various internal and external services, local times, and reminders, all for quick convenience.
This setup is designed to be accessible only from your local network (behind pfSense). For broader network monitoring, you will be installing the Prometheus + Grafana stack next.
1. Prepare the LXC Container
First, ensure your Ubuntu 22.04 LXC container is updated and ready.
- Access your LXC console via Proxmox UI or SSH.
- Update package lists and upgrade existing packages:
sudo apt update
sudo apt upgrade -y
2. Install Web Server and PHP
Install Apache2 (your web server), PHP, and necessary PHP modules.
- Install Apache2, PHP, and key PHP modules:
sudo apt install apache2 php libapache2-mod-php php-curl php-mbstring php-gd -y
- Restart Apache to ensure PHP modules are loaded:
sudo systemctl restart apache2
3. Create Necessary Directories and Set Permissions
The web server needs specific directories to store images, reminders, birthdays, and cache files, with appropriate permissions.
- Create directories:
sudo mkdir -p /var/www/html/local_photos
sudo mkdir -p /var/www/html/cache
- Set ownership to the web server user (`www-data`) and grant write permissions to `local_photos` and `cache`:
sudo chown -R www-data:www-data /var/www/html/local_photos/
sudo chmod -R 775 /var/www/html/local_photos/
sudo chown -R www-data:www-data /var/www/html/cache/
sudo chmod -R 775 /var/www/html/cache/
- Create empty JSON files for configuration, reminders, and birthdays. These need to be writable by `www-data`:
sudo touch /var/www/html/config.json
sudo touch /var/www/html/reminders.json
sudo touch /var/www/html/birthdays.json
sudo chown www-data:www-data /var/www/html/config.json
sudo chown www-data:www-data /var/www/html/reminders.json
sudo chown www-data:www-data /var/www/html/birthdays.json
sudo chmod 664 /var/www/html/config.json
sudo chmod 664 /var/www/html/reminders.json
sudo chmod 664 /var/www/html/birthdays.json
4. Configure Kiosk Settings (`config.json`)
This file controls timings and colors for your kiosk display.
- Open for editing:
sudo nano /var/www/html/config.json
- Paste the following content:
{
"statusFetchInterval": 30000,
"pictureDisplayInterval": 10000,
"reminderDisplayDuration": 10000,
"colors": {
"bodyBg": "#000000",
"primaryBarBg": "#333333",
"secondaryBarBg": "#333333",
"textColor": "#ffffff",
"groupBg": "#222222",
"groupBorder": "#555555",
"onlineColor": "#00FF00",
"offlineColor": "#FF0000",
"httpErrorColor": "#FFFF00",
"cfErrorColor": "#FFA500",
"generalErrorColor": "#FF6347",
"connectionFailedColor": "#FF0000",
"pingableColor": "#1E90FF",
"contentMissingColor": "#FFD700",
"reminderUpcomingColor": "#61dafb",
"reminderDueColor": "#FFD700"
}
}
- Save and exit (`Ctrl+X`, `Y`, `Enter`).
5. Create Kiosk Display Files
These are the core files for your main kiosk display page.
- File: /var/www/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiosk Display</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="top-info-bar">
<div id="ticker-content">
</div>
</div>
<div id="image-container">
<img id="kiosk-image" src="" alt="Gallery Image">
</div>
<div id="bottom-status-bar">
<div class="status-group">
<span class="status-group-title">Websites</span>
<div class="status-item">Main Site: <span id="www-status" class="status-indicator">...</span></div>
<div class="status-item">Wiki: <span id="wiki-status" class="status-indicator">...</span></div>
<div class="status-item">Files: <span id="files-status" class="status-indicator">...</span></div>
<div class="status-item">Photos: <span id="photos-status" class="status-indicator">...</span></div>
</div>
<div class="status-group">
<span class="status-group-title">Internal Services</span>
<div class="status-item">Samba Server: <span id="samba-status" class="status-indicator">...</span></div>
<div class="status-item">Jellyfin: <span id="jellyfin-status" class="status-indicator">...</span></div>
<div class="status-item">Nginx Proxy: <span id="nginx-proxy-status" class="status-indicator">...</span></div>
</div>
<div class="status-group">
<span class="status-group-title">Info & Times</span>
<div class="status-item">Current Photo: <span id="current-photo-filename" class="status-indicator">...</span></div>
<div class="status-item">UK Time: <span id="uk-time" class="status-indicator">...</span></div>
<div class="status-item">Spain Time: <span id="spain-time" class="status-indicator">...</span></div>
<div class="status-item">Next Birthday: <span id="next-birthday-display" class="status-indicator">...</span></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
- File: /var/www/html/style.css
:root { /* Define CSS variables */
--body-bg: #000000;
--primary-bar-bg: #333333; /* Top and bottom bar background */
--secondary-bar-bg: #222222; /* Status group background */
--text-color: #ffffff;
--group-border: #555555;
--status-online-color: #00FF00;
--status-offline-color: #FF0000;
--status-http-error-color: #FFFF00;
--status-cf-error-color: #FFA500;
--status-general-error-color: #FF6347;
--status-connection-failed-color: #FF0000;
--status-pingable-color: #1E90FF;
--status-content-missing-color: #FFD700;
--reminder-upcoming-color: #61dafb;
--reminder-due-color: #FFD700;
}
body {
margin: 0;
overflow: hidden;
background-color: var(--body-bg);
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text-color);
font-size: 1.2em;
}
#top-info-bar {
width: 100%;
background-color: var(--primary-bar-bg);
color: var(--text-color);
padding: 5px 0;
box-sizing: border-box;
flex-shrink: 0;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2em;
overflow: hidden;
position: relative;
z-index: 10;
}
#ticker-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 10px;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
text-align: center;
}
#image-container {
flex-grow: 1;
flex-shrink: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
width: 100%;
position: relative;
}
#kiosk-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
transition: opacity 1s ease-in-out;
z-index: 5;
}
#bottom-status-bar {
min-height: 120px;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
align-items: stretch;
font-size: 0.9em;
background-color: var(--primary-bar-bg);
color: var(--text-color);
padding: 5px 0;
box-sizing: border-box;
flex-shrink: 0;
z-index: 10;
}
.status-group {
flex: 1 1 30%;
min-width: 150px;
margin: 5px;
padding: 5px;
border: 1px solid var(--group-border);
border-radius: 5px;
background-color: var(--secondary-bar-bg);
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.status-group-title {
font-weight: bold;
text-decoration: underline;
margin-bottom: 5px;
display: block;
text-align: center;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
flex-shrink: 0;
}
.status-label {
font-weight: normal;
}
.status-indicator {
font-weight: bold;
text-align: right;
margin-left: 5px;
}
/* Status Colors - now reference CSS variables */
.status-indicator.online { color: var(--status-online-color); }
.status-indicator.offline { color: var(--status-offline-color); }
.status-indicator.http-error { color: var(--status-http-error-color); }
.status-indicator.cf-error { color: var(--status-cf-error-color); }
.status-indicator.error { color: var(--status-general-error-color); }
.status-indicator.connection-failed { color: var(--status-connection-failed-color); }
.status-indicator.pingable { color: var(--status-pingable-color); }
.status-indicator.content-missing { color: var(--status-content-missing-color); }
/* Reminder Specific Styles (for single display) */
.reminder-text {
margin-right: 5px;
}
.reminder-time {
font-weight: bold;
flex-shrink: 0;
}
.reminder-upcoming {
color: var(--reminder-upcoming-color);
font-weight: bold;
}
.reminder-due {
color: var(--reminder-due-color);
font-weight: bold;
animation: pulse 1s infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.05); opacity: 0.8; }
}
- File: /var/www/html/script.js
<syntaxhighlight lang="javascript"> // --- DOM Element Constants --- const imageElement = document.getElementById('kiosk-image'); const currentPhotoFilenameSpan = document.getElementById('current-photo-filename'); const tickerContent = document.getElementById('ticker-content');
const wwwStatusSpan = document.getElementById('www-status'); const wikiStatusSpan = document.getElementById('wiki-status'); const filesStatusSpan = document.getElementById('files-status'); const photosStatusSpan = document.getElementById('photos-status');
const sambaStatusSpan = document.getElementById('samba-status'); const jellyfinStatusSpan = document.getElementById('jellyfin-status'); const nginxProxyStatusSpan = document.getElementById('nginx-proxy-status');
const ukTimeSpan = document.getElementById('uk-time'); const spainTimeSpan = document.getElementById('spain-time');
const nextBirthdayDisplaySpan = document.getElementById('next-birthday-display');
// --- GLOBAL SETTINGS & INTERVALS Variables --- let kioskSettings = {};
let activeReminders = []; let currentReminderDisplayIndex = 0; let reminderCyclingIntervalId = null;
let pictureCyclingIntervalId = null;
const statusClassMap = {
'Online': 'online', 'Offline': 'offline', 'Offline (CURL Error)': 'offline', 'HTTP Error': 'http-error', 'CF Error': 'cf-error', 'Error': 'error', 'Connection Failed': 'offline', 'Pingable (UDP)': 'pingable', 'Online (UDP)': 'pingable', 'Offline (UDP)': 'offline', 'Content Missing': 'content-missing'
};
// --- ALL FUNCTION DEFINITIONS ---
function setStatusDisplay(spanElement, statusData) {
if (typeof statusData === 'string') {
statusData = { status: statusData, message: statusData };
}
spanElement.textContent = statusData.status;
spanElement.className = 'status-indicator ' + (statusClassMap[statusData.status] || 'error');
if (statusData.message && statusData.message !== statusData.status && statusData.message !== 'OK (HTTP 200)' && statusData.message !== 'Jellyfin OK' && statusData.message !== 'Proxy OK (HTTP 200)') {
spanElement.textContent = statusData.message;
}
}
function displayCurrentReminder() {
if (activeReminders.length === 0) {
tickerContent.innerHTML = "No reminders active.";
return;
}
const reminder = activeReminders[currentReminderDisplayIndex]; const currentTime = Date.now() / 1000;
const dt = new Date(reminder.timestamp * 1000);
const formattedDate = dt.toLocaleDateString([], { month: 'short', day: 'numeric' });
const formattedTime = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
let statusClass = ;
if (reminder.timestamp <= currentTime && reminder.timestamp >= currentTime - (60 * 5)) {
statusClass = 'reminder-due';
} else if (reminder.timestamp > currentTime && reminder.timestamp <= currentTime + (60 * 15)) {
statusClass = 'reminder-upcoming';
}
tickerContent.innerHTML = `${reminder.text} (${formattedDate} ${formattedTime})`;
currentReminderDisplayIndex = (currentReminderDisplayIndex + 1) % activeReminders.length;
}
// Function to update just the picture
function updatePicture() {
imageElement.style.opacity = 0;
fetch('http://' + window.location.hostname + '/get_next_image.php')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok: ' + response.statusText);
}
return response.json();
})
.then(data => {
if (data.imageUrl) {
const tempImage = new Image();
tempImage.onload = () => {
imageElement.src = tempImage.src;
imageElement.style.opacity = 1;
};
tempImage.onerror = () => {
console.error('Failed to load image:', data.imageUrl);
imageElement.src = 'image_load_error.small.png';
imageElement.style.opacity = 1;
};
tempImage.src = data.imageUrl;
currentPhotoFilenameSpan.textContent = data.currentImageFilename || 'N/A';
} else {
console.warn('No image URL received:', data);
imageElement.src = 'error_image.small.png';
imageElement.style.opacity = 1;
currentPhotoFilenameSpan.textContent = 'Image Error';
}
})
.catch(error => {
console.error('Error fetching picture:', error);
imageElement.src = 'no_connection.png';
imageElement.style.opacity = 1;
currentPhotoFilenameSpan.textContent = 'Pic Conn Error';
});
}
// MODIFIED: updateKioskContent to only fetch status/text data
function updateKioskContent() {
fetch('http://' + window.location.hostname + '/get_status_data.php')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok: ' + response.statusText);
}
return response.json();
})
.then(data => {
if (data.error) {
console.error('PHP Script Error:', data.error);
// Status area errors
[wwwStatusSpan, wikiStatusSpan, filesStatusSpan, photosStatusSpan,
sambaStatusSpan, jellyfinStatusSpan, nginxProxyStatusSpan,
ukTimeSpan, spainTimeSpan, currentPhotoFilenameSpan, nextBirthdayDisplaySpan].forEach(span => {
span.textContent = 'Error';
span.className = 'status-indicator error';
});
currentPhotoFilenameSpan.textContent = 'N/A';
ukTimeSpan.textContent = 'N/A';
spainTimeSpan.textContent = 'N/A';
nextBirthdayDisplaySpan.textContent = 'Error';
activeReminders = [{ text: 'ERROR: PHP Script Failed!', timestamp: Date.now() / 1000 }];
currentReminderDisplayIndex = 0;
if (reminderCyclingIntervalId) clearInterval(reminderCyclingIntervalId);
displayCurrentReminder();
reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration);
return;
}
// Update Website Statuses
if (data.websiteStatuses) {
setStatusDisplay(wwwStatusSpan, data.websiteStatuses['Main Site']);
setStatusDisplay(wikiStatusSpan, data.websiteStatuses['Wiki']);
setStatusDisplay(filesStatusSpan, data.websiteStatuses['Files']);
setStatusDisplay(photosStatusSpan, data.websiteStatuses['Photos']);
}
// Update Internal Service Statuses
if (data.serviceStatuses) {
setStatusDisplay(sambaStatusSpan, data.serviceStatuses['Samba_Server']);
setStatusDisplay(jellyfinStatusSpan, data.serviceStatuses['Jellyfin_Host']);
setStatusDisplay(nginxProxyStatusSpan, data.serviceStatuses['Nginx_Proxy']);
}
// Reminders
activeReminders = data.activeReminders;
currentReminderDisplayIndex = 0;
if (reminderCyclingIntervalId) {
clearInterval(reminderCyclingIntervalId);
}
displayCurrentReminder();
if (activeReminders.length > 1) {
reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration);
}
// Update Next Birthday
if (data.nextBirthday) {
nextBirthdayDisplaySpan.textContent = `${data.nextBirthday.name} (${data.nextBirthday.date})`;
} else {
nextBirthdayDisplaySpan.textContent = 'N/A';
}
})
.catch(error => {
console.error('Error fetching status data (network/server issue):', error);
// General network error affects all status displays
[wwwStatusSpan, wikiStatusSpan, filesStatusSpan, photosStatusSpan,
sambaStatusSpan, jellyfinStatusSpan, nginxProxyStatusSpan,
ukTimeSpan, spainTimeSpan, currentPhotoFilenameSpan, nextBirthdayDisplaySpan].forEach(span => {
span.textContent = 'LXC Down';
span.className = 'status-indicator offline';
});
currentPhotoFilenameSpan.textContent = 'N/A';
ukTimeSpan.textContent = 'N/A';
spainTimeSpan.textContent = 'N/A';
nextBirthdayDisplaySpan.textContent = 'LXC Down';
activeReminders = [{ text: 'ERROR: LXC Offline!', timestamp: Date.now() / 1000 }];
currentReminderDisplayIndex = 0;
if (reminderCyclingIntervalId) clearInterval(reminderCyclingIntervalId);
displayCurrentReminder();
reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration);
});
}
// Function to update all times (client-side, every second) function updateAllTimes() {
const now = new Date();
ukTimeSpan.textContent = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
spainTimeSpan.textContent = now.toLocaleTimeString('es-ES', { timeZone: 'Europe/Madrid', hour: '2-digit', minute: '2-digit', hour12: false });
}
// --- INITIAL SETUP (Load Config First) --- async function initializeKiosk() {
try {
const response = await fetch('http://' + window.location.hostname + '/config.json');
if (!response.ok) {
throw new Error('Failed to load config.json');
}
kioskSettings = await response.json();
console.log('Kiosk settings loaded:', kioskSettings);
// Apply colors from settings
const root = document.documentElement;
root.style.setProperty('--body-bg', kioskSettings.colors.bodyBg);
root.style.setProperty('--primary-bar-bg', kioskSettings.colors.primaryBarBg);
root.style.setProperty('--secondary-bar-bg', kioskSettings.colors.secondaryBarBg);
root.style.setProperty('--text-color', kioskSettings.colors.textColor);
root.style.setProperty('--group-border', kioskSettings.colors.groupBorder);
root.style.setProperty('--status-online-color', kioskSettings.colors.onlineColor);
root.style.setProperty('--status-offline-color', kioskSettings.colors.offlineColor);
root.style.setProperty('--status-http-error-color', kioskSettings.colors.httpErrorColor);
root.style.setProperty('--status-cf-error-color', kioskSettings.colors.cfErrorColor);
root.style.setProperty('--status-general-error-color', kioskSettings.colors.generalErrorColor);
root.style.setProperty('--status-connection-failed-color', kioskSettings.colors.connectionFailedColor);
root.style.setProperty('--status-pingable-color', kioskSettings.colors.pingableColor);
root.style.setProperty('--status-content-missing-color', kioskSettings.colors.contentMissingColor);
root.style.setProperty('--reminder-upcoming-color', kioskSettings.colors.reminderUpcomingColor);
root.style.setProperty('--reminder-due-color', kioskSettings.colors.reminderDueColor);
// Start main intervals after config is loaded
updateKioskContent(); // Initial fetch of status/text data
setInterval(updateKioskContent, kioskSettings.statusFetchInterval); // Schedule status fetches
updatePicture(); // Initial fetch of picture
pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval); // Schedule picture fetches
setInterval(updateAllTimes, 1000); // Keep all times updating every second
} catch (error) {
console.error('Error initializing kiosk with config:', error);
// Fallback to default settings and continue without custom colors/timings if config fails
kioskSettings = {
"statusFetchInterval": 30000, "pictureDisplayInterval": 10000, "reminderDisplayDuration": 10000,
"colors": { /* default colors from CSS - will be hardcoded in CSS if config fails */ }
};
// Continue with default intervals
updateKioskContent();
setInterval(updateKioskContent, kioskSettings.statusFetchInterval);
updatePicture();
pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval);
setInterval(updateAllTimes, 1000);
}
}
updateAllTimes(); // Initial display of times immediately (before config loads) initializeKiosk(); // Start the main kiosk initialization