Restructure file write logic to avoid unnecessary writes #291
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: HTML Render and Screenshot | ||
| on: | ||
| push: | ||
| branches: [main, master] | ||
| paths: | ||
| - 'TankAlarm-092025-Server-Hologram/*.ino' | ||
| - 'TankAlarm-112025-Server-BluesOpta/*.ino' | ||
| - 'TankAlarm-112025-Viewer-BluesOpta/*.ino' | ||
| - '.github/workflows/html-render.yml' | ||
| pull_request: | ||
| branches: [main, master] | ||
| paths: | ||
| - 'TankAlarm-092025-Server-Hologram/*.ino' | ||
| - 'TankAlarm-112025-Server-BluesOpta/*.ino' | ||
| - 'TankAlarm-112025-Viewer-BluesOpta/*.ino' | ||
| - '.github/workflows/html-render.yml' | ||
| types: [opened, synchronize, reopened, review_requested] | ||
| workflow_dispatch: | ||
| jobs: | ||
| render-html: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.head_ref || github.ref_name }} | ||
| fetch-depth: 1 | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '20' | ||
| - name: Install Playwright | ||
| run: | | ||
| npm install playwright | ||
| npx playwright install chromium | ||
| - name: Extract and render HTML for 092025 Server | ||
| run: | | ||
| mkdir -p /tmp/html-092025 | ||
| python3 << 'EOF' | ||
| import re | ||
| import os | ||
| ino_file = 'TankAlarm-092025-Server-Hologram/TankAlarm-092025-Server-Hologram.ino' | ||
| output_dir = '/tmp/html-092025' | ||
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | ||
| content = f.read() | ||
| # Find functions that generate HTML pages | ||
| pages = { | ||
| 'dashboard': ('sendWebPage', 'Dashboard'), | ||
| 'email-management': ('sendEmailManagementPage', 'Email Management'), | ||
| 'tank-management': ('sendTankManagementPage', 'Tank Management'), | ||
| 'calibration': ('sendCalibrationPage', 'Calibration'), | ||
| '404': ('send404Page', '404 Page') | ||
| } | ||
| for page_name, (func_name, title) in pages.items(): | ||
| # Find the function more carefully | ||
| func_pattern = rf'void {func_name}\([^)]*\)\s*\{{' | ||
| match = re.search(func_pattern, content) | ||
| if match: | ||
| start_pos = match.end() | ||
| # Find the matching closing brace | ||
| brace_count = 1 | ||
| pos = start_pos | ||
| while pos < len(content) and brace_count > 0: | ||
| if content[pos] == '{': | ||
| brace_count += 1 | ||
| elif content[pos] == '}': | ||
| brace_count -= 1 | ||
| pos += 1 | ||
| func_body = content[start_pos:pos-1] | ||
| # Extract HTML from client.println statements | ||
| html_lines = [] | ||
| for line in func_body.split('\n'): | ||
| line = line.strip() | ||
| # Match client.println("...") or client.print("...") | ||
| println_match = re.search(r'client\.print(?:ln)?\s*\(\s*[fF]?\s*\(\s*"([^"]*)"\s*\)\s*\)', line) | ||
| if not println_match: | ||
| println_match = re.search(r'client\.print(?:ln)?\s*\(\s*"([^"]*)"\s*\)', line) | ||
| if println_match: | ||
| html_content = println_match.group(1) | ||
| # Unescape the string | ||
| html_content = html_content.replace(r'\"', '"') | ||
| html_content = html_content.replace(r"\'", "'") | ||
| html_content = html_content.replace(r'\n', '\n') | ||
| html_lines.append(html_content) | ||
| # Handle String concatenation with variables | ||
| elif 'client.print' in line: | ||
| # Check for meta refresh | ||
| if 'webPageRefreshSeconds' in line: | ||
| html_lines.append("<meta http-equiv='refresh' content='30'>") | ||
| # For other dynamic content, extract what we can | ||
| elif '"' in line: | ||
| parts = re.findall(r'"([^"]*)"', line) | ||
| for part in parts: | ||
| html_lines.append(part) | ||
| # Join HTML lines | ||
| html_content = '\n'.join(html_lines) | ||
| # Add sample data for dynamic content | ||
| if page_name == 'dashboard': | ||
| # Add sample tank data grouped by sites | ||
| sample_data = ''' | ||
| <h2>North Facility</h2> | ||
| <div class="tank-container"> | ||
| <div class="tank-card"> | ||
| <div class="tank-header">Tank #1</div> | ||
| <div class="tank-level">Level: 85.3%</div> | ||
| <div class="tank-change positive">24hr Change: +2.1%</div> | ||
| <div class="status status-normal">Status: Normal</div> | ||
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:55:56 AM</div> | ||
| </div> | ||
| <div class="tank-card"> | ||
| <div class="tank-header">Tank #2</div> | ||
| <div class="tank-level">Level: 52.1%</div> | ||
| <div class="tank-change negative">24hr Change: -3.2%</div> | ||
| <div class="status status-normal">Status: Normal</div> | ||
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:55:56 AM</div> | ||
| </div> | ||
| </div> | ||
| <h2>South Facility</h2> | ||
| <div class="tank-container"> | ||
| <div class="tank-card"> | ||
| <div class="tank-header">Tank #1</div> | ||
| <div class="tank-level">Level: 22.5%</div> | ||
| <div class="tank-change negative">24hr Change: -8.5%</div> | ||
| <div class="status status-alarm">Status: LOW</div> | ||
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:54:56 AM</div> | ||
| </div> | ||
| </div> | ||
| <h2>East Facility</h2> | ||
| <div class="tank-container"> | ||
| <div class="tank-card"> | ||
| <div class="tank-header">Tank #1</div> | ||
| <div class="tank-level">Level: 95.8%</div> | ||
| <div class="tank-change positive">24hr Change: +1.2%</div> | ||
| <div class="status status-normal">Status: Normal</div> | ||
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:56:26 AM</div> | ||
| </div> | ||
| </div> | ||
| <h2>West Facility</h2> | ||
| <div class="tank-container"> | ||
| <div class="tank-card"> | ||
| <div class="tank-header">Tank #1</div> | ||
| <div class="tank-level">Level: 15.2%</div> | ||
| <div class="tank-change negative">24hr Change: -12.8%</div> | ||
| <div class="status status-alarm">Status: CRITICAL</div> | ||
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:57:26 AM</div> | ||
| </div> | ||
| </div> | ||
| <div class="footer">Last updated: 2025-11-22 18:00:00 UTC</div> | ||
| ''' | ||
| # Replace the "No tank reports received yet" message with sample data | ||
| html_content = html_content.replace( | ||
| "<p style='text-align: center;'>No tank reports received yet.</p>", | ||
| sample_data | ||
| ) | ||
| # Make sure we have closing tags | ||
| if '</html>' not in html_content: | ||
| if '</body>' not in html_content: | ||
| html_content += '\n</body>' | ||
| html_content += '\n</html>' | ||
| # Write to file | ||
| output_file = os.path.join(output_dir, f'{page_name}.html') | ||
| with open(output_file, 'w', encoding='utf-8') as out: | ||
| out.write(html_content) | ||
| print(f'Extracted {page_name}: {len(html_lines)} lines') | ||
| EOF | ||
| - name: Extract and render HTML for 112025 Server | ||
| run: | | ||
| mkdir -p /tmp/html-112025 | ||
| python3 << 'EOF' | ||
| import re | ||
| import json | ||
| import time | ||
| ino_file = 'TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino' | ||
| output_dir = '/tmp/html-112025' | ||
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | ||
| content = f.read() | ||
| current_time = int(time.time()) | ||
| # Sample data for dashboard and client console | ||
| sample_clients_data = { | ||
| "si": "dev:server001", | ||
| "srv": { | ||
| "n": "Demo Tank Alarm Server", | ||
| "cf": "demo-tankalarm-clients", | ||
| "ps": False, | ||
| "pc": True | ||
| }, | ||
| "nde": current_time + 43200, | ||
| "lse": current_time - 180, | ||
| "cs": [ | ||
| {"c": "dev:client001", "s": "North Facility", "n": "Primary", "k": "1", "l": 78.5, "p": 85.3, "a": False, "u": current_time - 180, "v": 12.4}, | ||
| {"c": "dev:client001", "s": "North Facility", "n": "Secondary", "k": "2", "l": 45.2, "p": 52.1, "a": False, "u": current_time - 180, "v": 12.4}, | ||
| {"c": "dev:client002", "s": "South Facility", "n": "Storage", "k": "1", "l": 18.3, "p": 22.5, "a": True, "aType": "LOW", "u": current_time - 240, "v": 11.8}, | ||
| {"c": "dev:client003", "s": "East Facility", "n": "Main", "k": "1", "l": 92.1, "p": 95.8, "a": False, "u": current_time - 150, "v": 12.6}, | ||
| {"c": "dev:client004", "s": "West Facility", "n": "Backup", "k": "1", "l": 12.7, "p": 15.2, "a": True, "aType": "CRITICAL", "u": current_time - 90, "v": 10.2} | ||
| ] | ||
| } | ||
| # Extract all HTML pages | ||
| html_pages = { | ||
| 'DASHBOARD_HTML': 'dashboard', | ||
| 'CONFIG_GENERATOR_HTML': 'config-generator', | ||
| 'SERIAL_MONITOR_HTML': 'serial-monitor', | ||
| 'CALIBRATION_HTML': 'calibration', | ||
| 'CONTACTS_MANAGER_HTML': 'contacts', | ||
| 'CLIENT_CONSOLE_HTML': 'client-console' | ||
| } | ||
| for const_name, file_name in html_pages.items(): | ||
| pattern = rf'static const char {const_name}\[\] PROGMEM = R"HTML\((.*?)\)HTML"' | ||
| match = re.search(pattern, content, re.DOTALL) | ||
| if match: | ||
| html_content = match.group(1) | ||
| # For dashboard, inject sample data | ||
| if const_name == 'DASHBOARD_HTML': | ||
| sample_json = json.dumps(sample_clients_data) | ||
| html_content = html_content.replace('refreshData();', f'applyServerData({sample_json});') | ||
| # For client console, inject sample data and create TWO versions | ||
| elif const_name == 'CLIENT_CONSOLE_HTML': | ||
| sample_json = json.dumps(sample_clients_data) | ||
| # Version 1: With PIN modal showing (PIN setup view) | ||
| html_with_pin = html_content.replace('refreshData();', f''' | ||
| // Inject sample data | ||
| applyServerData({sample_json}, "dev:client001"); | ||
| // Show PIN setup modal immediately for preview | ||
| setTimeout(() => {{ | ||
| pinState.configured = false; | ||
| pinState.value = ''; | ||
| showPinModal('setup'); | ||
| }}, 100); | ||
| ''') | ||
| # Version 2: Without PIN modal (unlocked view) | ||
| html_unlocked = html_content.replace('refreshData();', f''' | ||
| // Inject sample data and simulate unlocked state | ||
| pinState.configured = true; | ||
| pinState.value = 'PREVIEW'; // Simulated unlocked state for preview only | ||
| applyServerData({sample_json}, "dev:client001"); | ||
| // Force hide the PIN modal for the unlocked preview | ||
| setTimeout(() => {{ | ||
| const modal = document.getElementById('pinModal'); | ||
| if (modal) {{ | ||
| modal.classList.add('hidden'); | ||
| }} | ||
| }}, 200); | ||
| ''') | ||
| # For serial monitor, inject sample log data | ||
| elif const_name == 'SERIAL_MONITOR_HTML': | ||
| sample_logs = { | ||
| "logs": [ | ||
| {"timestamp": current_time - 60, "level": "INFO", "source": "server", "message": "Server initialized successfully"}, | ||
| {"timestamp": current_time - 45, "level": "INFO", "source": "server", "message": "Connected to Notehub"}, | ||
| {"timestamp": current_time - 30, "level": "DEBUG", "source": "server", "message": "Polling for client updates..."}, | ||
| {"timestamp": current_time - 15, "level": "INFO", "source": "server", "message": "Received telemetry from dev:client001"} | ||
| ] | ||
| } | ||
| sample_json = json.dumps(sample_logs) | ||
| html_content = html_content.replace('refreshServerLogs();', f'renderLogs(els.serverLogs, {sample_json}.logs);') | ||
| # For calibration, inject sample calibration data | ||
| elif const_name == 'CALIBRATION_HTML': | ||
| sample_cal = { | ||
| "calibrations": [ | ||
| {"clientUid": "dev:client001", "tankNumber": 1, "entryCount": 5, "hasLearnedCalibration": True, "rSquared": 0.987, "learnedSlope": 7.42, "originalMaxValue": 120, "lastCalibrationEpoch": current_time - 86400}, | ||
| {"clientUid": "dev:client002", "tankNumber": 1, "entryCount": 2, "hasLearnedCalibration": False, "rSquared": 0, "learnedSlope": 0, "originalMaxValue": 96, "lastCalibrationEpoch": current_time - 172800} | ||
| ], | ||
| "logs": [] | ||
| } | ||
| sample_tanks = { | ||
| "tanks": [ | ||
| {"client": "dev:client001", "tank": 1, "site": "North Facility", "label": "Primary", "heightInches": 120, "levelInches": 78.5, "sensorMa": 14.2}, | ||
| {"client": "dev:client002", "tank": 1, "site": "South Facility", "label": "Storage", "heightInches": 96, "levelInches": 18.3, "sensorMa": 6.8} | ||
| ] | ||
| } | ||
| html_content = html_content.replace('loadTanks();', f'tanks = {json.dumps(sample_tanks["tanks"])}; populateTankDropdowns();') | ||
| html_content = html_content.replace('loadCalibrationData();', f'calibrations = {json.dumps(sample_cal["calibrations"])}; calibrationLogs = []; updateCalibrationStats(); updateCalibrationTable(); updateCalibrationLog();') | ||
| # For contacts, inject sample contact data | ||
| elif const_name == 'CONTACTS_MANAGER_HTML': | ||
| # Just render the page without API calls | ||
| html_content = html_content.replace('loadData();', '// Sample data mode - loadData() disabled for static preview') | ||
| # For CLIENT_CONSOLE_HTML, write the special versions | ||
| if const_name == 'CLIENT_CONSOLE_HTML': | ||
| # Write the PIN setup version | ||
| output_file_pin = f'{output_dir}/client-console-pin-setup.html' | ||
| with open(output_file_pin, 'w', encoding='utf-8') as out: | ||
| out.write(html_with_pin) | ||
| print(f'Extracted client-console-pin-setup HTML: {len(html_with_pin)} bytes') | ||
| # Write the unlocked version as the primary file | ||
| output_file = f'{output_dir}/{file_name}.html' | ||
| with open(output_file, 'w', encoding='utf-8') as out: | ||
| out.write(html_unlocked) | ||
| print(f'Extracted client-console (unlocked) HTML: {len(html_unlocked)} bytes') | ||
| else: | ||
| # Write the primary version for all other pages | ||
| output_file = f'{output_dir}/{file_name}.html' | ||
| with open(output_file, 'w', encoding='utf-8') as out: | ||
| out.write(html_content) | ||
| print(f'Extracted {file_name} HTML: {len(html_content)} bytes') | ||
| EOF | ||
| - name: Extract and render HTML for 112025 Viewer | ||
| run: | | ||
| mkdir -p /tmp/html-112025-viewer | ||
| python3 << 'EOF' | ||
| import re | ||
| import json | ||
| import time | ||
| ino_file = 'TankAlarm-112025-Viewer-BluesOpta/TankAlarm-112025-Viewer-BluesOpta.ino' | ||
| output_dir = '/tmp/html-112025-viewer' | ||
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | ||
| content = f.read() | ||
| # Find the VIEWER_DASHBOARD_HTML constant - need to handle macro splits | ||
| # The HTML is split into multiple R"HTML()HTML" sections with STR() macros in between | ||
| start_pattern = r'static const char VIEWER_DASHBOARD_HTML\[\] PROGMEM = R"HTML\(' | ||
| start_match = re.search(start_pattern, content) | ||
| if start_match: | ||
| # Find the end - look for the final )HTML"; that ends the declaration | ||
| start_pos = start_match.end() | ||
| # Find the position after the HTML definition ends | ||
| end_pattern = r'\)HTML";' | ||
| remaining = content[start_pos:] | ||
| # Collect all parts, handling the macro splits | ||
| html_parts = [] | ||
| current_pos = 0 | ||
| while True: | ||
| # Find next )HTML" | ||
| end_match = re.search(r'\)HTML"', remaining[current_pos:]) | ||
| if not end_match: | ||
| break | ||
| # Extract the HTML part | ||
| html_parts.append(remaining[current_pos:current_pos + end_match.start()]) | ||
| # Check if this is the final terminator | ||
| check_pos = current_pos + end_match.end() | ||
| if check_pos < len(remaining) and remaining[check_pos:check_pos+1] == ';': | ||
| # This is the final terminator | ||
| break | ||
| # Look for the next R"HTML( | ||
| next_start = re.search(r'R"HTML\(', remaining[check_pos:]) | ||
| if next_start: | ||
| # Extract the macro value in between | ||
| macro_section = remaining[check_pos:check_pos + next_start.start()] | ||
| # Replace STR(WEB_REFRESH_SECONDS) with 30 | ||
| if 'STR(WEB_REFRESH_SECONDS)' in macro_section: | ||
| html_parts.append('30') | ||
| current_pos = check_pos + next_start.end() | ||
| else: | ||
| break | ||
| html_content = ''.join(html_parts) | ||
| # Create sample data for the viewer | ||
| current_time = int(time.time()) | ||
| sample_data = { | ||
| "viewerName": "Demo Tank Alarm Viewer", | ||
| "viewerUid": "dev:viewer001", | ||
| "sourceServerName": "Demo Server", | ||
| "sourceServerUid": "dev:server001", | ||
| "generatedEpoch": current_time - 300, # 5 minutes ago | ||
| "lastFetchEpoch": current_time - 120, # 2 minutes ago | ||
| "nextFetchEpoch": current_time + 21480, # ~6 hours from now | ||
| "refreshSeconds": 21600, # 6 hours | ||
| "baseHour": 6, | ||
| "tanks": [ | ||
| { | ||
| "client": "dev:client001", | ||
| "site": "North Facility", | ||
| "label": "Primary", | ||
| "tank": 1, | ||
| "levelInches": 78.5, | ||
| "percent": 85.3, | ||
| "alarm": False, | ||
| "lastUpdate": current_time - 180 | ||
| }, | ||
| { | ||
| "client": "dev:client001", | ||
| "site": "North Facility", | ||
| "label": "Secondary", | ||
| "tank": 2, | ||
| "levelInches": 45.2, | ||
| "percent": 52.1, | ||
| "alarm": False, | ||
| "lastUpdate": current_time - 180 | ||
| }, | ||
| { | ||
| "client": "dev:client002", | ||
| "site": "South Facility", | ||
| "label": "Storage", | ||
| "tank": 1, | ||
| "levelInches": 18.3, | ||
| "percent": 22.5, | ||
| "alarm": True, | ||
| "alarmType": "LOW", | ||
| "lastUpdate": current_time - 240 | ||
| }, | ||
| { | ||
| "client": "dev:client003", | ||
| "site": "East Facility", | ||
| "label": "Main", | ||
| "tank": 1, | ||
| "levelInches": 92.1, | ||
| "percent": 95.8, | ||
| "alarm": False, | ||
| "lastUpdate": current_time - 150 | ||
| }, | ||
| { | ||
| "client": "dev:client004", | ||
| "site": "West Facility", | ||
| "label": "Backup", | ||
| "tank": 1, | ||
| "levelInches": 12.7, | ||
| "percent": 15.2, | ||
| "alarm": True, | ||
| "alarmType": "CRITICAL", | ||
| "lastUpdate": current_time - 90 | ||
| } | ||
| ] | ||
| } | ||
| # Inject the sample data into the HTML by replacing the fetchTanks call | ||
| # Find the fetchTanks() call at the end of the script and replace it with data injection | ||
| sample_data_json = json.dumps(sample_data) | ||
| # Replace the async fetchTanks call with synchronous data application | ||
| html_content = html_content.replace( | ||
| 'fetchTanks();', | ||
| f'applyTankData({sample_data_json}, null);' | ||
| ) | ||
| # Also remove the setInterval call to prevent attempts to fetch from non-existent API | ||
| html_content = re.sub( | ||
| r'setInterval\(\(\) => fetchTanks\(state\.selected\), REFRESH_SECONDS \* 1000\);', | ||
| '// Auto-refresh disabled for static preview', | ||
| html_content | ||
| ) | ||
| output_file = output_dir + '/dashboard.html' | ||
| with open(output_file, 'w', encoding='utf-8') as out: | ||
| out.write(html_content) | ||
| print(f'Extracted viewer dashboard HTML: {len(html_content)} bytes') | ||
| print(f'Injected sample data with {len(sample_data["tanks"])} tanks') | ||
| EOF | ||
| - name: Take screenshots with Playwright | ||
| run: | | ||
| node << 'EOF' | ||
| const playwright = require('playwright'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| async function takeScreenshots() { | ||
| const browser = await playwright.chromium.launch(); | ||
| const context = await browser.newContext({ | ||
| viewport: { width: 1280, height: 720 } | ||
| }); | ||
| const page = await context.newPage(); | ||
| // Screenshot 092025 pages | ||
| const dir092025 = '/tmp/html-092025'; | ||
| const output092025 = 'TankAlarm-092025-Server-Hologram/screenshots'; | ||
| if (fs.existsSync(dir092025)) { | ||
| fs.mkdirSync(output092025, { recursive: true }); | ||
| const files = fs.readdirSync(dir092025); | ||
| for (const file of files) { | ||
| if (file.endsWith('.html')) { | ||
| const htmlPath = path.join(dir092025, file); | ||
| const screenshotName = file.replace('.html', '.png'); | ||
| const screenshotPath = path.join(output092025, screenshotName); | ||
| try { | ||
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | ||
| await page.screenshot({ path: screenshotPath, fullPage: true }); | ||
| console.log(`Screenshot saved: ${screenshotPath}`); | ||
| } catch (error) { | ||
| console.error(`Error capturing ${file}:`, error.message); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // Screenshot 112025 pages | ||
| const dir112025 = '/tmp/html-112025'; | ||
| const output112025 = 'TankAlarm-112025-Server-BluesOpta/screenshots'; | ||
| if (fs.existsSync(dir112025)) { | ||
| fs.mkdirSync(output112025, { recursive: true }); | ||
| const files = fs.readdirSync(dir112025); | ||
| for (const file of files) { | ||
| if (file.endsWith('.html')) { | ||
| const htmlPath = path.join(dir112025, file); | ||
| const screenshotName = file.replace('.html', '.png'); | ||
| const screenshotPath = path.join(output112025, screenshotName); | ||
| try { | ||
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | ||
| await page.screenshot({ path: screenshotPath, fullPage: true }); | ||
| console.log(`Screenshot saved: ${screenshotPath}`); | ||
| } catch (error) { | ||
| console.error(`Error capturing ${file}:`, error.message); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // Screenshot 112025 Viewer pages | ||
| const dir112025Viewer = '/tmp/html-112025-viewer'; | ||
| const output112025Viewer = 'TankAlarm-112025-Viewer-BluesOpta/screenshots'; | ||
| if (fs.existsSync(dir112025Viewer)) { | ||
| fs.mkdirSync(output112025Viewer, { recursive: true }); | ||
| const files = fs.readdirSync(dir112025Viewer); | ||
| for (const file of files) { | ||
| if (file.endsWith('.html')) { | ||
| const htmlPath = path.join(dir112025Viewer, file); | ||
| const screenshotName = file.replace('.html', '.png'); | ||
| const screenshotPath = path.join(output112025Viewer, screenshotName); | ||
| try { | ||
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | ||
| await page.screenshot({ path: screenshotPath, fullPage: true }); | ||
| console.log(`Screenshot saved: ${screenshotPath}`); | ||
| } catch (error) { | ||
| console.error(`Error capturing ${file}:`, error.message); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| await browser.close(); | ||
| } | ||
| takeScreenshots().catch(console.error); | ||
| EOF | ||
| - name: Generate WEBSITE_PREVIEW.md for 092025 | ||
| run: | | ||
| cat > TankAlarm-092025-Server-Hologram/WEBSITE_PREVIEW.md << 'EOF' | ||
| # Website Preview - TankAlarm 092025 Server | ||
| This document contains screenshots of the web interface served by the TankAlarm-092025-Server-Hologram. | ||
| ## Dashboard | ||
|  | ||
| ## Email Management | ||
|  | ||
| ## Tank Management | ||
|  | ||
| ## Calibration | ||
|  | ||
| ## 404 Page | ||
|  | ||
| --- | ||
| *Screenshots automatically generated by GitHub Actions* | ||
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | ||
| EOF | ||
| - name: Generate WEBSITE_PREVIEW.md for 112025 | ||
| run: | | ||
| cat > TankAlarm-112025-Server-BluesOpta/WEBSITE_PREVIEW.md << 'EOF' | ||
| # Website Preview - TankAlarm 112025 Server | ||
| This document contains screenshots of the web interface served by the TankAlarm-112025-Server-BluesOpta. | ||
| ## Dashboard | ||
| Main fleet telemetry dashboard showing server metadata, statistics, and fleet telemetry table with relay controls. | ||
|  | ||
| ## Client Console - PIN Setup | ||
| PIN setup modal shown on first access to secure the configuration interface. | ||
|  | ||
| ## Client Console | ||
| Configuration management interface for remote clients with PIN-protected controls (unlocked view). | ||
|  | ||
| ## Config Generator | ||
| Create new client configurations with sensor definitions and alarm thresholds. | ||
|  | ||
| ## Serial Monitor | ||
| Debug log viewer with server and client serial output. | ||
|  | ||
| ## Calibration | ||
| Calibration learning system with manual level reading submission and drift analysis. | ||
|  | ||
| ## Contacts Manager | ||
| Contact and notification management with alarm associations. | ||
|  | ||
| --- | ||
| *Screenshots automatically generated by GitHub Actions* | ||
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | ||
| EOF | ||
| - name: Generate WEBSITE_PREVIEW.md for 112025 Viewer | ||
| run: | | ||
| cat > TankAlarm-112025-Viewer-BluesOpta/WEBSITE_PREVIEW.md << 'EOF' | ||
| # Website Preview - TankAlarm 112025 Viewer | ||
| This document contains screenshots of the web interface served by the TankAlarm-112025-Viewer-BluesOpta. | ||
| ## Dashboard | ||
| Read-only fleet telemetry dashboard showing server metadata and fleet snapshot with tank levels. | ||
|  | ||
| --- | ||
| *Screenshots automatically generated by GitHub Actions* | ||
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | ||
| EOF | ||
| - name: Upload screenshots as artifacts | ||
| if: github.event_name == 'pull_request' | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: website-screenshots | ||
| path: | | ||
| TankAlarm-092025-Server-Hologram/screenshots/ | ||
| TankAlarm-112025-Server-BluesOpta/screenshots/ | ||
| TankAlarm-112025-Viewer-BluesOpta/screenshots/ | ||
| retention-days: 7 | ||
| - name: Post preview comment on PR | ||
| if: github.event_name == 'pull_request' | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| // Check which screenshots were generated | ||
| const screenshotDirs = [ | ||
| { dir: 'TankAlarm-112025-Server-BluesOpta/screenshots', name: '112025 Server' }, | ||
| { dir: 'TankAlarm-112025-Viewer-BluesOpta/screenshots', name: '112025 Viewer' }, | ||
| { dir: 'TankAlarm-092025-Server-Hologram/screenshots', name: '092025 Server' } | ||
| ]; | ||
| let commentBody = '## 🖼️ Website Preview\n\n'; | ||
| commentBody += 'Screenshots have been generated for the web pages modified in this PR.\n\n'; | ||
| commentBody += '> **Note:** Download the `website-screenshots` artifact to view the full-resolution images.\n\n'; | ||
| for (const { dir, name } of screenshotDirs) { | ||
| if (fs.existsSync(dir)) { | ||
| const files = fs.readdirSync(dir).filter(f => f.endsWith('.png')); | ||
| if (files.length > 0) { | ||
| commentBody += `### ${name}\n\n`; | ||
| commentBody += '| Page | Screenshot |\n'; | ||
| commentBody += '|------|------------|\n'; | ||
| for (const file of files.sort()) { | ||
| const pageName = file.replace('.png', '').replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); | ||
| commentBody += `| ${pageName} | ✅ Generated |\n`; | ||
| } | ||
| commentBody += '\n'; | ||
| } | ||
| } | ||
| } | ||
| commentBody += '---\n'; | ||
| commentBody += `*Generated by GitHub Actions on ${new Date().toISOString()}*`; | ||
| // Find existing comment to update or create new one | ||
| const { data: comments } = await github.rest.issues.listComments({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: context.issue.number | ||
| }); | ||
| const botComment = comments.find(comment => | ||
| comment.user.type === 'Bot' && | ||
| comment.body.includes('## 🖼️ Website Preview') | ||
| ); | ||
| if (botComment) { | ||
| await github.rest.issues.updateComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| comment_id: botComment.id, | ||
| body: commentBody | ||
| }); | ||
| console.log('Updated existing preview comment'); | ||
| } else { | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: context.issue.number, | ||
| body: commentBody | ||
| }); | ||
| console.log('Created new preview comment'); | ||
| } | ||
| - name: Commit and push changes | ||
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') | ||
| run: | | ||
| git config --local user.email "github-actions[bot]@users.noreply.github.com" | ||
| git config --local user.name "github-actions[bot]" | ||
| git add TankAlarm-092025-Server-Hologram/WEBSITE_PREVIEW.md TankAlarm-092025-Server-Hologram/screenshots/ | ||
| git add TankAlarm-112025-Server-BluesOpta/WEBSITE_PREVIEW.md TankAlarm-112025-Server-BluesOpta/screenshots/ | ||
| git add TankAlarm-112025-Viewer-BluesOpta/WEBSITE_PREVIEW.md TankAlarm-112025-Viewer-BluesOpta/screenshots/ | ||
| git diff --staged --quiet || git commit -m "Update website preview screenshots [skip ci]" | ||
| git push origin HEAD:${{ github.head_ref || github.ref_name }} | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||