Kiosk Container

From Sea of Fate
Jump to navigationJump to search

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>
        <img id="kiosk-image" src="" alt="Gallery Image">
            Websites
Main Site: ...
Wiki: ...
Files: ...
Photos: ...
            Internal Services
Samba Server: ...
Jellyfin: ...
Nginx Proxy: ...
            Info & Times
Current Photo: ...
UK Time: ...
Spain Time: ...
Next Birthday: ...
    <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; }
}
</syntaxhighlight>
* 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 });
}

JavaScript: Initial Setup and Configuration Loading

This section of script.js is crucial. It runs once when the index.html page first loads, responsible for fetching your kiosk settings from config.json, applying those settings, and then starting all the recurring update intervals for your display.

If config.json cannot be loaded or parsed, the script will fall back to using default settings, ensuring the kiosk still functions.

<syntaxhighlight lang="javascript"> // --- INITIAL SETUP (Load Config First) --- // This asynchronous function is called once to initialize the entire kiosk display. async function initializeKiosk() { try { // Attempt to fetch the config.json file from the same web server. const response = await fetch('http://' + window.location.hostname + '/config.json'); if (!response.ok) { // If the fetch operation itself fails (e.g., file not found, network error), // throw an error to immediately jump to the catch block for fallback. throw new Error('Failed to load config.json: ' + response.statusText); } // Parse the JSON response into the global kioskSettings object. kioskSettings = await response.json(); console.log('Kiosk settings loaded:', kioskSettings);

   // Apply colors from the loaded settings to CSS variables.
   // This dynamically updates the display's colors based on your configuration.
   const root = document.documentElement; // This targets the :root pseudo-class in CSS
   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 all main update intervals using the loaded settings.
   // These functions (updateKioskContent, updatePicture, updateAllTimes) are defined earlier in script.js.
   // Initial fetch of status/text data (and sets up initial reminder cycle).
   updateKioskContent();
   // Schedule subsequent status data fetches using the interval from config.json.
   setInterval(updateKioskContent, kioskSettings.statusFetchInterval);
   // Initial fetch of picture.
   updatePicture();
   // Schedule subsequent picture fetches using the interval from config.json.
   pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval);
   // Keep all times (local, UK, Spain) updating every second. This interval is not configurable via config.json.
   setInterval(updateAllTimes, 1000); 

} catch (error) {

   // This catch block executes if `config.json` cannot be loaded or parsed, or any other error occurs during initialization.
   console.error('Error initializing kiosk with config:', error);
   
   // Fallback to default settings. These values should match your initial CSS defaults for consistency.
   // This ensures the kiosk still runs with basic functionality even if config.json is missing or corrupt.
   kioskSettings = { 
       "statusFetchInterval": 30000, // Default status refresh
       "pictureDisplayInterval": 10000, // Default picture refresh
       "reminderDisplayDuration": 10000, // Default reminder cycle
       "colors": { /* Default colors from CSS will apply if config.json fails */ } 
   };
   
   // Alert the user about the configuration failure (visible in browser, might need to check console on kiosk).
   alert('Kiosk configuration could not be loaded. Running with default settings. Please check config.json file and its permissions.');
   // Continue starting intervals using these fallback default values.
   // The kiosk will still attempt to function using its hardcoded CSS colors.
   updateKioskContent();
   setInterval(updateKioskContent, kioskSettings.statusFetchInterval); 
   updatePicture();
   pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval);
   setInterval(updateAllTimes, 1000); 

} }

// Initial calls that kick off the entire process when script.js first loads in the browser. // Update times immediately so they show up quickly before the main config loads. updateAllTimes(); // Start the main kiosk initialization process, which will load the config and then set up other intervals. initializeKiosk(); </syntaxhighlight>