Adds fastfetch/Server Observations as a screen
This commit is contained in:
parent
a85705b9be
commit
57a766380a
11 changed files with 210 additions and 10 deletions
11
DEVLOG.md
Normal file
11
DEVLOG.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
## Server Observations
|
||||||
|
|
||||||
|
Added a new `Server Observations` forecast screen that blends Linux server telemetry into the WeatherStar rotation using `fastfetch`.
|
||||||
|
|
||||||
|
- added `/api/server-info` to run `fastfetch` and return a cleaned plain-text summary
|
||||||
|
- created a new `serverobservations` display module and EJS partial
|
||||||
|
- registered the new screen in the main display deck and script loading flow
|
||||||
|
- paginated the server info across multiple readable screens instead of trying to force a single page
|
||||||
|
- adjusted styling to better fit the blue content box and reduced the header title size for this screen
|
||||||
|
- updated navigation handling to safely work with sparse display arrays introduced by the new nav slot
|
||||||
|
- updated the frontend build so generated CSS is copied to the development-served stylesheet path as well
|
||||||
|
|
@ -102,7 +102,8 @@ const buildCss = () => src(cssSources)
|
||||||
.pipe(sass({ style: 'compressed' }).on('error', sass.logError))
|
.pipe(sass({ style: 'compressed' }).on('error', sass.logError))
|
||||||
.pipe(rename({ suffix: '.min' }))
|
.pipe(rename({ suffix: '.min' }))
|
||||||
.pipe(sourceMaps.write('./'))
|
.pipe(sourceMaps.write('./'))
|
||||||
.pipe(dest(RESOURCES_PATH));
|
.pipe(dest(RESOURCES_PATH))
|
||||||
|
.pipe(dest('./server/styles'));
|
||||||
|
|
||||||
const htmlSources = [
|
const htmlSources = [
|
||||||
'views/*.ejs',
|
'views/*.ejs',
|
||||||
|
|
|
||||||
26
index.mjs
26
index.mjs
|
|
@ -2,6 +2,8 @@ import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
import {
|
import {
|
||||||
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
|
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
|
||||||
} from './proxy/handlers.mjs';
|
} from './proxy/handlers.mjs';
|
||||||
|
|
@ -10,6 +12,8 @@ import OVERRIDES from './src/overrides.mjs';
|
||||||
import cache from './proxy/cache.mjs';
|
import cache from './proxy/cache.mjs';
|
||||||
import devTools from './src/com.chrome.devtools.mjs';
|
import devTools from './src/com.chrome.devtools.mjs';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
||||||
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
|
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
|
||||||
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
|
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
|
||||||
|
|
@ -117,6 +121,28 @@ const staticOptions = {
|
||||||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||||
// Skip setting up routes for the caching proxy server in static mode
|
// Skip setting up routes for the caching proxy server in static mode
|
||||||
if (!process.env?.STATIC) {
|
if (!process.env?.STATIC) {
|
||||||
|
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
|
||||||
|
app.get('/api/server-info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Use --structure to show only essential info: OS, Kernel, Uptime, CPU, GPU, Memory, Disk
|
||||||
|
const { stdout } = await execAsync('fastfetch --structure "Title:OS:Kernel:Uptime:CPU:GPU:Memory:Disk" --pipe false');
|
||||||
|
// Strip all ANSI escape sequences (color codes, cursor positioning, etc.)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleanOutput = stdout.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: cleanOutput,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// fastfetch not available or other error
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/api/', weatherProxy);
|
app.use('/api/', weatherProxy);
|
||||||
|
|
||||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ const getWeather = async (latLon, haveDataCallback) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// call for new data on each display
|
// call for new data on each display
|
||||||
displays.forEach((display) => display.getData(weatherParameters));
|
displays.forEach((display) => display?.getData(weatherParameters));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to get weather data: ${error.message}`);
|
console.error(`Failed to get weather data: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -172,12 +172,12 @@ const updateStatus = (value) => {
|
||||||
// the weather.gov api has long load times for some products when you are the first
|
// the weather.gov api has long load times for some products when you are the first
|
||||||
// requester for the product after the cache expires
|
// requester for the product after the cache expires
|
||||||
const countLoadedDisplays = () => displays.reduce((acc, display) => {
|
const countLoadedDisplays = () => displays.reduce((acc, display) => {
|
||||||
if (display.status !== STATUS.loading) return acc + 1;
|
if (display?.status !== STATUS.loading) return acc + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const hideAllCanvases = () => {
|
const hideAllCanvases = () => {
|
||||||
displays.forEach((display) => display.hideCanvas());
|
displays.forEach((display) => display?.hideCanvas());
|
||||||
};
|
};
|
||||||
|
|
||||||
// is playing interface
|
// is playing interface
|
||||||
|
|
@ -250,7 +250,7 @@ const loadDisplay = (direction) => {
|
||||||
for (let i = 0; i < totalDisplays; i += 1) {
|
for (let i = 0; i < totalDisplays; i += 1) {
|
||||||
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
||||||
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
||||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) {
|
if (displays[idx]?.status === STATUS.loaded && displays[idx]?.timing.totalScreens > 0) {
|
||||||
// Prevent infinite recursion by ensuring we don't select the same display
|
// Prevent infinite recursion by ensuring we don't select the same display
|
||||||
if (idx !== curIdx) {
|
if (idx !== curIdx) {
|
||||||
foundSuitableDisplay = true;
|
foundSuitableDisplay = true;
|
||||||
|
|
@ -272,6 +272,10 @@ const loadDisplay = (direction) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDisplay = displays[idx];
|
const newDisplay = displays[idx];
|
||||||
|
if (!newDisplay) {
|
||||||
|
console.warn('Selected display is undefined, aborting navigation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// hide all displays
|
// hide all displays
|
||||||
hideAllCanvases();
|
hideAllCanvases();
|
||||||
// show the new display and navigate to an appropriate display
|
// show the new display and navigate to an appropriate display
|
||||||
|
|
@ -280,7 +284,7 @@ const loadDisplay = (direction) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// get the current display index or value
|
// get the current display index or value
|
||||||
const currentDisplayIndex = () => displays.findIndex((display) => display.active);
|
const currentDisplayIndex = () => displays.findIndex((display) => display?.active);
|
||||||
const currentDisplay = () => displays[currentDisplayIndex()];
|
const currentDisplay = () => displays[currentDisplayIndex()];
|
||||||
|
|
||||||
const setPlaying = (newValue) => {
|
const setPlaying = (newValue) => {
|
||||||
|
|
@ -586,7 +590,7 @@ const resize = (force = false) => {
|
||||||
|
|
||||||
// reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh
|
// reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh
|
||||||
const resetStatuses = () => {
|
const resetStatuses = () => {
|
||||||
displays.forEach((display) => { display.status = STATUS.loading; });
|
displays.forEach((display) => { if (display) display.status = STATUS.loading; });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply scanline scaling to try and prevent banding by avoiding fractional scaling
|
// Apply scanline scaling to try and prevent banding by avoiding fractional scaling
|
||||||
|
|
@ -761,7 +765,7 @@ const generateCheckboxes = () => {
|
||||||
|
|
||||||
if (!availableDisplays) return;
|
if (!availableDisplays) return;
|
||||||
// generate checkboxes
|
// generate checkboxes
|
||||||
const checkboxes = displays.map((d) => d.generateCheckbox(d.defaultEnabled)).filter((d) => d);
|
const checkboxes = displays.map((d) => d?.generateCheckbox(d?.defaultEnabled)).filter((d) => d);
|
||||||
|
|
||||||
// write to page
|
// write to page
|
||||||
availableDisplays.innerHTML = '';
|
availableDisplays.innerHTML = '';
|
||||||
|
|
|
||||||
98
server/scripts/modules/serverobservations.mjs
Normal file
98
server/scripts/modules/serverobservations.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Server Observations display - shows fastfetch output
|
||||||
|
import { safeJson } from './utils/fetch.mjs';
|
||||||
|
import STATUS from './status.mjs';
|
||||||
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
|
import { registerDisplay } from './navigation.mjs';
|
||||||
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
|
||||||
|
const LINES_PER_PAGE = 4;
|
||||||
|
const PAGE_DURATION_MS = 7000;
|
||||||
|
|
||||||
|
class ServerObservations extends WeatherDisplay {
|
||||||
|
constructor(navId, elemId) {
|
||||||
|
super(navId, elemId, 'Server Observations', true);
|
||||||
|
|
||||||
|
// Don't show on progress screen
|
||||||
|
this.showOnProgress = false;
|
||||||
|
this.timing.baseDelay = PAGE_DURATION_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(weatherParameters, refresh) {
|
||||||
|
if (!super.getData(weatherParameters, refresh)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch server info from the API
|
||||||
|
const response = await safeJson('/api/server-info', {
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if fastfetch is available
|
||||||
|
if (!response || !response.success) {
|
||||||
|
if (debugFlag('serverobservations')) {
|
||||||
|
console.log('Server Observations: fastfetch not available');
|
||||||
|
}
|
||||||
|
this.setStatus(STATUS.noData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = response.data;
|
||||||
|
this.setStatus(STATUS.loaded);
|
||||||
|
} catch (error) {
|
||||||
|
if (debugFlag('serverobservations')) {
|
||||||
|
console.log('Server Observations: error fetching data:', error.message);
|
||||||
|
}
|
||||||
|
this.setStatus(STATUS.noData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async drawCanvas() {
|
||||||
|
super.drawCanvas();
|
||||||
|
|
||||||
|
// Get the output container
|
||||||
|
const outputElem = this.elem.querySelector('.server-output');
|
||||||
|
const container = this.elem.querySelector('.container');
|
||||||
|
|
||||||
|
// Split the fastfetch output into lines
|
||||||
|
const lines = this.data.split('\n');
|
||||||
|
|
||||||
|
// Filter to show only key system info lines (contain "==")
|
||||||
|
const infoLines = lines.filter((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
// Only keep lines that have the "Key == Value" format
|
||||||
|
return trimmed && trimmed.includes(' == ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 0; i < infoLines.length; i += LINES_PER_PAGE) {
|
||||||
|
pages.push(infoLines.slice(i, i + LINES_PER_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
outputElem.innerHTML = '';
|
||||||
|
pages.forEach((pageLines) => {
|
||||||
|
const pageElem = document.createElement('div');
|
||||||
|
pageElem.className = 'server-page';
|
||||||
|
|
||||||
|
pageLines.forEach((line) => {
|
||||||
|
const lineDiv = document.createElement('div');
|
||||||
|
lineDiv.className = 'server-line';
|
||||||
|
lineDiv.textContent = line.trim().replace(' == ', ': ');
|
||||||
|
pageElem.appendChild(lineDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
outputElem.appendChild(pageElem);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pageHeight = container.offsetHeight;
|
||||||
|
this.timing.totalScreens = Math.max(1, pages.length);
|
||||||
|
this.timing.delay = new Array(this.timing.totalScreens).fill(1);
|
||||||
|
this.calcNavTiming();
|
||||||
|
|
||||||
|
const top = -this.screenIndex * this.pageHeight;
|
||||||
|
outputElem.style.top = `${top}px`;
|
||||||
|
|
||||||
|
this.finishDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register display with navId 13 (after other displays)
|
||||||
|
registerDisplay(new ServerObservations(13, 'server-observations'));
|
||||||
48
server/styles/scss/_server-observations.scss
Normal file
48
server/styles/scss/_server-observations.scss
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
@use 'shared/_colors'as c;
|
||||||
|
@use 'shared/_utils'as u;
|
||||||
|
|
||||||
|
#server-observations-html.weather-display {
|
||||||
|
.header .title.single {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-display .server-observations {
|
||||||
|
// Override the default has-scroll height to fit content properly
|
||||||
|
&.main {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
top: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-output {
|
||||||
|
position: relative;
|
||||||
|
font-family: 'Star4000';
|
||||||
|
font-size: 20pt;
|
||||||
|
line-height: 32px;
|
||||||
|
color: #fff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
@include u.text-shadow();
|
||||||
|
|
||||||
|
.server-page {
|
||||||
|
height: 250px;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-line {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,4 +14,5 @@
|
||||||
@use 'hazards';
|
@use 'hazards';
|
||||||
@use 'media';
|
@use 'media';
|
||||||
@use 'spc-outlook';
|
@use 'spc-outlook';
|
||||||
|
@use 'server-observations';
|
||||||
@use 'shared/scanlines';
|
@use 'shared/scanlines';
|
||||||
3
server/styles/ws.min.css
vendored
3
server/styles/ws.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -63,6 +63,7 @@
|
||||||
<script type="module" src="scripts/modules/settings.mjs"></script>
|
<script type="module" src="scripts/modules/settings.mjs"></script>
|
||||||
<script type="module" src="scripts/modules/media.mjs"></script>
|
<script type="module" src="scripts/modules/media.mjs"></script>
|
||||||
<script type="module" src="scripts/modules/custom-scroll-text.mjs"></script>
|
<script type="module" src="scripts/modules/custom-scroll-text.mjs"></script>
|
||||||
|
<script type="module" src="scripts/modules/serverobservations.mjs"></script>
|
||||||
<script type="module" src="scripts/index.mjs"></script>
|
<script type="module" src="scripts/index.mjs"></script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|
@ -133,6 +134,9 @@
|
||||||
<div id="hazards-html" class="weather-display">
|
<div id="hazards-html" class="weather-display">
|
||||||
<%- include('partials/hazards.ejs') %>
|
<%- include('partials/hazards.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="server-observations-html" class="weather-display">
|
||||||
|
<%- include('partials/server-observations.ejs') %>
|
||||||
|
</div>
|
||||||
<%- include('partials/scroll.ejs') %>
|
<%- include('partials/scroll.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
6
views/partials/server-observations.ejs
Normal file
6
views/partials/server-observations.ejs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<%- include('header.ejs', {title: 'Server Observations', hasTime: true}) %>
|
||||||
|
<div class="main has-scroll server-observations has-box">
|
||||||
|
<div class="container">
|
||||||
|
<div class="server-output"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue