diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 834c1085..b112149a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ sloth = { module = "dev.frozenmilk.sinister:Sloth", version.ref = "sloth" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } ivy = { module = "com.pedropathing.ivy:core", version.ref = "ivy" } blaze = { module = "dev.anygeneric:blazeftc", version.ref = "blaze"} +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } diff --git a/telemetry-ftc/Module.md b/telemetry-ftc/Module.md index c28151c9..e654a12a 100644 --- a/telemetry-ftc/Module.md +++ b/telemetry-ftc/Module.md @@ -20,6 +20,13 @@ FtcTelemetry.config.serverPort = 5810 Telemetry.log("Drive/Pose", pose) ``` +While the server is running, it also serves your logs. `/download` is a page that lists every +log with its OpMode name and start time, newest first. On a Control Hub that is +`http://192.168.43.1:5810/download`, and on a Robot Controller phone it is +`http://192.168.49.1:5810/download` over Wi-Fi Direct. `/logs` returns the same list as JSON, and +`/logs/` downloads one file. A log that is still being written is listed but can't be +downloaded until its OpMode stops. + Two utility OpModes, "Disable NextFTC Telemetry Server" and "Enable NextFTC Telemetry Server", turn the NetworkTables server off for competition and back on afterward. The choice is saved on the Robot Controller, so it holds after the app restarts. `FtcTelemetry.setServerEnabled` does the same from diff --git a/telemetry-ftc/build.gradle.kts b/telemetry-ftc/build.gradle.kts index 32699299..1fb0c698 100644 --- a/telemetry-ftc/build.gradle.kts +++ b/telemetry-ftc/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { } compileOnly(libs.bundles.ftc) implementation(libs.sloth) + implementation(libs.kotlinx.serialization.json) // The backend implements telemetry types that live in the SDK, so tests need it on their classpath testImplementation(libs.ftc.robot.core) { isTransitive = false } diff --git a/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/FtcTelemetry.kt b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/FtcTelemetry.kt index 1507ec76..94b49f83 100644 --- a/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/FtcTelemetry.kt +++ b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/FtcTelemetry.kt @@ -24,6 +24,8 @@ import dev.nextftc.telemetry.nt.NT4Backend import dev.nextftc.telemetry.nt.NT4Server import dev.nextftc.telemetry.wpilog.WPILogBackend import dev.nextftc.telemetry.wpilog.WPILogWriter +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.firstinspires.ftc.robotcore.internal.system.AppUtil import java.io.File import java.io.FileOutputStream @@ -99,6 +101,14 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications { private var eventLoop: FtcEventLoop? = null private var server: NT4Server? = null private var log: WPILogBackend? = null + + /** The file [log] writes to, which the download page won't serve until it is closed. */ + @Volatile + private var activeLogFile: File? = null + + /** Serves `/download`, `/logs` and `/logs/` on the server's port. */ + private val logDownloads = + LogDownloads(directory = { config.logDirectory }, activeFile = { activeLogFile }) private var live: NT4Backend? = null private var driverStation: DriverStationBackend? = null private var prefs: SharedPreferences? = null @@ -169,12 +179,16 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications { File(config.logDirectory, "${stamp}__${name.replace(Regex("[^A-Za-z0-9_.-]"), "_")}.wpilog") val writer = WPILogWriter( FileOutputStream(file), - extraHeader = """{"source":"NextFTC","opMode":"${name.replace("\"", "'")}"}""", + extraHeader = buildJsonObject { + put("source", "NextFTC") + put("opMode", name) + }.toString(), timeSource = registry.timeSource, origin = registry.origin, ) WPILogBackend(writer).also { log = it + activeLogFile = file lastFlush = registry.timeSource.markNow() registry.register(it) } @@ -189,6 +203,7 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications { config.serverPort, timeSource = registry.timeSource, origin = registry.origin, + routes = { logDownloads.install(this) }, ).also { it.start() server = it @@ -270,6 +285,7 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications { } } log = null + activeLogFile = null live = null driverStation = null } diff --git a/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/LogDownloads.kt b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/LogDownloads.kt new file mode 100644 index 00000000..03d97666 --- /dev/null +++ b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/LogDownloads.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 NextFTC Team + * + * Use of this source code is governed by an BSD-3-clause + * license that can be found in the LICENSE.md file at the root of this repository or at + * https://opensource.org/license/bsd-3-clause. + */ + +package dev.nextftc.telemetry.ftc + +import io.ktor.http.CacheControl +import io.ktor.http.ContentDisposition +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.http.content.LocalFileContent +import io.ktor.server.http.content.staticResources +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import java.io.DataInputStream +import java.io.File +import java.io.IOException +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Routes that serve the WPILOGs [FtcTelemetry] wrote over HTTP, on the NetworkTables server's port: + * + * - `/download` is a page that lists them, newest first, with a link for each. It is the static + * files in [PAGE_RESOURCES], and a script in it reads `/logs`. + * - `/logs` is the list as JSON. + * - `/logs/` downloads one. + * + * Only `.wpilog` files directly inside the directory can be reached, and a log that is still being + * written is listed but can't be downloaded, since the file would be cut off. + * + * @param directory Where the logs are, read on every request so a change to the setting applies + * @param activeFile The log being written right now, if any + */ +internal class LogDownloads(private val directory: () -> File, private val activeFile: () -> File?) { + internal class Entry( + val file: File, + /** The OpMode's name, from the file's header or else its name. */ + val opMode: String, + /** When the OpMode was initialized, in the Robot Controller's time zone, or null if the name has no stamp. */ + val started: Date?, + val sizeBytes: Long, + val modifiedMillis: Long, + val recording: Boolean, + ) { + val name: String get() = file.name + } + + fun install(routing: Routing) = with(routing) { + staticResources(PAGE_PATH, PAGE_RESOURCES) { + cacheControl { listOf(CacheControl.NoCache(null)) } + } + + get("/logs") { + call.respondText(withContext(Dispatchers.IO) { json(entries()) }, ContentType.Application.Json) + } + + get("/logs/{name}") { + val name = call.parameters["name"] + val entry = withContext(Dispatchers.IO) { entries().firstOrNull { it.name == name } } + when { + entry == null -> call.respondText("Not found", status = HttpStatusCode.NotFound) + entry.recording -> call.respondText( + "That log is still being written. Stop the OpMode first.", + status = HttpStatusCode.Conflict, + ) + else -> { + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter( + ContentDisposition.Parameters.FileName, + entry.name, + ).toString(), + ) + call.respond(LocalFileContent(entry.file, ContentType.Application.OctetStream)) + } + } + } + } + + /** Every log in the directory, newest first. */ + internal fun entries(): List { + val active = activeFile()?.let { runCatching { it.canonicalFile }.getOrNull() } + val files = directory().listFiles { f -> f.isFile && f.name.endsWith(EXTENSION) } ?: return emptyList() + return files + .map { file -> + val parsed = NAME.matchEntire(file.name) + val started = parsed?.let { parseStamp(it.groupValues[1]) } + Entry( + file = file, + opMode = + readOpMode(file) ?: parsed?.groupValues?.get(2)?.replace('_', ' ') + ?: file.name.removeSuffix(EXTENSION), + started = started, + sizeBytes = file.length(), + modifiedMillis = file.lastModified(), + recording = active != null && runCatching { file.canonicalFile }.getOrNull() == active, + ) + } + .sortedWith( + compareByDescending { + it.started?.time ?: it.modifiedMillis + }.thenByDescending { it.name }, + ) + } + + private fun json(entries: List): String { + val shown = SimpleDateFormat("EEE MMM d, yyyy h:mm:ss a", Locale.US) + return buildJsonArray { + for (entry in entries) { + addJsonObject { + put("name", entry.name) + put("opMode", entry.opMode) + put("started", entry.started?.time) + put("startedText", shown.format(entry.started ?: Date(entry.modifiedMillis))) + put("sizeBytes", entry.sizeBytes) + put("modified", entry.modifiedMillis) + put("recording", entry.recording) + } + } + } + .toString() + } + + private companion object { + const val EXTENSION = ".wpilog" + + /** Where the page is served. */ + const val PAGE_PATH = "/download" + + /** + * Where the page is located in the resources directory. + */ + const val PAGE_RESOURCES = "dev/nextftc/telemetry/ftc/download" + + /** The name [FtcTelemetry] gives a log: the stamp, then the sanitized OpMode name. */ + val NAME = Regex("""(\d{4}_\d{2}_\d{2}__\d{2}_\d{2}_\d{2})__(.*)\.wpilog""") + + fun parseStamp(stamp: String): Date? = try { + SimpleDateFormat("yyyy_MM_dd__HH_mm_ss", Locale.US).apply { isLenient = false }.parse(stamp) + } catch (e: ParseException) { + null + } + + /** Reads the OpMode's real name from the header [FtcTelemetry] wrote, since the file name is sanitized. */ + fun readOpMode(file: File): String? = try { + DataInputStream(file.inputStream().buffered()).use { input -> + // "WPILOG", a 2-byte version, a 4-byte little-endian length, then the extra header text + val fixed = ByteArray(12) + input.readFully(fixed) + if (String(fixed, 0, 6, Charsets.US_ASCII) != "WPILOG") return null + val length = (fixed[8].toInt() and 0xFF) or ((fixed[9].toInt() and 0xFF) shl 8) or + ((fixed[10].toInt() and 0xFF) shl 16) or ((fixed[11].toInt() and 0xFF) shl 24) + if (length !in 1..4096) return null + val text = ByteArray(length).also { input.readFully(it) }.toString(Charsets.UTF_8) + val header = try { + Json.parseToJsonElement(text) as? JsonObject + } catch (e: SerializationException) { + null + } + (header?.get("opMode") as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + } + } catch (e: IOException) { + null + } + } +} diff --git a/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/index.html b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/index.html new file mode 100644 index 00000000..631efad3 --- /dev/null +++ b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/index.html @@ -0,0 +1,35 @@ + + + + + +NextFTC Logs + + + + +
+ + + + + + + + + + + +

Logs

+

Loading…

+
+ + + + + +
+
+ + + diff --git a/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.css b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.css new file mode 100644 index 00000000..babe605a --- /dev/null +++ b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.css @@ -0,0 +1,69 @@ +/* The colors, and the card and button shapes, follow the NextFTC docs (starlight-theme-galaxy). */ +:root { + color-scheme: dark; + --accent-low: #113145; + --accent: #0099ff; + --accent-high: #b0dbf7; + --text: #ffffff; + --text-2: #e8eff6; + --muted: #7d8e9e; + --bg: #17181c; + --hairline: #212929; + --card-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%); + --card-border: rgba(255, 255, 255, 0.12); + --card-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.1); + --row-hover: rgba(255, 255, 255, 0.04); + --font: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-title: "Science Gothic", var(--font); + --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace; +} +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --accent-low: #c6e6fa; + --accent-high: #0b5a8e; + --text: #0c1014; + --text-2: #1b2835; + --muted: #4b5a69; + --bg: #fbfcfc; + --hairline: #edeef3; + --card-bg: linear-gradient(135deg, rgba(0, 0, 0, 0.025) 0%, rgba(0, 0, 0, 0.015) 100%); + --card-border: rgba(0, 0, 0, 0.08); + --card-shadow: 0 4px 16px rgba(0, 0, 0, 0.03), 0 1px 4px rgba(0, 0, 0, 0.02), inset 0 1px 0 rgba(255, 255, 255, 0.8); + --row-hover: rgba(0, 0, 0, 0.03); + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text-2); font: 16px/1.65 var(--font); } +main { max-width: 60rem; margin: 0 auto; padding: 2.5rem 1rem 4rem; } +.wordmark { display: block; height: 1.75rem; width: auto; margin-bottom: 2rem; } +.wordmark .ink { fill: var(--text); } +.wordmark .blue { fill: var(--accent); } +h1 { font: 700 2rem/1.2 var(--font-title); color: var(--text); margin: 0 0 .5rem; } +h1::after { content: ""; display: block; width: 3rem; height: 3px; margin-top: .6rem; border-radius: 2px; + background: linear-gradient(90deg, var(--accent), transparent); } +p.sub { margin: 1rem 0 1.75rem; color: var(--muted); } +.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 1rem; + box-shadow: var(--card-shadow); overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: .8rem 1.25rem; border-bottom: 1px solid var(--hairline); white-space: nowrap; } +tbody tr { transition: background .2s ease; } +tbody tr:hover { background: var(--row-hover); } +tbody tr:last-child td { border-bottom: 0; } +th { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +td:first-child { white-space: normal; min-width: 14rem; } +.opmode { display: block; color: var(--text); font-weight: 500; } +.file { display: block; color: var(--muted); font: .75rem/1.4 var(--font-mono); word-break: break-all; } +td:last-child { text-align: right; } +.num { text-align: right; font-variant-numeric: tabular-nums; } +a.button { display: inline-block; padding: .35rem 1rem; border-radius: .75rem; color: #fff; font-weight: 600; + font-size: .875rem; text-decoration: none; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-high) 100%); + box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 25%, transparent); + transition: transform .3s ease, box-shadow .3s ease; } +a.button:hover { transform: translateY(-2px); box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 40%, transparent); } +a.button:focus-visible { outline: 2px solid var(--accent-high); outline-offset: 2px; } +.badge { display: inline-block; padding: .15rem .7rem; border-radius: 999px; font-size: .8rem; font-weight: 600; + color: var(--accent-high); background: var(--accent-low); } +.message { padding: 3rem 1rem; text-align: center; color: var(--muted); } +@media (max-width: 40rem) { th, td { padding: .7rem .8rem; } main { padding-top: 1.5rem; } } diff --git a/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.js b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.js new file mode 100644 index 00000000..2fab0f52 --- /dev/null +++ b/telemetry-ftc/src/main/resources/dev/nextftc/telemetry/ftc/download/logs.js @@ -0,0 +1,81 @@ +const summary = document.getElementById("summary"); +const message = document.getElementById("message"); +const table = document.getElementById("table"); +const rows = document.getElementById("rows"); + +function formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; +} + +function cell(text, className) { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + return td; +} + +function nameCell(log) { + const td = document.createElement("td"); + const opMode = document.createElement("span"); + opMode.className = "opmode"; + opMode.textContent = log.opMode; + const file = document.createElement("span"); + file.className = "file"; + file.textContent = log.name; + td.append(opMode, file); + return td; +} + +function actionCell(log) { + const td = document.createElement("td"); + if (log.recording) { + const badge = document.createElement("span"); + badge.className = "badge"; + badge.textContent = "Recording"; + td.appendChild(badge); + } else { + const link = document.createElement("a"); + link.className = "button"; + link.href = "/logs/" + encodeURIComponent(log.name); + link.download = log.name; + link.textContent = "Download"; + td.appendChild(link); + } + return td; +} + +function render(logs) { + rows.replaceChildren(...logs.map((log) => { + const tr = document.createElement("tr"); + tr.append( + nameCell(log), + cell(log.startedText), + cell(formatSize(log.sizeBytes), "num"), + actionCell(log), + ); + return tr; + })); + summary.textContent = logs.length + (logs.length === 1 ? " log" : " logs"); + table.hidden = logs.length === 0; + message.hidden = logs.length !== 0; + message.textContent = "No logs yet"; +} + +async function refresh() { + try { + const response = await fetch("/logs", { cache: "no-store" }); + if (!response.ok) throw new Error("HTTP " + response.status); + render(await response.json()); + } catch (error) { + summary.textContent = "Unable to load logs"; + table.hidden = true; + message.hidden = false; + message.textContent = String(error); + } +} + +refresh(); +// So a log that was recording turns into a download once its OpMode stops +setInterval(refresh, 5000); diff --git a/telemetry-ftc/src/test/kotlin/dev/nextftc/telemetry/ftc/LogDownloadsTest.kt b/telemetry-ftc/src/test/kotlin/dev/nextftc/telemetry/ftc/LogDownloadsTest.kt new file mode 100644 index 00000000..3a757029 --- /dev/null +++ b/telemetry-ftc/src/test/kotlin/dev/nextftc/telemetry/ftc/LogDownloadsTest.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026 NextFTC Team + * + * Use of this source code is governed by an BSD-3-clause + * license that can be found in the LICENSE.md file at the root of this repository or at + * https://opensource.org/license/bsd-3-clause. + */ + +package dev.nextftc.telemetry.ftc + +import dev.nextftc.telemetry.nt.NT4Server +import dev.nextftc.telemetry.wpilog.WPILogWriter +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Files +import java.text.SimpleDateFormat +import java.util.Locale + +private class HttpResult( + val code: Int, + val contentType: String?, + val headers: Map, + val bytes: ByteArray, +) { + val text: String get() = String(bytes, Charsets.UTF_8) +} + +/** Requests to a running [NT4Server], over real HTTP. */ +private class Client(private val server: NT4Server) { + fun get(path: String): HttpResult { + val connection = URL("http://127.0.0.1:${server.boundPort}$path").openConnection() as HttpURLConnection + val code = connection.responseCode + val body = + (if (code < 400) connection.inputStream else connection.errorStream)?.readBytes() ?: ByteArray(0) + val headers = listOf("Cache-Control", "Content-Disposition").associateWith { + connection.getHeaderField(it) + } + return HttpResult(code, connection.contentType, headers, body) + } + + fun logs(): List = Json.parseToJsonElement(get("/logs").text).jsonArray.map { it.jsonObject } +} + +class LogDownloadsTest : + FunSpec({ + fun withDirectory(block: (File) -> Unit) { + val dir = Files.createTempDirectory("logs").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + /** Serves [dir] the way FtcTelemetry does, with [active] as the log being written. */ + fun withServer(dir: File, active: File? = null, block: (Client) -> Unit) { + val logDownloads = LogDownloads({ dir }, { active }) + val server = NT4Server(port = 0, host = "127.0.0.1", routes = { logDownloads.install(this) }) + server.start() + try { + block(Client(server)) + } finally { + server.stop() + } + } + + /** A real log, with the header [FtcTelemetry] writes. */ + fun writeLog(dir: File, name: String, opMode: String): File { + val file = File(dir, name) + val header = buildJsonObject { + put("source", "NextFTC") + put("opMode", opMode) + } + WPILogWriter(FileOutputStream(file), extraHeader = header.toString()).close() + return file + } + + test("lists logs newest first with the OpMode name from the header") { + withDirectory { dir -> + writeLog(dir, "2026_11_05__10_00_00__Old_Auto.wpilog", "Old Auto") + writeLog(dir, "2026_11_06__09_30_15__Red_Teleop.wpilog", "Red Teleop") + File(dir, "notes.txt").writeText("not a log") + + withServer(dir) { client -> + val response = client.get("/logs") + + response.code shouldBe 200 + response.contentType shouldContain "application/json" + response.text shouldNotContain "notes.txt" + client.logs().map { it["opMode"]!!.jsonPrimitive.content } shouldBe + listOf("Red Teleop", "Old Auto") + } + } + } + + test("falls back to the file name when the header has no OpMode") { + withDirectory { dir -> + File(dir, "2026_11_06__09_30_15__Some_OpMode.wpilog").writeText("garbage") + + val entries = LogDownloads({ dir }, { null }).entries() + + entries.single().opMode shouldBe "Some OpMode" + (entries.single().started != null) shouldBe true + } + } + + test("the JSON has what the page shows, with the OpMode name left as it is") { + withDirectory { dir -> + writeLog(dir, "2026_11_06__09_30_15__x.wpilog", "Evil & co") + + withServer(dir) { client -> + val log = client.logs().single() + + // The page sets it as text, never as HTML, so it isn't escaped here + log["opMode"]?.jsonPrimitive?.content shouldBe "Evil & co" + log["name"]?.jsonPrimitive?.content shouldBe "2026_11_06__09_30_15__x.wpilog" + log["startedText"]?.jsonPrimitive?.content shouldBe "Fri Nov 6, 2026 9:30:15 AM" + log["started"]?.jsonPrimitive?.long shouldBe + SimpleDateFormat("yyyy_MM_dd__HH_mm_ss", Locale.US).parse("2026_11_06__09_30_15")!!.time + log["recording"]?.jsonPrimitive?.boolean shouldBe false + (log["sizeBytes"]?.jsonPrimitive?.long ?: 0) shouldBeGreaterThan 0L + log.keys shouldBe + setOf("name", "opMode", "started", "startedText", "sizeBytes", "modified", "recording") + } + } + } + + test("OpMode names with quotes and backslashes survive the log header and the JSON") { + withDirectory { dir -> + val tricky = "He said \"hi\" \\ back\nnew line" + writeLog(dir, "2026_11_06__09_30_15__x.wpilog", tricky) + + withServer(dir) { client -> + client.logs().single()["opMode"]?.jsonPrimitive?.content shouldBe tricky + } + } + } + + test("a log without a stamp in its name is listed with a null start") { + withDirectory { dir -> + File(dir, "custom.wpilog").writeText("garbage") + + withServer(dir) { client -> + val log = client.logs().single() + + log["started"] shouldBe JsonNull + log["opMode"]?.jsonPrimitive?.content shouldBe "custom" + } + } + } + + test("an empty or missing directory is an empty list") { + val missing = File(Files.createTempDirectory("logs").toFile(), "nope") + + withServer(missing) { client -> client.get("/logs").text shouldBe "[]" } + } + + test("downloads a log by name") { + withDirectory { dir -> + val file = writeLog(dir, "2026_11_06__09_30_15__x.wpilog", "x") + + withServer(dir) { client -> + val response = client.get("/logs/${file.name}") + + response.code shouldBe 200 + response.bytes.toList() shouldBe file.readBytes().toList() + response.headers["Content-Disposition"] shouldContain "attachment" + response.headers["Content-Disposition"] shouldContain file.name + } + } + } + + test("won't serve anything outside the listed logs") { + withDirectory { dir -> + val outside = File(dir.parentFile, "secret-${dir.name}.wpilog").apply { writeText("secret") } + try { + writeLog(dir, "2026_11_06__09_30_15__x.wpilog", "x") + File(dir, "other.txt").writeText("text") + + withServer(dir) { client -> + for (path in listOf( + "/logs/../${outside.name}", + "/logs/..%2F${outside.name}", + "/logs/${outside.absolutePath}", + "/logs/other.txt", + "/logs/missing.wpilog", + )) { + client.get(path).code shouldBe 404 + } + } + } finally { + outside.delete() + } + } + } + + test("a log still being written is listed but refused") { + withDirectory { dir -> + val active = writeLog(dir, "2026_11_06__09_30_15__x.wpilog", "x") + + withServer(dir, active) { client -> + client.get("/logs/${active.name}").code shouldBe 409 + client.logs().single()["recording"]?.jsonPrimitive?.boolean shouldBe true + } + } + } + + test("serves the page and its files as they are") { + withDirectory { dir -> + withServer(dir) { client -> + for (path in listOf("/download", "/download/", "/download/index.html")) { + val page = client.get(path) + page.code shouldBe 200 + page.contentType shouldContain "text/html" + page.text shouldContain "href=\"/download/logs.css\"" + page.text shouldContain "src=\"/download/logs.js\"" + page.text shouldNotContain "