Rohan Verma



Commit Diff


commit - /dev/null
commit + 91c2fbb0178d372c7de613bcc377cfbeefc26b48
blob - /dev/null
blob + a373fb4b28e59937d6e58caf0f15131289cbcb54 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1 @@
+services.json
blob - /dev/null
blob + 8c19a3a15fbfa7597cee90f0a7f23aca2ca48a06 (mode 755)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Rohan Verma
+
+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.
blob - /dev/null
blob + 74159d899d7ca5e957f1ebb52a09a4d3cc9dd658 (mode 755)
--- /dev/null
+++ README.md
@@ -0,0 +1,52 @@
+# navigator 🧭
+Navigate between your HomeLab services
+
+## preview
+<img width="1774" height="985" alt="image" src="https://github.com/user-attachments/assets/e58bcbae-1867-4333-b386-e1728629a2ed" />
+<img width="1777" height="993" alt="image" src="https://github.com/user-attachments/assets/d560cdaf-1d22-4dcd-9f86-6127ca7ae369" />
+
+## credits
+- @faustinoaq for porting the JS api to C
+- ryanoasis/nerd-fonts for patching GoMono with nerd for GoMono Nerd Font
+
+## features?
+- super lightweight, in my testing it uses about ~2.2 mb for running the API and the site, the html site is only ~7kb so it fits in one packet
+- dynamically adjusting config file, if you enter a entry in the service.json it'll automatically update the site without restart
+- minimal and clean, meant to be as minimal as possible
+- a status api checker built in that pings each site and all runs in one container
+- dark mode/light mode button
+- the entire container and setup is about ~56kb for everything
+
+## how do i set this up?
+1. git clone this repository
+```
+git clone https://github.com/rohanverma2007/navigator.git
+```
+or clone this if your on arm
+```
+git clone -b arm https://github.com/rohanverma2007/navigator.git
+```
+
+2. cd into the navigator directory `cd navigator`
+
+3. simply run the command `docker compose up -d` (or `podman-compose up -d` if you use podman)
+
+4. should be running on port 11080, a reverse proxy is recommended for this setup
+
+## how do i update?
+
+1. cd into the git cloned directory
+
+2. type inside the dir `git pull`
+
+## reverse proxies?
+I currently use Traefik on my podman containers, heres the labels I have on my navigator container, but you can really use whatever reverse proxy you want (etc. NGINX, Caddy, Apache)
+```
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.navigate.rule=Host(`navigate.example.com`)"
+      - "traefik.http.routers.navigate.entrypoints=websecure"
+      - "traefik.http.routers.navigate.tls=true"
+      - "traefik.http.routers.navigate.tls.certresolver=lets-encrypt"
+      - "traefik.http.services.navigate.loadbalancer.server.port=11080"
+```
blob - /dev/null
blob + 6d0046034b4ed16096917fa028498301508be9b3 (mode 755)
Binary files /dev/null and assets/dark.png differ
blob - /dev/null
blob + aea1cb78aafbd07bfa51d19b2f8ea4fdd80ccf3d (mode 755)
Binary files /dev/null and assets/light.png differ
blob - /dev/null
blob + bb41e6ada29084ceda07edd90b0e789005e4f5ac (mode 755)
--- /dev/null
+++ compose.yaml
@@ -0,0 +1,40 @@
+services:
+  navigator:
+    image: alpine:latest
+    container_name: navigator
+    restart: unless-stopped
+    working_dir: /app
+    command: ./server
+    ports:
+      - "11080:11080"
+    dns:
+      - 1.1.1.1
+      - 8.8.8.8
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.navigate.rule=Host(`navigate.rsap.sh`)"
+      - "traefik.http.routers.navigate.entrypoints=websecure"
+      - "traefik.http.routers.navigate.tls=true"
+      - "traefik.http.routers.navigate.tls.certresolver=lets-encrypt"
+      - "traefik.http.services.navigate.loadbalancer.server.port=11080"
+    networks:
+      - podman
+    volumes:
+      - ./index.html:/app/index.html:ro
+      - ./services.json:/app/services.json:ro
+      - ./assets/dark.png:/app/dark.png:ro
+      - ./assets/light.png:/app/light.png:ro
+      - ./status-api/server.c:/app/server.c:ro
+      - ./fonts:/app/fonts:ro
+    entrypoint: |
+      sh -c "
+        apk add --no-cache curl gcc musl-dev &&
+        gcc -Os -s -ffunction-sections -fdata-sections -Wl,--gc-sections -o server server.c &&
+        strip --strip-all server &&
+        ls -lh server &&
+        ./server
+      "
+
+networks:
+  podman:
+    external: true
blob - /dev/null
blob + 488376227af092dd09c6070a984de633d4cd3aa5 (mode 755)
Binary files /dev/null and fonts/GoMonoNerdFont-Bold.ttf differ
blob - /dev/null
blob + 16a642c3b4deae303da096948637a9868dd84113 (mode 755)
Binary files /dev/null and fonts/GoMonoNerdFont-Regular.ttf differ
blob - /dev/null
blob + ec7d6813033657ce1c40ddb534f1646343c42624 (mode 755)
--- /dev/null
+++ index.html
@@ -0,0 +1,542 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width,initial-scale=1">
+	<title>Navigator</title>
+	<link rel="icon" href="dark.png">
+	<link rel="apple-touch-icon" href="light.png">
+	<style>
+        @font-face {
+            font-family: 'GoMono Nerd Font';
+            src: url('./fonts/GoMonoNerdFont-Regular.ttf') format('truetype');
+            font-weight: normal;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'GoMono Nerd Font';
+            src: url('./fonts/GoMonoNerdFont-Bold.ttf') format('truetype');
+            font-weight: bold;
+            font-style: normal;
+        }
+
+		* {
+			margin: 0;
+			padding: 0;
+			box-sizing: border-box;
+		}
+
+		:root {
+			--bp: #fff;
+			--bs: #f8f8f8;
+			--bh: #f0f0f0;
+			--bc: #fff;
+			--tp: #000;
+			--ts: #666;
+			--b: #e0e0e0;
+			--s: rgba(0, 0, 0, .08);
+			--l: url(light.png);
+			--on: #00c851;
+			--of: #ff3547;
+		}
+
+		[data-theme=dark] {
+			--bp: #0a0a0a;
+			--bs: #1a1a1a;
+			--bh: #252525;
+			--bc: #141414;
+			--tp: #fff;
+			--ts: #999;
+			--b: #2a2a2a;
+			--s: rgba(0, 0, 0, .4);
+			--l: url(dark.png);
+		}
+
+        :root[data-theme="light"] #ti {
+          color: #000000;
+        }
+
+        :root[data-theme="dark"] #ti {
+          color: var(--tp);
+        }
+
+		body {
+			font-family: 'GoMono Nerd Font', monospace;
+			background: var(--bp);
+			color: var(--tp);
+			min-height: 100vh;
+			padding: 60px 40px;
+			transition: background-color .3s, color .3s;
+		}
+
+		.c {
+			max-width: 1400px;
+			margin: 0 auto;
+		}
+
+		.h {
+			margin-bottom: 60px;
+			display: flex;
+			align-items: center;
+			gap: 16px;
+		}
+
+		.lo {
+			width: 40px;
+			height: 40px;
+			background: var(--l) no-repeat center/contain;
+			flex-shrink: 0;
+			transform: translateY(-1px);
+		}
+
+		h1 {
+			font-size: 2rem;
+			font-weight: 400;
+			letter-spacing: -.02em;
+			transform: translateY(-2px);
+		}
+
+		.g {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+			gap: 20px;
+		}
+
+		.sc {
+			background: var(--bs);
+			border: 1.5px solid var(--b);
+			border-radius: 0px;
+			padding: 24px;
+			text-decoration: none;
+			color: var(--tp);
+			transition: all .2s;
+			position: relative;
+			display: flex;
+			align-items: center;
+			gap: 20px;
+		}
+
+		.sc:hover {
+			background: var(--bh);
+			transform: translateY(-2px);
+			box-shadow: 0 8px 24px var(--s);
+		}
+
+		.si {
+			width: 48px;
+			height: 48px;
+			border-radius: 0px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 24px;
+			flex-shrink: 0;
+			background: var(--bs);
+			border: 0px solid var(--b);
+			overflow: hidden;
+		}
+
+		.si img {
+			width: 100%;
+			height: 100%;
+			object-fit: contain;
+		}
+
+		.sn {
+			flex: 1;
+			min-width: 0;
+		}
+
+		.sn>div:first-child {
+			font-size: 16px;
+			font-weight: 500;
+			margin-bottom: 4px;
+		}
+
+		.sn>div:last-child {
+			font-size: 13px;
+			color: var(--ts);
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+
+		.d {
+			position: absolute;
+			top: 12px;
+			right: 12px;
+			width: 8px;
+			height: 8px;
+			border-radius: 50%;
+			background: var(--b);
+			transition: all .3s;
+		}
+
+		.d.on {
+			background: var(--on);
+			box-shadow: 0 0 8px rgba(0, 200, 81, .4);
+		}
+
+		.d.of {
+			background: var(--of);
+			box-shadow: 0 0 8px rgba(255, 53, 71, .4);
+		}
+
+		.e, .l {
+			text-align: center;
+			padding: 100px 20px;
+			color: var(--ts);
+		}
+
+		.e h2 {
+			font-size: 1.5rem;
+			font-weight: 400;
+			margin-bottom: 10px;
+			color: var(--tp);
+		}
+
+		.e p, .l {
+			font-size: 14px;
+		}
+
+		.er, .ce {
+			background: var(--bs);
+			border: 1px solid var(--b);
+			border-radius: 12px;
+			padding: 40px;
+			text-align: center;
+			color: var(--ts);
+		}
+
+		.ce {
+			padding: 20px;
+			margin-top: 20px;
+			text-align: left;
+		}
+
+		.er h2, .ce h3 {
+			font-weight: 400;
+			margin-bottom: 10px;
+			color: var(--tp);
+		}
+
+		.er h2 {
+			font-size: 1.2rem;
+		}
+
+		.ce h3 {
+			font-size: 14px;
+			font-weight: 500;
+		}
+
+		.er pre, .ce pre {
+			background: var(--bc);
+			border: 1px solid var(--b);
+			border-radius: 8px;
+			padding: 12px;
+			margin-top: 20px;
+			font-size: 12px;
+			text-align: left;
+			overflow-x: auto;
+			font-family: 'GoMono Nerd Font', monospace;
+			color: var(--tp);
+		}
+
+		.ce pre {
+			border-radius: 4px;
+			margin-top: 10px;
+		}
+
+        #ti {
+          font-family: 'GoMono Nerd Font', monospace;
+          font-size: 20px;
+          line-height: 1;          /* critical */
+          display: block;
+          transform: translateX(-3px);
+        }
+
+		.sb {
+			position: fixed;
+			bottom: 20px;
+			right: 20px;
+			font-size: 14px;
+			color: var(--ts);
+			font-family: 'GoMono Nerd Font', monospace;
+            font-weight: 400;
+			letter-spacing: -.01em;
+			background: var(--bs);
+			padding: 10px 16px;
+			border: 1px solid var(--b);
+			border-radius: 8px;
+			backdrop-filter: blur(8px);
+		}
+
+		.ft {
+			position: fixed;
+			bottom: 20px;
+			left: 50%;
+			transform: translateX(-50%);
+			color: #ffffff;
+			opacity: 0.3;
+			font-size: 12px;
+		}
+
+		.ft span {
+			color: #ffffff;
+			opacity: 0.6;
+		}
+
+		.ft a {
+			color: inherit;
+			text-decoration: none;
+			transition: opacity 0.2s;
+		}
+
+		.ft a:hover {
+			opacity: 2;
+			text-decoration: underline;
+		}
+
+		.tt {
+			position: fixed;
+			top: 20px;
+			right: 20px;
+			width: 44px;
+			height: 44px;
+			border-radius: 12px;
+			background: var(--bs);
+			border: 1px solid var(--b);
+			cursor: pointer;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			transition: all .3s;
+			font-size: 20px;
+            font-family: 'GoMono Nerd Font', monospace;
+		}
+
+		.tt:hover {
+			background: var(--bh);
+			transform: scale(1.05);
+		}
+
+		.tt:active {
+			transform: scale(.95);
+		}
+
+		code {
+			background: var(--bh);
+			padding: 2px 6px;
+			border-radius: 3px;
+		}
+
+		@media(max-width: 768px) {
+			body {
+				padding: 40px 20px;
+			}
+
+			.g {
+				grid-template-columns: 1fr;
+			}
+
+			h1 {
+				font-size: 1.5rem;
+			}
+
+			.lo {
+				width: 32px;
+				height: 32px;
+			}
+
+			.sb {
+				font-size: 12px;
+				padding: 8px 12px;
+				bottom: 16px;
+				right: 16px;
+			}
+		}
+
+		@media(prefers-reduced-motion: reduce) {
+			*, ::before, ::after {
+				animation-duration: .01ms!important;
+				animation-iteration-count: 1!important;
+				transition-duration: .01ms!important;
+			}
+		}
+	</style>
+</head>
+<body>
+	<button class="tt" id="t" aria-label="Toggle theme">
+		<span id="ti"></span>
+	</button>
+	<div class="c">
+		<div class="h">
+			<div class="lo"></div>
+			<h1>Navigator</h1>
+		</div>
+		<div id="c">
+			<div class="l">Loading services...</div>
+		</div>
+	</div>
+	<div class="sb">
+		<span id="sc">0</span> services | <span id="oc">0</span> online
+	</div>
+	<div class="ft">
+		Made with <span>ó°‹‘</span> by <a href="https://github.com/rohanverma2007" target="_blank" rel="noopener noreferrer">Rohan Verma</a>
+	</div>
+	<script>
+		(() => {
+			const d = document,
+				q = d.querySelector.bind(d),
+				qa = d.querySelectorAll.bind(d),
+				ls = localStorage,
+				el = d.documentElement;
+			let s = [],
+				ci,
+				lcc = null,
+				sa = null;
+
+			// Theme
+			const pd = matchMedia('(prefers-color-scheme:dark)').matches,
+				st = ls.getItem('t'),
+				th = st || (pd ? 'dark' : 'light');
+			el.setAttribute('data-theme', th);
+			const ut = () => {
+				const ct = el.getAttribute('data-theme');
+				q('#ti').textContent = ct === 'dark' ? '' : '';
+			};
+			ut();
+			q('#t').onclick = () => {
+				const ct = el.getAttribute('data-theme'),
+					nt = ct === 'dark' ? 'light' : 'dark';
+				el.setAttribute('data-theme', nt);
+				ls.setItem('t', nt);
+				ut();
+			};
+
+			// Load config
+			const lc = async () => {
+				try {
+					let r, cl = 0;
+					for (const p of ['./services.json', 'services.json']) {
+						try {
+							r = await fetch(`${p}?t=${Date.now()}`);
+							if (r.ok) {
+								cl = 1;
+								break;
+							}
+						} catch {}
+					}
+					if (!cl && location.protocol === 'file:') {
+						se();
+						return;
+					}
+					if (!cl) throw Error('Could not find services.json');
+					const t = await r.text();
+					if (t !== lcc) {
+						lcc = t;
+						const d = JSON.parse(t);
+						s = d.services || [];
+						sa = d.statusApiUrl || null;
+						rs();
+						cs();
+					}
+				} catch (e) {
+					console.error('Failed to load config:', e);
+					sh(e);
+				}
+			};
+
+			// Render services
+			const rs = () => {
+				const c = q('#c'),
+					sc = q('#sc'),
+					oc = q('#oc');
+				sc.textContent = s.length;
+				if (!s.length) {
+					c.innerHTML = `<div class="e"><h2>No services configured</h2><p>Add services to your services.json file</p><div class="ce"><h3>Example services.json:</h3><pre>{
+  "services": [
+    {
+      "name": "Example Service",
+      "url": "example.rsap.sh",
+      "icon": "🚀"
+    }
+  ]
+}</pre></div></div>`;
+					return;
+				}
+				const g = d.createElement('div');
+				g.className = 'g';
+				s.forEach(sv => {
+					const a = d.createElement('a');
+					let u = sv.url;
+					if (!u.startsWith('http')) u = 'https://' + u;
+					if (sv.port) {
+						const o = new URL(u);
+						o.port = sv.port;
+						u = o.toString();
+					}
+					a.href = u;
+					a.className = 'sc';
+					a.target = '_blank';
+					a.rel = 'noopener noreferrer';
+					let ic = '';
+					if (sv.icon) {
+						if (sv.icon.startsWith('http') || sv.icon.startsWith('/') || sv.icon.includes('.')) {
+							ic = `<img src="${sv.icon}" alt="${sv.name}" loading="lazy" onerror="this.style.display='none';this.parentElement.innerHTML='📦';">`;
+						} else ic = sv.icon;
+					} else ic = '📦';
+					const sc = sv.status === 'online' ? 'on' : sv.status === 'offline' ? 'of' : '';
+					const du = sv.port ? `${sv.url}:${sv.port}` : sv.url;
+					a.innerHTML = `<div class="d ${sc}"></div><div class="si">${ic}</div><div class="sn"><div>${sv.name}</div><div>${du}</div></div>`;
+					g.appendChild(a);
+				});
+				c.innerHTML = '';
+				c.appendChild(g);
+				oc.textContent = s.filter(sv => sv.status === 'online').length;
+			};
+
+			// Error handlers
+			const sh = e => {
+				q('#c').innerHTML = `<div class="er"><h2>Configuration Error</h2><p>Failed to load services.json</p><pre>${e.message}</pre><div class="ce"><h3>Setup Instructions:</h3><p style="margin-bottom:10px;font-size:13px">1. Create a file named <strong>services.json</strong> in the same directory as this HTML file<br>2. Add your services configuration<br>3. Make sure you're serving files through a web server (not file://)</p><h3>Example services.json:</h3><pre>{
+  "services": [
+    {
+      "name": "Jellyfin",
+      "url": "jellyfin.example.com",
+      "icon": "🎭"
+    }
+  ]
+}</pre></div></div>`;
+			};
+
+			const se = () => {
+				q('#c').innerHTML = `<div class="er"><h2>Local File Access Restricted</h2><p>Cannot load services.json using file:// protocol</p><div class="ce"><h3>How to fix:</h3><p style="margin-bottom:10px;font-size:13px">Option 1: Use a local web server<br><code>python3 -m http.server 8000</code><br><br>Option 2: Use Live Server in VS Code<br><br>Option 3: Deploy to your web server/Traefik</p></div></div>`;
+			};
+
+			// Status check
+			const ck = async i => {
+				const sv = s[i],
+					u = sv.url.startsWith('http') ? sv.url : `https://${sv.url}`;
+				if (sa) {
+					try {
+						const r = await fetch(`${sa}/check?url=${encodeURIComponent(u)}`);
+						const d = await r.json();
+						s[i].status = d.online ? 'online' : 'offline';
+					} catch {
+						s[i].status = 'online';
+					}
+				} else s[i].status = 'online';
+				rs();
+			};
+
+			const cs = () => s.forEach((_, i) => ck(i));
+
+			// Init
+			lc();
+			ci = setInterval(lc, 5000);
+			addEventListener('beforeunload', () => ci && clearInterval(ci));
+		})();
+	</script>
+</body>
+</html>
blob - /dev/null
blob + 792a397431dd426b6392d89c9f4a5b6d007f077f (mode 755)
--- /dev/null
+++ status-api/server.c
@@ -0,0 +1,234 @@
+// Ported plain C version from https://github.com/rohanverma2007/navigator/blob/main/status-api/server.js
+// Minimal footprint (~1MB) C port of the navigator tool API server.js shared in r/homelab by u/SmokerrYT
+// @faustinoaq
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <netinet/in.h>
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <errno.h>
+
+#define PORT 11080
+#define TIMEOUT 3
+#define CACHE_TTL 20
+#define CACHE_MAX 100
+#define BUF_SIZE 4096
+
+struct CacheEntry {
+    char url[256];
+    int online;
+    int code;
+    int time;
+    time_t ts;
+};
+struct CacheEntry cache[CACHE_MAX];
+int cache_size = 0;
+
+// Find cache entry
+int find_cache(const char *url) {
+    for (int i = 0; i < cache_size; ++i) {
+        if (strcmp(cache[i].url, url) == 0) return i;
+    }
+    return -1;
+}
+
+// Add/update cache
+void set_cache(const char *url, int online, int code, int elapsed) {
+    int idx = find_cache(url);
+    if (idx < 0 && cache_size < CACHE_MAX) idx = cache_size++;
+    if (idx >= 0) {
+        strncpy(cache[idx].url, url, 255);
+        cache[idx].online = online;
+        cache[idx].code = code;
+        cache[idx].time = elapsed;
+        cache[idx].ts = time(NULL);
+    }
+}
+
+// Clean old cache
+void clean_cache() {
+    time_t now = time(NULL);
+    for (int i = 0; i < cache_size;) {
+        if (now - cache[i].ts > CACHE_TTL) {
+            cache[i] = cache[--cache_size];
+        } else {
+            ++i;
+        }
+    }
+}
+
+// URL decode function
+void url_decode(char *dst, const char *src) {
+    char *p = dst;
+    while (*src) {
+        if (*src == '%' && src[1] && src[2]) {
+            char hex[3] = {src[1], src[2], 0};
+            *p++ = (char)strtol(hex, NULL, 16);
+            src += 3;
+        } else if (*src == '+') {
+            *p++ = ' ';
+            src++;
+        } else {
+            *p++ = *src++;
+        }
+    }
+    *p = 0;
+}
+
+// Minimal HTTP check using curl
+void check_http(const char *url, int *online, int *code, int *ms) {
+    char cmd[512], buf[16];
+    char full_url[512];
+    
+    // Add https:// if not present
+    if (strstr(url, "http://") == url || strstr(url, "https://") == url) {
+        strcpy(full_url, url);
+    } else {
+        snprintf(full_url, sizeof(full_url), "https://%s", url);
+    }
+    
+    snprintf(cmd, sizeof(cmd), "curl -s -o /dev/null -w %%{http_code} --connect-timeout 2 --max-time 3 --insecure '%s'", full_url);
+    clock_t start = clock();
+    FILE *fp = popen(cmd, "r");
+    if (!fp) { *online = 0; *code = 0; *ms = TIMEOUT * 1000; return; }
+    if (fgets(buf, sizeof(buf), fp) == NULL) buf[0] = '\0';
+    pclose(fp);
+    *ms = (int)(1000.0 * (clock() - start) / CLOCKS_PER_SEC);
+    *code = atoi(buf);
+    // Consider auth prompts (401/403) as online since service is responding
+    *online = (*code >= 200 && *code < 500 && *code != 404);
+}
+
+// Get content type based on file extension
+const char* get_content_type(const char *path) {
+    if (strstr(path, ".html")) return "text/html";
+    if (strstr(path, ".json")) return "application/json";
+    if (strstr(path, ".png")) return "image/png";
+    if (strstr(path, ".jpg") || strstr(path, ".jpeg")) return "image/jpeg";
+    return "text/plain";
+}
+
+// Serve static file
+void serve_file(int fd, const char *path) {
+    int f = open(path, O_RDONLY);
+    if (f < 0) {
+        dprintf(fd, "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot found");
+        return;
+    }
+    struct stat st;
+    fstat(f, &st);
+    const char *content_type = get_content_type(path);
+    dprintf(fd, "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %ld\r\nCache-Control: public, max-age=86400\r\n\r\n", content_type, st.st_size);
+    char buf[BUF_SIZE];
+    ssize_t n;
+    while ((n = read(f, buf, BUF_SIZE)) > 0) {
+        ssize_t w = write(fd, buf, n);
+        (void)w;
+    }
+    close(f);
+}
+
+// Minimal JSON response
+void send_json(int fd, const char *json) {
+    dprintf(fd, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n%s", json);
+}
+
+// Parse URL param with decoding
+void get_query(const char *req, const char *key, char *out, int outlen) {
+    char *p = strstr(req, key);
+    if (!p) { out[0] = 0; return; }
+    p += strlen(key);
+    char *e = strchr(p, '&');
+    if (!e) e = strchr(p, ' ');
+    int len = e ? e - p : strlen(p);
+    if (len > outlen-1) len = outlen-1;
+    
+    char temp[256];
+    strncpy(temp, p, len); 
+    temp[len] = 0;
+    url_decode(out, temp);
+}
+
+// Main server loop
+int main() {
+    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
+    if (server_fd < 0) return 1;
+    int opt = 1;
+    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+    struct sockaddr_in addr = {0};
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = INADDR_ANY;
+    addr.sin_port = htons(PORT);
+    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) return 1;
+    listen(server_fd, 10);
+    printf("Listening on port %d...\n", PORT);
+    while (1) {
+        int client_fd = accept(server_fd, NULL, NULL);
+        if (client_fd < 0) continue;
+        char req[BUF_SIZE] = {0};
+        ssize_t r = read(client_fd, req, BUF_SIZE-1);
+        (void)r;
+        clean_cache();
+        // API routes
+        if (strstr(req, "GET /api/health")) {
+            char json[128];
+            snprintf(json, sizeof(json), "{\"ok\":1,\"up\":%ld}", time(NULL));
+            send_json(client_fd, json);
+        } else if (strstr(req, "GET /api/check?url=")) {
+            char url[256];
+            get_query(req, "url=", url, sizeof(url));
+            int idx = find_cache(url);
+            int online, code, ms;
+            if (idx >= 0 && time(NULL)-cache[idx].ts < CACHE_TTL) {
+                online = cache[idx].online; code = cache[idx].code; ms = cache[idx].time;
+            } else {
+                check_http(url, &online, &code, &ms);
+                set_cache(url, online, code, ms);
+            }
+            char json[256];
+            snprintf(json, sizeof(json), "{\"url\":\"%s\",\"online\":%s,\"code\":%d,\"time\":%d}", url, online ? "true" : "false", code, ms);
+            send_json(client_fd, json);
+        } else if (strstr(req, "GET /api/cache")) {
+            char json[BUF_SIZE];
+            int n = snprintf(json, sizeof(json), "{\"size\":%d,\"ttl\":%d,\"entries\":[", cache_size, CACHE_TTL);
+            for (int i = 0; i < cache_size; ++i) {
+                n += snprintf(json+n, sizeof(json)-n, "{\"url\":\"%s\",\"online\":%s,\"age\":%ld}%s", cache[i].url, cache[i].online ? "true" : "false", time(NULL)-cache[i].ts, i+1<cache_size?",":"");
+            }
+            snprintf(json+n, sizeof(json)-n, "]}");
+            send_json(client_fd, json);
+        } else if (strstr(req, "DELETE /api/cache")) {
+            cache_size = 0;
+            send_json(client_fd, "{\"cleared\":1}");
+        } else if (strstr(req, "GET / ") || strstr(req, "GET /\r")) {
+            serve_file(client_fd, "index.html");
+        } else if (strstr(req, "GET /services.json")) {
+            serve_file(client_fd, "services.json");
+        } else if (strstr(req, "GET /")) {
+            // Serve static file
+            char path[128] = {0};
+            char *p = strstr(req, "GET /") + 4;
+            char *e = strchr(p, ' ');
+            int len = e ? e-p : 0;
+            if (len > 0 && len < 100) {
+                if (*p == '/') p++;  // Skip leading slash
+                strncpy(path, p, len-1);
+                path[len-1] = 0;
+                serve_file(client_fd, path);
+            } else {
+                dprintf(client_fd, "HTTP/1.1 400 Bad Request\r\n\r\n");
+            }
+        } else {
+            dprintf(client_fd, "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot found");
+        }
+        close(client_fd);
+    }
+    close(server_fd);
+    return 0;
+}