Skip to content

Commit 2b83e73

Browse files
committed
feat: add device online/offline status with ping check
- Add POST /api/ping endpoint with concurrent ICMP ping (up to 8 threads) - Add pingIp field to device model for reachability checks - Add StatusDot component with green/red/gray indicators and pulse animation - Add 30-second auto-polling and manual refresh button in web UI - Update fallback HTML with status column and pingIp form field - Update README API reference - Rewrite CHANGELOG to cover all releases
1 parent c43032c commit 2b83e73

4 files changed

Lines changed: 254 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,62 @@
1-
Changelog
1+
# Changelog
22

33
All notable changes to this project will be documented in this file.
44

5-
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7-
Unreleased
8-
- Initial public release preparation: docs (LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY), metadata polish, README links.
7+
## [0.1.6] – 2026-02-13
98

10-
1.0.0 – Initial
11-
- Expo/React Native Android foreground service to relay Wake-on-LAN requests over Tailscale.
12-
- HTTP `POST /wake` endpoint with shared token authentication.
13-
- UDP WOL broadcast with automatic subnet broadcast calculation.
14-
- Vite-based web UI packaged into Android assets.
9+
### Added
10+
- Device online/offline status via ICMP ping (`POST /api/ping` endpoint).
11+
- `pingIp` field on devices for reachability checks (separate from WOL broadcast IP).
12+
- Status indicators (green/red/gray dot) on web UI device cards with 30-second auto-polling.
13+
- Manual refresh button in web UI header.
14+
- Fallback HTML updated with status column and `pingIp` form field.
15+
16+
## [0.1.5] – 2026-02-12
17+
18+
### Fixed
19+
- Added error handling and logging to boot receiver.
20+
21+
## [0.1.4] – 2026-02-12
22+
23+
### Changed
24+
- Enabled Gradle build cache for faster CI builds.
25+
26+
## [0.1.3] – 2026-02-12
27+
28+
### Changed
29+
- Optimised Android build with Gradle caching and parallel execution.
30+
31+
## [0.1.2] – 2026-02-12
1532

33+
### Added
34+
- GitHub Actions workflow for automated APK releases.
35+
- Release documentation (`RELEASE.md`).
36+
37+
### Fixed
38+
- Build configuration fixes for release workflow.
39+
40+
## [0.1.1] – 2026-02-12
41+
42+
### Added
43+
- GitHub Actions workflow (initial iteration).
44+
45+
## [0.1.0] – 2026-02-12
46+
47+
### Added
48+
- File-based logging for Android service and boot receiver.
49+
- In-app log viewer with clear functionality.
50+
- Redesigned web UI with animated device cards, modals, and improved error handling.
51+
- Project guidelines (`AGENTS.md`), emulator setup docs, and automated setup script.
52+
53+
## [0.0.1] – Initial Release
54+
55+
### Added
56+
- Expo/React Native Android foreground service to relay Wake-on-LAN requests.
57+
- HTTP `POST /wol` endpoint with shared token authentication.
58+
- UDP WOL broadcast with configurable broadcast IP and port.
59+
- Vite-based web UI packaged into Android assets.
60+
- Device management API (`GET/POST/DELETE /api/devices`).
61+
- Auto-start on boot via `BootCompletedReceiver`.
62+
- Battery optimization settings shortcut.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,12 @@ curl -X POST "http://<phone-ip-or-vpn-ip>:<port>/wol" \
115115
- `GET /api/devices`
116116
- Returns JSON array of saved devices: `{ id, name, mac, ip, port }[]`.
117117
- `POST /api/devices`
118-
- JSON body: `{ id?, name, mac, ip="255.255.255.255", port=9 }`. Creates or updates a device. Returns `{ id }`.
118+
- JSON body: `{ id?, name, mac, ip="255.255.255.255", port=9, pingIp="" }`. Creates or updates a device. Returns `{ id }`. `pingIp` is the device's actual IP for status checks (optional).
119119
- `DELETE /api/devices/:id`
120120
- Deletes a saved device. Returns `{ ok: true }`.
121+
- `POST /api/ping`
122+
- JSON body: `{ "ip": "192.168.1.100" }` or `{ "ips": ["192.168.1.100", "192.168.1.101"] }` for batch.
123+
- Returns `{ "results": { "192.168.1.100": true, "192.168.1.101": false } }`. Uses ICMP ping with 1-second timeout.
121124
- `GET /api/dev-proxy`
122125
- Returns `{ enabled, url }` for dev proxy config.
123126
- `POST /api/dev-proxy`

android/app/src/main/java/com/anonymous/wolrelay/WolServer.kt

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import java.net.HttpURLConnection
1111
import java.net.URL
1212
import java.util.Locale
1313
import java.util.UUID
14+
import java.util.concurrent.Executors
15+
import java.util.concurrent.TimeUnit
1416

1517
class WolServer(
1618
private val context: Context,
@@ -30,6 +32,7 @@ class WolServer(
3032
session.uri == "/api/devices" && session.method == Method.GET -> listDevices(session)
3133
session.uri == "/api/devices" && session.method == Method.POST -> upsertDevice(session)
3234
session.uri.startsWith("/api/devices/") && session.method == Method.DELETE -> deleteDevice(session)
35+
session.uri == "/api/ping" && session.method == Method.POST -> handlePing(session)
3336
else -> serveStaticOrIndex(session)
3437
}
3538
} catch (e: Exception) {
@@ -207,6 +210,7 @@ class WolServer(
207210
val mac = json.optString("mac")
208211
val ip = json.optString("ip", "255.255.255.255")
209212
val port = json.optInt("port", 9)
213+
val pingIp = json.optString("pingIp", "")
210214
if (name.isBlank() || mac.isBlank()) return badRequest("Missing name/mac")
211215

212216
val arr = loadDevices()
@@ -218,6 +222,7 @@ class WolServer(
218222
obj.put("mac", mac)
219223
obj.put("ip", ip)
220224
obj.put("port", port)
225+
obj.put("pingIp", pingIp)
221226
updated = true
222227
break
223228
}
@@ -229,6 +234,7 @@ class WolServer(
229234
obj.put("mac", mac)
230235
obj.put("ip", ip)
231236
obj.put("port", port)
237+
obj.put("pingIp", pingIp)
232238
arr.put(obj)
233239
}
234240
saveDevices(arr)
@@ -267,6 +273,67 @@ class WolServer(
267273
}
268274
}
269275

276+
private fun handlePing(session: IHTTPSession): Response {
277+
if (!isAuthorized(session)) return unauthorized()
278+
val files = mutableMapOf<String, String>()
279+
return try {
280+
session.parseBody(files)
281+
val body = files["postData"] ?: return badRequest("Missing body")
282+
val json = JSONObject(body)
283+
284+
// Accept either { "ip": "..." } or { "ips": ["...", ...] }
285+
val ips = mutableListOf<String>()
286+
if (json.has("ip")) {
287+
json.optString("ip").trim().takeIf { it.isNotEmpty() }?.let { ips.add(it) }
288+
}
289+
if (json.has("ips")) {
290+
val arr = json.optJSONArray("ips")
291+
if (arr != null) {
292+
for (i in 0 until arr.length()) {
293+
arr.optString(i).trim().takeIf { it.isNotEmpty() }?.let { ips.add(it) }
294+
}
295+
}
296+
}
297+
if (ips.isEmpty()) return badRequest("Missing ip or ips")
298+
299+
// Ping all IPs concurrently
300+
val results = JSONObject()
301+
val executor = Executors.newFixedThreadPool(ips.size.coerceAtMost(8))
302+
val futures = ips.map { ip ->
303+
executor.submit<Pair<String, Boolean>> {
304+
ip to pingHost(ip)
305+
}
306+
}
307+
for (future in futures) {
308+
try {
309+
val (ip, reachable) = future.get(5, TimeUnit.SECONDS)
310+
results.put(ip, reachable)
311+
} catch (_: Exception) {
312+
// Timeout or error — mark as unreachable
313+
}
314+
}
315+
executor.shutdown()
316+
317+
newJsonResponse(Response.Status.OK, JSONObject().put("results", results))
318+
} catch (e: Exception) {
319+
badRequest("Invalid request: ${e.message}")
320+
}
321+
}
322+
323+
private fun pingHost(ip: String): Boolean {
324+
return try {
325+
// Sanitize: only allow IP-like characters to prevent command injection
326+
val sanitized = ip.replace(Regex("[^0-9a-fA-F.:]"), "")
327+
if (sanitized.isEmpty()) return false
328+
329+
val process = Runtime.getRuntime().exec(arrayOf("ping", "-c", "1", "-W", "1", sanitized))
330+
val exited = process.waitFor()
331+
exited == 0
332+
} catch (_: Exception) {
333+
false
334+
}
335+
}
336+
270337
private fun loadDevices(): JSONArray {
271338
val s = prefs.getString("devices", "[]") ?: "[]"
272339
return try { JSONArray(s) } catch (_: Exception) { JSONArray() }
@@ -347,6 +414,10 @@ class WolServer(
347414
table { border-collapse: collapse; width: 100%; }
348415
th, td { text-align: left; padding: 0.4rem; border-bottom: 1px solid #eee; }
349416
.actions button { margin-right: .4rem }
417+
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
418+
.dot-online { background: #22c55e; }
419+
.dot-offline { background: #ef4444; }
420+
.dot-unknown { background: #ccc; }
350421
</style>
351422
</head>
352423
<body>
@@ -365,7 +436,7 @@ class WolServer(
365436
<div class="card">
366437
<h3>Devices</h3>
367438
<table id="devicetable">
368-
<thead><tr><th>Name</th><th>MAC</th><th>IP</th><th>Port</th><th class="actions">Actions</th></tr></thead>
439+
<thead><tr><th>Name</th><th>MAC</th><th>IP</th><th>Port</th><th>Status</th><th class="actions">Actions</th></tr></thead>
369440
<tbody id="devices"></tbody>
370441
</table>
371442
</div>
@@ -382,6 +453,10 @@ class WolServer(
382453
<label>MAC Address</label><br />
383454
<input name="mac" placeholder="AA:BB:CC:DD:EE:FF" required />
384455
</div>
456+
<div>
457+
<label>Device IP (for status check)</label><br />
458+
<input name="pingIp" placeholder="192.168.1.100 (optional)" />
459+
</div>
385460
<div class="row">
386461
<div>
387462
<label>Broadcast IP</label><br />
@@ -425,17 +500,27 @@ class WolServer(
425500
426501
async function refreshDevices() {
427502
const tbody = document.getElementById('devices');
428-
tbody.innerHTML = '<tr><td colspan="5">Loading…</td></tr>';
503+
tbody.innerHTML = '<tr><td colspan="6">Loading…</td></tr>';
429504
try {
430505
const list = await api('/api/devices');
431506
tbody.innerHTML = '';
432507
if (!Array.isArray(list) || list.length === 0) {
433-
tbody.innerHTML = '<tr><td colspan="5">No devices yet</td></tr>';
508+
tbody.innerHTML = '<tr><td colspan="6">No devices yet</td></tr>';
434509
return;
435510
}
511+
// Collect pingIps for status check
512+
const pingIps = list.map(d => d.pingIp).filter(Boolean);
513+
let statuses = {};
514+
if (pingIps.length > 0) {
515+
try {
516+
const pingRes = await api('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ips: pingIps }) });
517+
statuses = pingRes.results || {};
518+
} catch (_) {}
519+
}
436520
for (const d of list) {
437521
const tr = document.createElement('tr');
438-
tr.innerHTML = `<td>${'$'}{d.name||''}</td><td>${'$'}{d.mac||''}</td><td>${'$'}{d.ip||''}</td><td>${'$'}{d.port||9}</td>`;
522+
const statusDot = d.pingIp ? (statuses[d.pingIp] ? '<span class="dot dot-online"></span>Online' : '<span class="dot dot-offline"></span>Offline') : '<span class="dot dot-unknown"></span>—';
523+
tr.innerHTML = `<td>${'$'}{d.name||''}</td><td>${'$'}{d.mac||''}</td><td>${'$'}{d.ip||''}</td><td>${'$'}{d.port||9}</td><td>${'$'}{statusDot}</td>`;
439524
const tdActions = document.createElement('td');
440525
tdActions.className='actions';
441526
const wakeBtn = document.createElement('button');
@@ -452,7 +537,7 @@ class WolServer(
452537
tbody.appendChild(tr);
453538
}
454539
} catch (err) {
455-
tbody.innerHTML = `<tr><td colspan="5">Error: ${'$'}{err.message}</td></tr>`;
540+
tbody.innerHTML = `<tr><td colspan="6">Error: ${'$'}{err.message}</td></tr>`;
456541
}
457542
}
458543
@@ -463,19 +548,20 @@ class WolServer(
463548
f.mac.value = d.mac||'';
464549
f.ip.value = d.ip||'255.255.255.255';
465550
f.port.value = d.port||9;
551+
f.pingIp.value = d.pingIp||'';
466552
}
467553
468554
function resetForm() {
469555
const f = document.forms[0];
470556
f.id.value = '';
471-
f.name.value=''; f.mac.value=''; f.ip.value='255.255.255.255'; f.port.value=9;
557+
f.name.value=''; f.mac.value=''; f.ip.value='255.255.255.255'; f.port.value=9; f.pingIp.value='';
472558
document.getElementById('formResult').textContent='';
473559
}
474560
475561
async function saveDevice(e) {
476562
e.preventDefault();
477563
const f = e.target;
478-
const payload = { id: f.id.value, name: f.name.value, mac: f.mac.value, ip: f.ip.value, port: Number(f.port.value)||9 };
564+
const payload = { id: f.id.value, name: f.name.value, mac: f.mac.value, ip: f.ip.value, port: Number(f.port.value)||9, pingIp: (f.pingIp.value||'').trim() };
479565
try {
480566
await api('/api/devices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
481567
document.getElementById('formResult').textContent = 'Saved';

0 commit comments

Comments
 (0)