5 Commits

Author SHA1 Message Date
818accc454 Bring repo up to REPO_POLICIES.md standards
- Add prettier (4-space indents) and reformat all files
- Add Makefile with test/lint/fmt/fmt-check/check/docker targets
- Add MIT LICENSE file
- Add REPO_POLICIES.md
- Fix Dockerfile: listen on 8080 with PORT env var via envsubst
- Restructure README.md with all required sections
- Set up pre-commit hook (make check)
- Update .prettierignore, .gitignore, .dockerignore
2026-02-22 15:59:10 +01:00
ca403e68d1 Pin Dockerfile base images to SHA256 digests
Mutable tags on Docker Hub enable RCE during builds.
Pin to exact digests with version/date comments for auditability.
2026-02-22 14:55:55 +01:00
e106dd5332 Update README with Docker, health indicator, and WAN/local docs 2026-02-22 14:51:55 +01:00
fa3d50393e Add Dockerfile with nginx for static serving
- Multi-stage build: node:22-alpine builds, nginx:stable-alpine serves
- RFC1918 set_real_ip_from for 10/8, 172.16/12, 192.168/16
- X-Forwarded-For real_ip_header with recursive resolution
- Access log to stdout, error log to stderr (Docker best practice)
- Immutable cache headers for hashed static assets
- SPA fallback via try_files
2026-02-22 14:51:33 +01:00
651c86211d Refactor to classes and implement UI improvements
Architecture:
- Extract AppState and HostState classes (no global mutable state)
- Extract SparklineRenderer class with static methods
- Extract CONFIG object for all constants
- Break monolithic functions into focused helpers

Features:
- Clickable service URLs (open in new tab, existing styling)
- Health status box above summary (red DEGRADED if >half unreachable)
- Local Gateway separated into bottom group
- Local Gateway excluded from WAN min/max/avg summary stats
- Pause stops probes but history keeps scrolling (blank gaps, no false outage)
- WAN_HOSTS / LOCAL_HOSTS separation with indexed rendering
2026-02-22 14:51:06 +01:00
14 changed files with 812 additions and 519 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.DS_Store
*.log
.claude

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
yarn.lock
.claude/

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"tabWidth": 4
}

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
# Config template — envsubst replaces $PORT at container start
COPY <<'EOF' /etc/nginx/netwatch.conf.template
server {
listen $PORT;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Trust RFC1918 reverse proxies for X-Forwarded-For
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Access log to stdout (Docker best practice)
access_log /dev/stdout combined;
error_log /dev/stderr warn;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
EOF
COPY --from=build /app/dist /usr/share/nginx/html
ENV PORT=8080
EXPOSE 8080
CMD ["/bin/sh", "-c", "envsubst '$PORT' < /etc/nginx/netwatch.conf.template > /etc/nginx/conf.d/netwatch.conf && exec nginx -g 'daemon off;'"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 @sneak (https://sneak.berlin)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
.PHONY: test lint fmt fmt-check check docker
test:
timeout 30 yarn build
lint:
yarn prettier --check .
fmt:
yarn prettier --write .
fmt-check:
yarn prettier --check .
check: test lint fmt-check
docker:
timeout 300 docker build -t netwatch .

119
README.md
View File

@@ -1,32 +1,6 @@
# NetWatch
NetWatch is an MIT-licensed JavaScript single-page application by [@sneak](https://sneak.berlin) that provides real-time network latency monitoring to common internet hosts, displayed with color-coded figures and sparkline graphs, served from a static bucket or Docker container.
Real-time network latency monitor SPA designed to be served from a static bucket.
## Features
- **Real-time monitoring**: Updates every 250ms
- **10 monitored hosts**: Google Cloud, AWS, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, local gateway (192.168.100.1), and datavi.be
- **Visual latency display**: Large color-coded latency figures (NNNms format)
- **Sparkline graphs**: 300-second history visualization for each host
- **Color coding**:
- Green: <50ms (excellent)
- Lime: <100ms (good)
- Yellow: <200ms (moderate)
- Orange: <500ms (poor)
- Red: >500ms (bad)
- Gray: offline/unreachable
- **Zero runtime dependencies**: All resources bundled into build artifacts
- **IPv4 only**: Designed for IPv4 connectivity testing
## Technical Details
- Latency measured via HEAD requests with cache-busting
- Uses `mode: 'no-cors'` to allow cross-origin requests where CORS headers aren't present
- 5-second timeout for unresponsive hosts
- Canvas-based sparkline rendering
- Tailwind CSS v4 for styling
## Build
## Getting Started
```bash
# Install dependencies
@@ -40,20 +14,48 @@ yarn build
# Preview production build
yarn preview
# Docker
docker build -t netwatch .
docker run -p 8080:8080 netwatch
```
## Deployment
## Rationale
After running `yarn build`, deploy the contents of the `dist/` directory to any static file host:
When debugging network issues, it's useful to have a persistent at-a-glance view of latency and reachability to multiple well-known internet endpoints. NetWatch provides this as a zero-dependency SPA that can be deployed anywhere static files are served, with no backend required.
- AWS S3
- Google Cloud Storage
- Cloudflare Pages
- Vercel
- Netlify
- GitHub Pages
## Design
## Output Structure
The application is a single-page app built with Vite and Tailwind CSS v4. All code lives in `src/main.js` with a class-based architecture:
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis ticks, etc.)
- **`HostState`**: Per-host state management — history buffer, latency tracking, status transitions
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause state, aggregate stats
- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes, color-coded line segments, error regions, and DPR-aware scaling
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / `updateSummary()` / `updateHealthBox()` handle incremental updates
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, pushes samples, redraws UI. When paused, pushes blank markers (no probes, no false outage)
### Monitoring targets
- **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be
- **1 Local host**: Local Gateway (192.168.100.1), tracked separately from WAN stats
### Latency measurement
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with `performance.now()`. 1-second timeout; anything over 1000ms is clamped to unreachable. IPv4 only.
### Color coding
| Latency | Color |
| ----------- | ------ |
| < 50ms | Green |
| < 100ms | Lime |
| < 200ms | Yellow |
| < 500ms | Orange |
| >= 500ms | Red |
| Unreachable | Gray |
### Output structure
```
dist/
@@ -63,15 +65,32 @@ dist/
└── index-*.js
```
All assets are bundled and minified. No external dependencies at runtime.
## Features
- Real-time monitoring with 2s update interval and 300s history sparklines
- Health indicator: green (HEALTHY) or red (DEGRADED) based on WAN reachability
- Summary stats: reachable count, min/max/avg latency across WAN hosts only
- Fixed chart axes: Y-axis 01000ms, X-axis 0300s
- Color-coded latency figures and sparkline line segments
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no false outage)
- Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
After running `yarn build`, deploy the contents of the `dist/` directory to any static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or use the Docker image behind a reverse proxy.
The Docker image:
- Listens on port 8080 by default (override with `PORT` env var)
- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16)
- Sends access logs to stdout
- Caches static assets with immutable headers
## Browser Compatibility
Requires modern browser with:
- ES modules support
- Fetch API
- Canvas API
- CSS custom properties
Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties.
## Limitations
@@ -79,6 +98,18 @@ Requires modern browser with:
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network.
- **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing.
## TODO
- Add unit tests
- Add eslint for JS linting (currently lint target runs prettier only)
- Add configurable host list (environment variable or config file)
- Add latency history export (CSV/JSON)
- Add notification/alert when status changes to DEGRADED
## License
MIT
MIT. See [LICENSE](LICENSE).
## Author
[@sneak](https://sneak.berlin)

110
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,110 @@
# Development Policies
- Docker image references by tag are server-mutable, therefore using them is
an RCE vulnerability. All docker image references must use cryptographic
hashes to securely specify the exact image that is expected.
- Correspondingly, `go install` commands using things like '@latest' are
also dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go
install targets using commit hashes which are cryptographically secure.
- Every repo with software in it must have a Makefile in the root. Each
such Makefile should support `make test` (runs the project-specific
tests), `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and
`make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make
docker` (builds docker image).
- Every repo should have a Dockerfile. If the repo contains non-server
software, the Dockerfile should bring up a development environment and
`make check` (i.e. the docker build should fail if the branch is not
green).
- Platform-specific standard formatting should be used. `black` for python,
`prettier` for js/css/etc, `go fmt` for go. The only changes to default
settings should be to specify four-space indents where applicable (i.e.
everything except `go fmt`).
- If local testing is possible (it is not always), `make check` should be a
pre-commit hook. If it is not possible, `make lint && make fmt-check`
should be a pre-commit hook.
- If a working `make test` takes more than 20 seconds, that's a bug that
needs fixing. In fact, there should be a timeout specified in the
`Makefile` that fails it automatically if it takes >30s.
- Docker builds should time out in 5 minutes or less.
- `main` must always pass `make check`, no exceptions.
- Do all changes on a feature branch. You can do whatever you want on a
feature branch.
- We have a standardized `.golangci.yml` which we reuse and is _NEVER_ to be
modified by an agent, only manually by the user. It can be copied from
`~/dev/upaas/.golangci.yml` if it exists at that location.
- When specifying images or packages by hash in Dockerfiles or
`docker-compose.yml`, put a comment above the line and show the version
and date at which it was current.
- For javascript, always use `yarn` over `npm`.
- Whenever writing dates, ALWAYS write YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables, as is
standard for Dockerized applications.
- Dockerized web services should listen on the default HTTP port of 8080
unless overridden with the `PORT` environment variable.
- The `README.md` is a project's primary documentation. It should contain
at a minimum the following sections:
- Description
- Include a short and complete description of the functionality and
purpose of the software as the first line in the readme. It must
include:
- the name
- the purpose
- the category (web server, SPA, command line tool, etc)
- the license
- the author
- eg: "µPaaS is an MIT-licensed Go web application by @sneak
that receives git-frontend webhooks and interacts with a
Docker server to build and deploy applications in realtime as
certain branches are updated."
- Getting Started
- a code block with copy-pasteable installation/use sections
- Rationale
- why does this exist?
- Design
- how is the program structured?
- TODO
- This is your TODO list for the project - update it meticulously,
even in between commits. Whenever planning, put your todo list in
the README so that a separate agent with new context can pick up
where you left off.
- License
- GPL or MIT or WTFPL - ask the user when beginning a new project
and include a LICENSE file in the root and in a section in the
README.
- Author
- @sneak (link `@sneak` to `https://sneak.berlin`).
- When beginning a new project, initialize a git repo and make the first
commit simply the first version of the README.md in the root of the repo.
- For Go packages, the module root is `sneak.berlin/go/...`, such
as `sneak.berlin/go/dnswatcher`.
- We use SemVer always.
- If no tag `1.0.0` or greater exists in the repository, modify the existing
migrations and assume no installed base or existing databases. If
`>=1.0.0`, database changes add new migration files.
- New repos must have at a minimum the following files:
- `README.md`, `.git`, `.gitignore`
- `POLICIES.md` (copy from `~/Documents/_PROMPTS/POLICIES.md`)
- `Dockerfile`, `.dockerignore`
- for go: `go.mod`, `go.sum`, `.golangci.yml`
- for js: `package.json`

View File

@@ -1,10 +1,14 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetWatch - Network Latency Monitor</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>" />
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>"
/>
</head>
<body class="bg-gray-900 text-white min-h-screen">
<div id="app"></div>

View File

@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}

View File

@@ -1,258 +1,347 @@
import './styles.css'
import "./styles.css";
// Configuration
const UPDATE_INTERVAL = 2000 // ms
const HISTORY_DURATION = 300 // seconds
const MAX_HISTORY_POINTS = Math.ceil((HISTORY_DURATION * 1000) / UPDATE_INTERVAL) // 150 points
const REQUEST_TIMEOUT = 1000 // ms
// --- Configuration -----------------------------------------------------------
// Pause state
let isPaused = false
let intervalId = null
const CONFIG = Object.freeze({
updateInterval: 2000,
historyDuration: 300,
requestTimeout: 1000,
maxLatency: 1000,
yAxisTicks: [0, 250, 500, 750, 1000],
xAxisTicks: [0, 60, 120, 180, 240, 300],
canvasHeight: 80,
get maxHistoryPoints() {
return Math.ceil((this.historyDuration * 1000) / this.updateInterval);
},
});
// Hosts to monitor (IPv4 preferred endpoints)
const HOSTS = [
{ name: 'Google Cloud Console', url: 'https://console.cloud.google.com', color: '#4285f4' },
{ name: 'AWS Console', url: 'https://console.aws.amazon.com', color: '#ff9900' },
{ name: 'GitHub', url: 'https://github.com', color: '#f0f6fc' },
{ name: 'Cloudflare', url: 'https://www.cloudflare.com', color: '#f38020' },
{ name: 'Microsoft Azure', url: 'https://portal.azure.com', color: '#0078d4' },
{ name: 'DigitalOcean', url: 'https://www.digitalocean.com', color: '#0080ff' },
{ name: 'Fastly CDN', url: 'https://www.fastly.com', color: '#ff282d' },
{ name: 'Akamai', url: 'https://www.akamai.com', color: '#0096d6' },
{ name: 'Local Gateway', url: 'http://192.168.100.1', color: '#a855f7' },
{ name: 'datavi.be', url: 'https://datavi.be', color: '#10b981' },
]
const WAN_HOSTS = [
{ name: "Google Cloud Console", url: "https://console.cloud.google.com" },
{ name: "AWS Console", url: "https://console.aws.amazon.com" },
{ name: "GitHub", url: "https://github.com" },
{ name: "Cloudflare", url: "https://www.cloudflare.com" },
{ name: "Microsoft Azure", url: "https://portal.azure.com" },
{ name: "DigitalOcean", url: "https://www.digitalocean.com" },
{ name: "Fastly CDN", url: "https://www.fastly.com" },
{ name: "Akamai", url: "https://www.akamai.com" },
{ name: "datavi.be", url: "https://datavi.be" },
];
// State: history for each host
const hostState = HOSTS.map(host => ({
...host,
history: [], // Array of { timestamp, latency } or { timestamp, error }
lastLatency: null,
status: 'pending', // 'online', 'offline', 'pending', 'error'
}))
const LOCAL_HOSTS = [{ name: "Local Gateway", url: "http://192.168.100.1" }];
// --- App State ---------------------------------------------------------------
class HostState {
constructor(host) {
this.name = host.name;
this.url = host.url;
this.history = []; // { timestamp, latency, paused }
this.lastLatency = null;
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
}
pushSample(timestamp, result) {
this.history.push({
timestamp,
latency: result.latency,
error: result.error,
});
this._trim();
this.lastLatency = result.latency;
if (result.error === "timeout") this.status = "error";
else if (result.error) this.status = "offline";
else this.status = "online";
}
pushPaused(timestamp) {
this.history.push({ timestamp, latency: null, paused: true });
this._trim();
}
averageLatency() {
const valid = this.history.filter((p) => p.latency !== null);
if (valid.length === 0) return null;
return Math.round(
valid.reduce((s, p) => s + p.latency, 0) / valid.length,
);
}
_trim() {
while (this.history.length > CONFIG.maxHistoryPoints)
this.history.shift();
}
}
class AppState {
constructor() {
this.wan = WAN_HOSTS.map((h) => new HostState(h));
this.local = LOCAL_HOSTS.map((h) => new HostState(h));
this.paused = false;
}
get allHosts() {
return [...this.wan, ...this.local];
}
/** WAN-only stats from latest sample (excludes local) */
wanStats() {
const reachable = this.wan.filter((h) => h.lastLatency !== null);
const latencies = reachable.map((h) => h.lastLatency);
const total = this.wan.length;
if (latencies.length === 0)
return { reachable: 0, total, min: null, max: null, avg: null };
return {
reachable: latencies.length,
total,
min: Math.min(...latencies),
max: Math.max(...latencies),
avg: Math.round(
latencies.reduce((a, b) => a + b, 0) / latencies.length,
),
};
}
/** Overall health: true = healthy (more than half WAN reachable) */
isHealthy() {
const reachable = this.wan.filter((h) => h.lastLatency !== null).length;
return reachable > this.wan.length / 2;
}
}
// --- Latency Measurement -----------------------------------------------------
// Measure latency using HEAD request
async function measureLatency(url) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.requestTimeout,
);
// Add cache-busting parameter
const targetUrl = new URL(url)
targetUrl.searchParams.set('_cb', Date.now().toString())
const targetUrl = new URL(url);
targetUrl.searchParams.set("_cb", Date.now().toString());
const start = performance.now()
const start = performance.now();
try {
await fetch(targetUrl.toString(), {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store',
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
})
const latency = Math.round(performance.now() - start)
clearTimeout(timeoutId)
// Clamp >1000ms to unreachable
if (latency > 1000) {
return { latency: null, error: 'timeout' }
}
return { latency, error: null }
});
const latency = Math.round(performance.now() - start);
clearTimeout(timeoutId);
if (latency > CONFIG.maxLatency)
return { latency: null, error: "timeout" };
return { latency, error: null };
} catch (err) {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
return { latency: null, error: 'timeout' }
}
return { latency: null, error: 'unreachable' }
clearTimeout(timeoutId);
if (err.name === "AbortError")
return { latency: null, error: "timeout" };
return { latency: null, error: "unreachable" };
}
}
// Get latency color (raw hex for canvas)
function getLatencyColor(latency) {
if (latency === null) return '#6b7280' // gray
if (latency < 50) return '#22c55e' // green
if (latency < 100) return '#84cc16' // lime
if (latency < 200) return '#eab308' // yellow
if (latency < 500) return '#f97316' // orange
return '#ef4444' // red
// --- Color Helpers -----------------------------------------------------------
function latencyHex(latency) {
if (latency === null) return "#6b7280";
if (latency < 50) return "#22c55e";
if (latency < 100) return "#84cc16";
if (latency < 200) return "#eab308";
if (latency < 500) return "#f97316";
return "#ef4444";
}
// Get Tailwind class for latency
function getLatencyClass(latency, status) {
if (status === 'offline' || status === 'error' || latency === null) {
return 'text-gray-500'
}
if (latency < 50) return 'text-green-500'
if (latency < 100) return 'text-lime-500'
if (latency < 200) return 'text-yellow-500'
if (latency < 500) return 'text-orange-500'
return 'text-red-500'
function latencyClass(latency, status) {
if (status === "offline" || status === "error" || latency === null)
return "text-gray-500";
if (latency < 50) return "text-green-500";
if (latency < 100) return "text-lime-500";
if (latency < 200) return "text-yellow-500";
if (latency < 500) return "text-orange-500";
return "text-red-500";
}
// Fixed axis constants
const Y_AXIS_MAX = 1000 // ms
const Y_AXIS_TICKS = [0, 250, 500, 750, 1000]
const X_AXIS_TICKS = [0, 60, 120, 180, 240, 300] // seconds ago
// --- Sparkline Renderer ------------------------------------------------------
// Draw sparkline on canvas - latest point at right edge, growing left
function drawSparkline(canvas, history) {
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
const width = canvas.width / dpr
const height = canvas.height / dpr
class SparklineRenderer {
static MARGIN = { left: 35, right: 10, top: 5, bottom: 18 };
// Margins for axis labels
const marginLeft = 35
const marginRight = 10
const marginTop = 5
const marginBottom = 18
static draw(canvas, history) {
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const m = SparklineRenderer.MARGIN;
const cw = w - m.left - m.right;
const ch = h - m.top - m.bottom;
const chartWidth = width - marginLeft - marginRight
const chartHeight = height - marginTop - marginBottom
ctx.clearRect(0, 0, w, h);
SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
// Clear canvas
ctx.clearRect(0, 0, width, height)
const len = history.length;
const pw = cw / (CONFIG.maxHistoryPoints - 1);
const getX = (i) => m.left + cw - (len - 1 - i) * pw;
const getY = (lat) => m.top + ch - (lat / CONFIG.maxLatency) * ch;
// Draw Y-axis labels and grid lines
ctx.font = '9px monospace'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
Y_AXIS_TICKS.forEach(tick => {
const y = marginTop + chartHeight - (tick / Y_AXIS_MAX) * chartHeight
// Grid line
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(marginLeft, y)
ctx.lineTo(width - marginRight, y)
ctx.stroke()
// Label
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText(`${tick}`, marginLeft - 4, y)
})
// Draw X-axis labels
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
X_AXIS_TICKS.forEach(tick => {
// tick is seconds ago, 0 = now (right edge), 300 = 5min ago (left edge)
const x = marginLeft + chartWidth - (tick / HISTORY_DURATION) * chartWidth
// Label
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText(`-${tick}s`, x, height - marginBottom + 4)
})
// Helper functions for chart coordinates
const historyLen = history.length
const pointWidth = chartWidth / (MAX_HISTORY_POINTS - 1)
const getX = (i) => marginLeft + chartWidth - (historyLen - 1 - i) * pointWidth
const getY = (latency) => marginTop + chartHeight - (latency / Y_AXIS_MAX) * chartHeight
// Draw error regions first (behind the line)
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'
let inErrorRegion = false
let errorStart = 0
history.forEach((point, i) => {
const x = getX(i)
if (point.latency === null && !inErrorRegion) {
inErrorRegion = true
errorStart = x
} else if (point.latency !== null && inErrorRegion) {
inErrorRegion = false
ctx.fillRect(errorStart, marginTop, x - errorStart, chartHeight)
}
})
if (inErrorRegion) {
const lastX = getX(historyLen - 1)
ctx.fillRect(errorStart, marginTop, lastX - errorStart + 5, chartHeight)
SparklineRenderer._drawErrors(ctx, history, getX, m.top, ch);
SparklineRenderer._drawLine(ctx, history, getX, getY);
SparklineRenderer._drawTip(ctx, history, getX, getY);
}
// Draw the sparkline segments - color coded by latency
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
let prevPoint = null
let prevX = 0
let prevY = 0
history.forEach((point, i) => {
if (point.latency === null) {
prevPoint = null
return
static _drawYAxis(ctx, w, h, m, ch) {
ctx.font = "9px monospace";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (const tick of CONFIG.yAxisTicks) {
const y = m.top + ch - (tick / CONFIG.maxLatency) * ch;
ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(m.left, y);
ctx.lineTo(w - m.right, y);
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillText(`${tick}`, m.left - 4, y);
}
}
const x = getX(i)
const y = getY(point.latency)
if (prevPoint !== null) {
ctx.strokeStyle = getLatencyColor(point.latency)
ctx.beginPath()
ctx.moveTo(prevX, prevY)
ctx.lineTo(x, y)
ctx.stroke()
static _drawXAxis(ctx, w, h, m, cw) {
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (const tick of CONFIG.xAxisTicks) {
const x = m.left + cw - (tick / CONFIG.historyDuration) * cw;
ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillText(`-${tick}s`, x, h - m.bottom + 4);
}
}
prevPoint = point
prevX = x
prevY = y
})
static _drawErrors(ctx, history, getX, top, ch) {
ctx.fillStyle = "rgba(239, 68, 68, 0.2)";
let inErr = false,
start = 0;
for (let i = 0; i < history.length; i++) {
const p = history[i];
const x = getX(i);
// Only real errors, not paused gaps
const isError = p.latency === null && !p.paused;
if (isError && !inErr) {
inErr = true;
start = x;
} else if (!isError && inErr) {
inErr = false;
ctx.fillRect(start, top, x - start, ch);
}
}
if (inErr)
ctx.fillRect(start, top, getX(history.length - 1) - start + 5, ch);
}
// Draw latest point indicator
const lastValidPoint = [...history].reverse().find(p => p.latency !== null)
if (lastValidPoint) {
const lastIndex = history.lastIndexOf(lastValidPoint)
const x = getX(lastIndex)
const y = getY(lastValidPoint.latency)
static _drawLine(ctx, history, getX, getY) {
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
let prev = null;
for (let i = 0; i < history.length; i++) {
const p = history[i];
if (p.latency === null) {
prev = null;
continue;
}
const x = getX(i),
y = getY(p.latency);
if (prev) {
ctx.strokeStyle = latencyHex(p.latency);
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(x, y);
ctx.stroke();
}
prev = { x, y };
}
}
ctx.fillStyle = getLatencyColor(lastValidPoint.latency)
ctx.beginPath()
ctx.arc(x, y, 3, 0, Math.PI * 2)
ctx.fill()
static _drawTip(ctx, history, getX, getY) {
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].latency !== null) {
const x = getX(i),
y = getY(history[i].latency);
ctx.fillStyle = latencyHex(history[i].latency);
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
return;
}
}
}
static sizeCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = CONFIG.canvasHeight * dpr;
const ctx = canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
}
// Create the UI
function createUI() {
const app = document.getElementById('app')
// --- UI Renderer -------------------------------------------------------------
function hostRowHTML(host, index) {
return `
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
<div class="flex items-center gap-4">
<div class="w-48 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: ${latencyHex(null)}"></div>
<span class="font-medium text-white truncate">${host.name}</span>
</div>
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 mt-1 truncate block">${host.url}</a>
</div>
<div class="w-32 flex-shrink-0 text-right">
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
<span class="text-gray-500">---</span>
</div>
<div class="status-text text-xs text-gray-500 mt-1" 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>
</div>
</div>
</div>`;
}
function buildUI(state) {
const app = document.getElementById("app");
app.innerHTML = `
<div class="max-w-7xl mx-auto px-4 py-8">
<header class="mb-8">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">NetWatch</h1>
<button
id="pause-btn"
class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<button id="pause-btn"
class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>
</svg>
<svg id="play-icon" class="w-8 h-8 text-green-400 hidden" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
<path d="M8 5v14l11-7z"/>
</svg>
<span id="pause-text" class="text-lg font-semibold text-white">Pause</span>
</button>
</div>
<p class="text-gray-400 text-sm">
Real-time network latency monitor |
<span class="text-gray-500">Updates every ${UPDATE_INTERVAL / 1000}s</span> |
<span class="text-gray-500">History: ${HISTORY_DURATION}s</span> |
<span class="text-gray-500">Updates every ${CONFIG.updateInterval / 1000}s</span> |
<span class="text-gray-500">History: ${CONFIG.historyDuration}s</span> |
<span id="status-indicator" class="text-green-400">Running</span>
</p>
<div id="summary" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm">
<div id="health-box" class="mt-4 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm text-center">
<span id="health-text" class="text-gray-400">Waiting for data...</span>
</div>
<div id="summary" class="mt-2 p-3 bg-gray-800/70 rounded-lg border border-gray-700/50 font-mono text-sm">
<span class="text-gray-400">Reachable:</span> <span id="summary-reachable" class="text-white">--/--</span>
<span class="text-gray-600 mx-3">|</span>
<span class="text-gray-400">Min:</span> <span id="summary-min" class="text-green-400">--ms</span>
@@ -263,40 +352,13 @@ function createUI() {
</div>
</header>
<div id="hosts-container" class="space-y-4">
${hostState.map((host, index) => `
<div class="host-row bg-gray-800/50 rounded-lg p-4 border border-gray-700/50" data-index="${index}">
<div class="flex items-center gap-4">
<!-- Host Info -->
<div class="w-48 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: ${host.color}"></div>
<span class="font-medium text-white truncate">${host.name}</span>
</div>
<div class="text-xs text-gray-500 mt-1 truncate">${host.url}</div>
<div id="wan-hosts" class="space-y-4">
${state.wan.map((h, i) => hostRowHTML(h, i)).join("")}
</div>
<!-- Latency Display -->
<div class="w-32 flex-shrink-0 text-right">
<div class="latency-value text-4xl font-bold tabular-nums" data-host="${index}">
<span class="text-gray-500">---</span>
</div>
<div class="status-text text-xs text-gray-500 mt-1" data-host="${index}">
waiting...
</div>
</div>
<!-- Sparkline Graph -->
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas
class="sparkline-canvas w-full"
data-host="${index}"
height="80"
></canvas>
</div>
</div>
</div>
`).join('')}
<h2 class="text-gray-500 text-xs uppercase tracking-wide mt-6 mb-3">Local Network</h2>
<div id="local-hosts" class="space-y-4">
${state.local.map((h, i) => hostRowHTML(h, state.wan.length + i)).join("")}
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
@@ -310,219 +372,192 @@ function createUI() {
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p>
</footer>
</div>
`
</div>`;
// Set up canvas sizes after DOM is ready
requestAnimationFrame(() => {
document.querySelectorAll('.sparkline-canvas').forEach(canvas => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * window.devicePixelRatio
canvas.height = 80 * window.devicePixelRatio
canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio)
})
})
document
.querySelectorAll(".sparkline-canvas")
.forEach((c) => SparklineRenderer.sizeCanvas(c));
});
}
// Update single host display
function updateHostDisplay(index) {
const host = hostState[index]
const latencyEl = document.querySelector(`.latency-value[data-host="${index}"]`)
const statusEl = document.querySelector(`.status-text[data-host="${index}"]`)
const canvas = document.querySelector(`.sparkline-canvas[data-host="${index}"]`)
// --- UI Updaters -------------------------------------------------------------
if (!latencyEl || !statusEl || !canvas) return
function updateHostRow(host, index) {
const latencyEl = document.querySelector(
`.latency-value[data-host="${index}"]`,
);
const statusEl = document.querySelector(
`.status-text[data-host="${index}"]`,
);
const canvas = document.querySelector(
`.sparkline-canvas[data-host="${index}"]`,
);
if (!latencyEl || !statusEl || !canvas) return;
// Update latency value
if (host.lastLatency !== null) {
const colorClass = getLatencyClass(host.lastLatency, host.status)
latencyEl.innerHTML = `<span class="${colorClass}">${host.lastLatency}<span class="text-lg">ms</span></span>`
} else if (host.status === 'offline' || host.status === 'error') {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`
const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
} else if (host.status === "offline" || host.status === "error") {
latencyEl.innerHTML = `<span class="text-gray-500">---</span>`;
}
// Update status text
const avgLatency = calculateAverageLatency(host.history)
if (host.status === 'online' && avgLatency !== null) {
statusEl.textContent = `avg: ${avgLatency}ms`
statusEl.className = 'status-text text-xs text-gray-400 mt-1'
} else if (host.status === 'offline') {
statusEl.textContent = 'unreachable'
statusEl.className = 'status-text text-xs text-red-400 mt-1'
} else if (host.status === 'error') {
statusEl.textContent = 'timeout'
statusEl.className = 'status-text text-xs text-orange-400 mt-1'
const avg = host.averageLatency();
if (host.status === "online" && avg !== null) {
statusEl.textContent = `avg: ${avg}ms`;
statusEl.className = "status-text text-xs text-gray-400 mt-1";
} else if (host.status === "offline") {
statusEl.textContent = "unreachable";
statusEl.className = "status-text text-xs text-red-400 mt-1";
} else if (host.status === "error") {
statusEl.textContent = "timeout";
statusEl.className = "status-text text-xs text-orange-400 mt-1";
} else {
statusEl.textContent = 'connecting...'
statusEl.className = 'status-text text-xs text-gray-500 mt-1'
statusEl.textContent = "connecting...";
statusEl.className = "status-text text-xs text-gray-500 mt-1";
}
// Update sparkline
const canvasRect = canvas.getBoundingClientRect()
drawSparkline(canvas, host.history)
SparklineRenderer.draw(canvas, host.history);
}
// Calculate average latency from history
function calculateAverageLatency(history) {
const validPoints = history.filter(p => p.latency !== null)
if (validPoints.length === 0) return null
function updateSummary(state) {
const stats = state.wanStats();
const sum = validPoints.reduce((acc, p) => acc + p.latency, 0)
return Math.round(sum / validPoints.length)
}
const reachableEl = document.getElementById("summary-reachable");
const minEl = document.getElementById("summary-min");
const maxEl = document.getElementById("summary-max");
const avgEl = document.getElementById("summary-avg");
if (!reachableEl) return;
// Update summary line with current host stats
function updateSummary() {
const reachableHosts = hostState.filter(h => h.lastLatency !== null)
const reachableCount = reachableHosts.length
const totalCount = hostState.length
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
reachableEl.className =
stats.reachable === stats.total
? "text-green-400"
: stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
const reachableEl = document.getElementById('summary-reachable')
const minEl = document.getElementById('summary-min')
const maxEl = document.getElementById('summary-max')
const avgEl = document.getElementById('summary-avg')
if (!reachableEl) return
reachableEl.textContent = `${reachableCount}/${totalCount}`
reachableEl.className = reachableCount === totalCount ? 'text-green-400' :
reachableCount === 0 ? 'text-red-400' : 'text-yellow-400'
if (reachableCount > 0) {
const latencies = reachableHosts.map(h => h.lastLatency)
const min = Math.min(...latencies)
const max = Math.max(...latencies)
const avg = Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length)
minEl.textContent = `${min}ms`
minEl.className = getLatencyClass(min, 'online')
maxEl.textContent = `${max}ms`
maxEl.className = getLatencyClass(max, 'online')
avgEl.textContent = `${avg}ms`
avgEl.className = getLatencyClass(avg, 'online')
if (stats.min !== null) {
minEl.textContent = `${stats.min}ms`;
minEl.className = latencyClass(stats.min, "online");
maxEl.textContent = `${stats.max}ms`;
maxEl.className = latencyClass(stats.max, "online");
avgEl.textContent = `${stats.avg}ms`;
avgEl.className = latencyClass(stats.avg, "online");
} else {
minEl.textContent = '--ms'
minEl.className = 'text-gray-500'
maxEl.textContent = '--ms'
maxEl.className = 'text-gray-500'
avgEl.textContent = '--ms'
avgEl.className = 'text-gray-500'
for (const el of [minEl, maxEl, avgEl]) {
el.textContent = "--ms";
el.className = "text-gray-500";
}
}
}
// Main measurement loop
async function measureAll() {
const timestamp = Date.now()
function updateHealthBox(state) {
const el = document.getElementById("health-text");
const box = document.getElementById("health-box");
if (!el || !box) return;
const anyData = state.wan.some((h) => h.status !== "pending");
if (!anyData) return;
const healthy = state.isHealthy();
el.textContent = healthy ? "HEALTHY" : "DEGRADED";
el.className = healthy
? "text-green-400 font-bold"
: "text-red-400 font-bold";
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${
healthy
? "bg-green-900/20 border-green-700/50"
: "bg-red-900/20 border-red-700/50"
}`;
}
// --- Main Loop ---------------------------------------------------------------
async function tick(state) {
const ts = Date.now();
if (state.paused) {
// No probes — just push a paused marker so the chart keeps scrolling
for (const host of state.allHosts) {
host.pushPaused(ts);
}
// Redraw sparklines only
state.allHosts.forEach((host, i) => {
const canvas = document.querySelector(
`.sparkline-canvas[data-host="${i}"]`,
);
if (canvas) SparklineRenderer.draw(canvas, host.history);
});
return;
}
// Measure all hosts in parallel
const results = await Promise.all(
hostState.map(host => measureLatency(host.url))
)
state.allHosts.map((h) => measureLatency(h.url)),
);
// Update state
results.forEach((result, index) => {
const host = hostState[index]
state.allHosts.forEach((host, i) => {
host.pushSample(ts, results[i]);
updateHostRow(host, i);
});
// Add to history
host.history.push({
timestamp,
latency: result.latency,
error: result.error,
})
updateSummary(state);
updateHealthBox(state);
}
// Trim history to max size
while (host.history.length > MAX_HISTORY_POINTS) {
host.history.shift()
}
// --- Pause / Resume ----------------------------------------------------------
// Update current state
host.lastLatency = result.latency
if (result.error === 'timeout') {
host.status = 'error'
} else if (result.error) {
host.status = 'offline'
function togglePause(state) {
state.paused = !state.paused;
const pauseIcon = document.getElementById("pause-icon");
const playIcon = document.getElementById("play-icon");
const pauseText = document.getElementById("pause-text");
const indicator = document.getElementById("status-indicator");
if (state.paused) {
pauseIcon.classList.add("hidden");
playIcon.classList.remove("hidden");
pauseText.textContent = "Resume";
indicator.textContent = "Paused";
indicator.className = "text-yellow-400";
} else {
host.status = 'online'
}
// Update display
updateHostDisplay(index)
})
// Update summary
updateSummary()
}
// Handle window resize
function handleResize() {
document.querySelectorAll('.sparkline-canvas').forEach((canvas, index) => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * window.devicePixelRatio
canvas.height = 80 * window.devicePixelRatio
const ctx = canvas.getContext('2d')
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
// Redraw
if (hostState[index]) {
drawSparkline(canvas, hostState[index].history)
}
})
}
// Toggle pause state
function togglePause() {
isPaused = !isPaused
const pauseIcon = document.getElementById('pause-icon')
const playIcon = document.getElementById('play-icon')
const pauseText = document.getElementById('pause-text')
const statusIndicator = document.getElementById('status-indicator')
if (isPaused) {
// Stop the interval
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
pauseIcon.classList.add('hidden')
playIcon.classList.remove('hidden')
pauseText.textContent = 'Resume'
statusIndicator.textContent = 'Paused'
statusIndicator.className = 'text-yellow-400'
} else {
// Resume the interval
measureAll()
intervalId = setInterval(measureAll, UPDATE_INTERVAL)
pauseIcon.classList.remove('hidden')
playIcon.classList.add('hidden')
pauseText.textContent = 'Pause'
statusIndicator.textContent = 'Running'
statusIndicator.className = 'text-green-400'
pauseIcon.classList.remove("hidden");
playIcon.classList.add("hidden");
pauseText.textContent = "Pause";
indicator.textContent = "Running";
indicator.className = "text-green-400";
}
}
// Initialize
// --- Resize ------------------------------------------------------------------
function handleResize(state) {
document.querySelectorAll(".sparkline-canvas").forEach((canvas, i) => {
SparklineRenderer.sizeCanvas(canvas);
const host = state.allHosts[i];
if (host) SparklineRenderer.draw(canvas, host.history);
});
}
// --- Bootstrap ---------------------------------------------------------------
function init() {
createUI()
const state = new AppState();
buildUI(state);
// Wire up pause button
document.getElementById('pause-btn').addEventListener('click', togglePause)
document
.getElementById("pause-btn")
.addEventListener("click", () => togglePause(state));
// Start measurement loop
measureAll()
intervalId = setInterval(measureAll, UPDATE_INTERVAL)
tick(state);
setInterval(() => tick(state), CONFIG.updateInterval);
// Handle resize
window.addEventListener('resize', handleResize)
// Initial resize setup after a short delay
setTimeout(handleResize, 100)
window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), 100);
}
// Run when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init()
init();
}

View File

@@ -10,9 +10,14 @@
}
body {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.sparkline-container {
background: linear-gradient(to bottom, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%);
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0) 100%
);
}

View File

@@ -1,11 +1,11 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
build: {
target: 'esnext',
minify: 'esbuild',
target: "esnext",
minify: "esbuild",
cssMinify: true,
},
})
});

View File

@@ -668,6 +668,11 @@ postcss@^8.5.6:
picocolors "^1.1.1"
source-map-js "^1.2.1"
prettier@^3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
rollup@^4.43.0:
version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"