Compare commits

...
Author SHA1 Message Date
nyra 681cb29686 Space bar ribbon: fail-safe host graph fetch and parser 2026-09-27 18:35:02 +00:00
2 changed files with 247 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
package juloo.keyboard2;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
/** Host load history behind the space bar ribbon.
*
* Two rules, both Harley's, and they are the whole design:
* - failure is quiet. Offline, VPN down, Utumno off, junk payload or a timeout all end as
* "no data" - never a crash, and never a stale trace wearing a live face.
* - parsing happens on the fetch thread, so the draw path can neither block nor throw.
*/
public final class HostGraph
{
/** Payload series order. io leads: a stalled disk is the thing worth seeing. */
public static final String[] SERIES = { "io", "cpu" };
/** A sample older than this is not worth drawing as if it were now. */
public static final long MAX_AGE_MS = 15000L;
private static final int CONNECT_TIMEOUT_MS = 2500;
private static final int READ_TIMEOUT_MS = 2500;
private static final long MIN_INTERVAL_MS = 2000L;
private static final int DEFAULT_N = 60;
private static final Object _lock = new Object();
private static float[][] _values = new float[SERIES.length][];
private static long _fetchedAt = 0L;
private static long _lastAttempt = 0L;
private static boolean _inFlight = false;
private HostGraph() {}
/** True only when there is something honest to draw: data, and data that is still current. */
public static boolean hasData()
{
synchronized (_lock)
{
if (_values[0] == null || _values[0].length == 0)
return false;
return System.currentTimeMillis() - _fetchedAt < MAX_AGE_MS;
}
}
/** Copy of one series; empty when there is nothing to draw. Safe on the draw path. */
public static float[] series(int index)
{
synchronized (_lock)
{
float[] v = (index >= 0 && index < _values.length) ? _values[index] : null;
return v == null ? new float[0] : v.clone();
}
}
/** Fetch at most every couple of seconds; call freely while the keyboard is visible. */
public static void fetchIfNeeded(String baseUrl, String token)
{
long now = System.currentTimeMillis();
synchronized (_lock)
{
if (_inFlight || now - _lastAttempt < MIN_INTERVAL_MS)
return;
_inFlight = true;
_lastAttempt = now;
}
Thread worker = new Thread(() -> {
try
{
run(baseUrl, token);
}
catch (Throwable t)
{
forget();
}
finally
{
synchronized (_lock) { _inFlight = false; }
}
}, "host-graph");
worker.setDaemon(true);
worker.start();
}
private static void forget()
{
synchronized (_lock)
{
_values = new float[SERIES.length][];
_fetchedAt = 0L;
}
}
private static void run(String baseUrl, String token)
{
HttpURLConnection connection = null;
try
{
String url = baseUrl + "/graph?n=" + DEFAULT_N;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestProperty("Accept", "application/json");
if (token != null && !token.isEmpty())
connection.setRequestProperty("Authorization", "Bearer " + token);
StringBuilder body = new StringBuilder();
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, "UTF-8"))
{
char[] buf = new char[4096];
int n;
while ((n = reader.read(buf)) > 0)
body.append(buf, 0, n);
}
float[][] parsed = parse(body.toString());
if (parsed == null)
{
forget();
return;
}
synchronized (_lock)
{
_values = parsed;
_fetchedAt = System.currentTimeMillis();
}
}
catch (Exception e)
{
// No VPN, no route, host off, refused, unreadable: one answer for all of them.
forget();
}
finally
{
if (connection != null)
connection.disconnect();
}
}
/** Parses the payload, or returns null when it cannot be trusted. Pure, therefore tested. */
static float[][] parse(String json)
{
if (json == null)
return null;
float[][] out = new float[SERIES.length][];
for (int i = 0; i < SERIES.length; i++)
{
int nameAt = json.indexOf("\"" + SERIES[i] + "\"");
if (nameAt < 0)
return null; // a series we cannot read is no data at all
int open = json.indexOf('[', nameAt);
int close = open < 0 ? -1 : json.indexOf(']', open);
if (open < 0 || close < 0)
return null;
String[] parts = json.substring(open + 1, close).split(",");
float[] values = new float[parts.length];
for (int j = 0; j < parts.length; j++)
{
try
{
values[j] = Float.parseFloat(parts[j].trim());
}
catch (NumberFormatException e)
{
return null;
}
}
if (values.length == 0)
return null;
out[i] = values;
}
return out;
}
}
+75
View File
@@ -0,0 +1,75 @@
package juloo.keyboard2;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertNotNull;
/** The ribbon must fail to nothing, never to a lie or a crash. */
public class HostGraphTest
{
private static final String GOOD = "{\"ts\": \"2026-09-27T18:30:32+00:00\", \"host\": \"utumno\", "
+ "\"version\": \"0.1.0\", \"window_s\": 6, \"series\": ["
+ "{\"name\": \"io\", \"unit\": \"%\", \"values\": [0.0, 0.0, 13.6, 2.9, 6.0]}, "
+ "{\"name\": \"cpu\", \"unit\": \"%\", \"values\": [0.0, 0.0, 0.1, 0.9, 0.2]}]}";
@Test
public void realPayloadParses()
{
float[][] parsed = HostGraph.parse(GOOD);
assertNotNull(parsed);
assertEquals(2, parsed.length);
assertEquals(5, parsed[0].length);
assertEquals(13.6f, parsed[0][2], 0.001f);
assertEquals(0.2f, parsed[1][4], 0.001f);
}
@Test
public void garbageIsNoData()
{
assertNull(HostGraph.parse("not json at all"));
}
@Test
public void nullIsNoData()
{
assertNull(HostGraph.parse(null));
}
@Test
public void aMissingSeriesIsNoData()
{
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [1.0]}]}"));
}
@Test
public void emptyValuesAreNoData()
{
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": []}, "
+ "{\"name\": \"cpu\", \"values\": [1.0]}]}"));
}
@Test
public void aJunkNumberIsNoData()
{
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [1.0, abc]}, "
+ "{\"name\": \"cpu\", \"values\": [1.0]}]}"));
}
@Test
public void aShortWindowStillParses()
{
float[][] parsed = HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [3.5]}, "
+ "{\"name\": \"cpu\", \"values\": [0.5]}]}");
assertNotNull(parsed);
assertEquals(1, parsed[0].length);
assertEquals(3.5f, parsed[0][0], 0.001f);
}
@Test
public void nothingIsDrawnBeforeAnyDataArrives()
{
assertEquals(0, HostGraph.series(0).length);
assertEquals(false, HostGraph.hasData());
}
}