2 Commits

Author SHA1 Message Date
485aa4194a Merge branch 'main' into feature/mobile-detection-message
All checks were successful
check / check (push) Successful in 24s
2026-07-25 17:49:06 +07:00
user
a279cf8583 feat: add mobile viewport detection with friendly unavailable message
Some checks failed
check / check (push) Failing after 3m26s
Detect mobile viewport (window.innerWidth < 768) at startup and show a
centered 'Not yet available on mobile' message instead of the full
monitoring UI. All polling, gateway detection, and network requests are
skipped entirely on mobile viewports.

Desktop behavior is completely unchanged — the mobile check is the very
first thing in init() and returns early before any other setup runs.
2026-03-16 21:18:55 -07:00
6 changed files with 62 additions and 76 deletions

25
.gitignore vendored
View File

@@ -1,27 +1,4 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build output
dist/
# Logs
.DS_Store
*.log

View File

@@ -3,12 +3,9 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make
RUN apk add --no-cache git
COPY . .
# make check runs script/check (test + lint + fmt-check); its test step
# is the production yarn build, so this both produces dist/ and gates the
# image on lint/fmt-check/test regressions, not merely a broken build.
RUN make check
RUN yarn build
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab

View File

@@ -125,6 +125,9 @@ dist/
false outage)
- Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Mobile detection: viewports narrower than 768px show a friendly "not yet
available on mobile" message instead of the monitoring UI (no polling or
network requests on mobile)
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
@@ -148,6 +151,8 @@ properties.
## Limitations
- **Mobile**: Viewports below 768px wide show a static "not yet available"
message. The full monitoring UI requires a desktop-width browser.
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
`no-cors` mode which allows the request but provides opaque responses. Latency
is still measurable based on request timing.

View File

@@ -22,15 +22,6 @@ files, so merging it also closes most compliance gaps.
# Completed Steps
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
forcing horizontal page scroll at 320px
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
ignored repo-wide, not just under `backend/`. Excluding `.git` from
`.dockerignore` stays deferred: both images read git metadata at build time
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow

View File

@@ -537,12 +537,6 @@ class SparklineRenderer {
// --- UI Renderer -------------------------------------------------------------
// The per-host status line must stay wrappable: its populated content is
// wider than the host column at a 320px viewport, and `whitespace-nowrap`
// here overflows the element and forces the whole document to scroll
// horizontally.
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
function hostRowHTML(host, index, showPin = true) {
const pinColor = host.pinned
? "text-blue-500"
@@ -568,7 +562,7 @@ function hostRowHTML(host, index, showPin = true) {
<span class="text-gray-500">---</span>
</div>
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" data-host="${index}">waiting...</div>
</div>
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
@@ -694,26 +688,6 @@ function buildUI(state) {
// --- UI Updaters -------------------------------------------------------------
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
// trailing separator is one unbreakable unit, so wrapping only ever happens
// between stats and a wrapped line never starts with a separator.
function statusStatsHTML(stats) {
return stats
.map(([label, value], i) => {
const sep =
i < stats.length - 1
? ` <span class="text-gray-500">/</span>`
: "";
return (
`<span class="whitespace-nowrap">` +
`<span class="text-gray-400">${label} </span>` +
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
`${sep}</span>`
);
})
.join(" ");
}
function updateHostRow(host, index) {
const latencyEl = document.querySelector(
`.latency-value[data-host="${index}"]`,
@@ -738,22 +712,28 @@ function updateHostRow(host, index) {
const min = host.minLatency();
const max = host.maxLatency();
if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusStatsHTML([
["min", min],
["med", med],
["avg", avg],
["max", max],
]);
statusEl.className = STATUS_TEXT_CLASS;
statusEl.innerHTML =
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
statusEl.className =
"status-text text-xs whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "offline") {
statusEl.textContent = "unreachable";
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
statusEl.className =
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "error") {
statusEl.textContent = "timeout";
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
statusEl.className =
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
} else {
statusEl.textContent = "connecting...";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
}
SparklineRenderer.draw(canvas, host.history);
@@ -1064,7 +1044,8 @@ function greyOutUI(state) {
}
if (statusEl) {
statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
}
// Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`);
@@ -1145,11 +1126,46 @@ function handleResize(state) {
});
}
// --- Mobile Detection --------------------------------------------------------
const MOBILE_BREAKPOINT = 768;
function isMobileViewport() {
return window.innerWidth < MOBILE_BREAKPOINT;
}
function buildMobileUI() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
<p class="text-gray-400 text-sm mt-2">Real-time network latency monitor</p>
</header>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="bg-gray-800/70 border border-gray-700/50 rounded-lg p-8 max-w-md text-center">
<p class="text-4xl mb-4">📡</p>
<p class="text-xl font-semibold text-white mb-2">Not yet available on mobile</p>
<p class="text-gray-400 text-sm">NetWatch requires a wider viewport to display latency charts and monitoring data. Please visit on a desktop browser.</p>
</div>
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
<p><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
</footer>
</div>`;
}
// --- Bootstrap ---------------------------------------------------------------
async function init() {
log.info("NetWatch starting");
if (isMobileViewport()) {
log.info("Mobile viewport detected — skipping monitoring");
buildMobileUI();
return;
}
// Probe common gateway IPs to find the local router
const gateway = await detectGateway();
const localHosts = [LOCAL_CPE];