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(rename({ suffix: '.min' }))
|
||||
.pipe(sourceMaps.write('./'))
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
.pipe(dest(RESOURCES_PATH))
|
||||
.pipe(dest('./server/styles'));
|
||||
|
||||
const htmlSources = [
|
||||
'views/*.ejs',
|
||||
|
|
|
|||
26
index.mjs
26
index.mjs
|
|
@ -2,6 +2,8 @@ import 'dotenv/config';
|
|||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
|
||||
} from './proxy/handlers.mjs';
|
||||
|
|
@ -10,6 +12,8 @@ import OVERRIDES from './src/overrides.mjs';
|
|||
import cache from './proxy/cache.mjs';
|
||||
import devTools from './src/com.chrome.devtools.mjs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
||||
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.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)
|
||||
// Skip setting up routes for the caching proxy server in static mode
|
||||
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);
|
||||
|
||||
// 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
|
||||
displays.forEach((display) => display.getData(weatherParameters));
|
||||
displays.forEach((display) => display?.getData(weatherParameters));
|
||||
} catch (error) {
|
||||
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
|
||||
// requester for the product after the cache expires
|
||||
const countLoadedDisplays = () => displays.reduce((acc, display) => {
|
||||
if (display.status !== STATUS.loading) return acc + 1;
|
||||
if (display?.status !== STATUS.loading) return acc + 1;
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
const hideAllCanvases = () => {
|
||||
displays.forEach((display) => display.hideCanvas());
|
||||
displays.forEach((display) => display?.hideCanvas());
|
||||
};
|
||||
|
||||
// is playing interface
|
||||
|
|
@ -250,7 +250,7 @@ const loadDisplay = (direction) => {
|
|||
for (let i = 0; i < totalDisplays; i += 1) {
|
||||
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
||||
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
|
||||
if (idx !== curIdx) {
|
||||
foundSuitableDisplay = true;
|
||||
|
|
@ -272,6 +272,10 @@ const loadDisplay = (direction) => {
|
|||
}
|
||||
|
||||
const newDisplay = displays[idx];
|
||||
if (!newDisplay) {
|
||||
console.warn('Selected display is undefined, aborting navigation');
|
||||
return;
|
||||
}
|
||||
// hide all displays
|
||||
hideAllCanvases();
|
||||
// show the new display and navigate to an appropriate display
|
||||
|
|
@ -280,7 +284,7 @@ const loadDisplay = (direction) => {
|
|||
};
|
||||
|
||||
// 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 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
|
||||
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
|
||||
|
|
@ -761,7 +765,7 @@ const generateCheckboxes = () => {
|
|||
|
||||
if (!availableDisplays) return;
|
||||
// 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
|
||||
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 'media';
|
||||
@use 'spc-outlook';
|
||||
@use 'server-observations';
|
||||
@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/media.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>
|
||||
<% } %>
|
||||
|
||||
|
|
@ -133,6 +134,9 @@
|
|||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</div>
|
||||
<div id="server-observations-html" class="weather-display">
|
||||
<%- include('partials/server-observations.ejs') %>
|
||||
</div>
|
||||
<%- include('partials/scroll.ejs') %>
|
||||
</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