Add a log viewer to Spinner.

This is more of a proof-of-concept/demo for using a websocket with
Spinner. Gives an example of how we could push live updates to the
webapp.

Also, the logger is actually nice. Guaranteed to never get cluttered
with system logs. Looks basically identical to our other log viewers.
Filtering is basic but fast. And we could build much better tooling on
top of this.
This commit is contained in:
Greyson Parrelli 2023-09-25 09:24:42 -04:00 committed by Cody Henthorne
parent c314918c6b
commit 6a974c48ef
16 changed files with 647 additions and 15 deletions

View file

@ -26,9 +26,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
} }
override fun initializeLogging() { override fun initializeLogging() {
persistentLogger = PersistentLogger(this) Log.initialize({ true }, AndroidLogger(), PersistentLogger(this), inMemoryLogger)
Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger()) SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())

View file

@ -124,9 +124,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private static final String TAG = Log.tag(ApplicationContext.class); private static final String TAG = Log.tag(ApplicationContext.class);
@VisibleForTesting
protected PersistentLogger persistentLogger;
public static ApplicationContext getInstance(Context context) { public static ApplicationContext getInstance(Context context) {
return (ApplicationContext)context.getApplicationContext(); return (ApplicationContext)context.getApplicationContext();
} }
@ -265,10 +262,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
MemoryTracker.stop(); MemoryTracker.stop();
} }
public PersistentLogger getPersistentLogger() {
return persistentLogger;
}
public void checkBuildExpiration() { public void checkBuildExpiration() {
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) { if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
Log.w(TAG, "Build expired!"); Log.w(TAG, "Build expired!");
@ -295,8 +288,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
@VisibleForTesting @VisibleForTesting
protected void initializeLogging() { protected void initializeLogging() {
persistentLogger = new PersistentLogger(this); org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this));
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger()); SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());

View file

@ -2,8 +2,11 @@ package org.thoughtcrime.securesms
import android.content.ContentValues import android.content.ContentValues
import android.os.Build import android.os.Build
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.spinner.Spinner import org.signal.spinner.Spinner
import org.signal.spinner.Spinner.DatabaseConfig import org.signal.spinner.Spinner.DatabaseConfig
import org.signal.spinner.SpinnerLogger
import org.thoughtcrime.securesms.database.DatabaseMonitor import org.thoughtcrime.securesms.database.DatabaseMonitor
import org.thoughtcrime.securesms.database.GV2Transformer import org.thoughtcrime.securesms.database.GV2Transformer
import org.thoughtcrime.securesms.database.GV2UpdateTransformer import org.thoughtcrime.securesms.database.GV2UpdateTransformer
@ -22,8 +25,10 @@ import org.thoughtcrime.securesms.database.RecipientTransformer
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TimestampTransformer import org.thoughtcrime.securesms.database.TimestampTransformer
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.AppSignatureUtil import org.thoughtcrime.securesms.util.AppSignatureUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import java.util.Locale import java.util.Locale
class SpinnerApplicationContext : ApplicationContext() { class SpinnerApplicationContext : ApplicationContext() {
@ -69,6 +74,8 @@ class SpinnerApplicationContext : ApplicationContext() {
) )
) )
Log.initialize({ FeatureFlags.internalUser() }, AndroidLogger(), PersistentLogger(this), SpinnerLogger())
DatabaseMonitor.initialize(object : QueryMonitor { DatabaseMonitor.initialize(object : QueryMonitor {
override fun onSql(sql: String, args: Array<Any>?) { override fun onSql(sql: String, args: Array<Any>?) {
Spinner.onSql("signal", sql, args) Spinner.onSql("signal", sql, args)

View file

@ -19,6 +19,7 @@ dependencyResolutionManagement {
version('mp4parser', '1.9.39') version('mp4parser', '1.9.39')
version('android-gradle-plugin', '8.0.2') version('android-gradle-plugin', '8.0.2')
version('accompanist', '0.28.0') version('accompanist', '0.28.0')
version('nanohttpd', '2.3.1')
// Android Plugins // Android Plugins
library('android-library', 'com.android.library', 'com.android.library.gradle.plugin').versionRef('android-gradle-plugin') library('android-library', 'com.android.library', 'com.android.library.gradle.plugin').versionRef('android-gradle-plugin')
@ -144,7 +145,8 @@ dependencyResolutionManagement {
library('stream', 'com.annimon:stream:1.1.8') library('stream', 'com.annimon:stream:1.1.8')
library('lottie', 'com.airbnb.android:lottie:5.2.0') library('lottie', 'com.airbnb.android:lottie:5.2.0')
library('dnsjava', 'dnsjava:dnsjava:2.1.9') library('dnsjava', 'dnsjava:dnsjava:2.1.9')
library('nanohttpd-webserver', 'org.nanohttpd:nanohttpd-webserver:2.3.1') library('nanohttpd-webserver', 'org.nanohttpd', 'nanohttpd-webserver').versionRef('nanohttpd')
library('nanohttpd-websocket', 'org.nanohttpd', 'nanohttpd-websocket').versionRef('nanohttpd')
library('kotlinx-collections-immutable', 'org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5') library('kotlinx-collections-immutable', 'org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5')
// Can't use the newest version because it hits some weird NoClassDefFoundException // Can't use the newest version because it hits some weird NoClassDefFoundException

View file

@ -5135,6 +5135,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="c2a094648a63d55a9577934c85a79e5ea28b8b138b4915d3494c75aa23ca0ab9" origin="Generated by Gradle"/> <sha256 value="c2a094648a63d55a9577934c85a79e5ea28b8b138b4915d3494c75aa23ca0ab9" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="org.nanohttpd" name="nanohttpd-websocket" version="2.3.1">
<artifact name="nanohttpd-websocket-2.3.1.jar">
<sha256 value="8f81a852052b62dee7bb85c232e367369c3ac25fbc59fb62151e56a31aa3bb8a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.objenesis" name="objenesis" version="2.6"> <component group="org.objenesis" name="objenesis" version="2.6">
<artifact name="objenesis-2.6.jar"> <artifact name="objenesis-2.6.jar">
<sha256 value="5e168368fbc250af3c79aa5fef0c3467a2d64e5a7bd74005f25d8399aeb0708d" origin="Generated by Gradle"/> <sha256 value="5e168368fbc250af3c79aa5fef0c3467a2d64e5a7bd74005f25d8399aeb0708d" origin="Generated by Gradle"/>

View file

@ -3,10 +3,16 @@ package org.signal.spinnertest
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.spinner.Spinner import org.signal.spinner.Spinner
import org.signal.spinner.SpinnerLogger
import java.util.UUID import java.util.UUID
import kotlin.random.Random
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -26,6 +32,25 @@ class MainActivity : AppCompatActivity() {
mapOf("main" to Spinner.DatabaseConfig(db = { db })), mapOf("main" to Spinner.DatabaseConfig(db = { db })),
emptyMap() emptyMap()
) )
Log.initialize(AndroidLogger(), SpinnerLogger())
object : Thread() {
override fun run() {
while (true) {
when (Random.nextInt(0, 5)) {
0 -> Log.v("MyTag", "Message: ${System.currentTimeMillis()}")
1 -> Log.d("MyTag", "Message: ${System.currentTimeMillis()}")
2 -> Log.i("MyTag", "Message: ${System.currentTimeMillis()}")
3 -> Log.w("MyTag", "Message: ${System.currentTimeMillis()}")
4 -> Log.e("MyTag", "Message: ${System.currentTimeMillis()}")
}
ThreadUtil.sleep(Random.nextLong(0, 200))
}
}
}.start()
findViewById<Button>(R.id.log_throwable_button).setOnClickListener { Log.e("MyTag", "Message: ${System.currentTimeMillis()}", Throwable()) }
} }
private fun insertMockData(db: SQLiteDatabase) { private fun insertMockData(db: SQLiteDatabase) {

View file

@ -27,4 +27,10 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:text="Then go to localhost:5000 in your browser." /> android:text="Then go to localhost:5000 in your browser." />
<Button
android:id="@+id/log_throwable_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Log Throwable" />
</LinearLayout> </LinearLayout>

View file

@ -11,6 +11,7 @@ dependencies {
implementation libs.jknack.handlebars implementation libs.jknack.handlebars
implementation libs.nanohttpd.webserver implementation libs.nanohttpd.webserver
implementation libs.nanohttpd.websocket
implementation libs.androidx.sqlite implementation libs.androidx.sqlite
testImplementation testLibs.junit.junit testImplementation testLibs.junit.junit

View file

@ -5,7 +5,7 @@ html, body {
width: 100%; width: 100%;
} }
select, input { select, input, button {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
font-variant-ligatures: none; font-variant-ligatures: none;
font-size: 1rem; font-size: 1rem;

View file

@ -0,0 +1,290 @@
<html>
{{> partials/head title="Home" }}
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" rel="stylesheet">
<style type="text/css">
html, body {
overflow: hidden;
}
h1.collapse-header {
font-size: 1.35rem;
}
h2.collapse-header {
font-size: 1.15rem;
}
#log-container {
width: calc(100% - 32px);
height: calc(100% - 325px);
overflow-y: scroll;
overflow-x: auto;
border: 1px solid black;
resize: vertical;
white-space: pre;
font-family: 'JetBrains Mono', monospace;
background-color: #2b2b2b;
margin-top: 8px;
border-radius: 5px;
border: 1px solid black;
padding: 8px;
}
#logs {
width: 100%;
}
.log-verbose {
color: #8a8a8a;
}
.log-debug {
color: #5ca72b;
}
.log-info{
color: #46bbb9;
}
.log-warn{
color: #d6cb37;
}
.log-error{
color: #ff6b68;
}
#follow-button.enabled {
opacity: 0.5;
}
#toolbar {
width: calc(100% - 14px);
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
}
#toolbar #filter-text {
flex-grow: 1;
margin-left: 8px;
}
#socket-status {
width: 16px;
height: 16px;
border-radius: 16px;
margin: 3px 0 3px 3px;
border: 1px solid black;
}
#socket-status.connected {
background-color: #5ca72b;
}
#socket-status.connecting {
background-color: #d6cb37;
}
#socket-status.disconnected {
background-color: #cc0000;
}
</style>
<body>
{{> partials/prefix isLogs=true}}
<div id="toolbar">
<button onclick="onFollowClicked()" id="follow-button">Follow</button>
<input type="text" id="filter-text" placeholder="Filter..." />
<div id="socket-status"></div>
</div>
<div id="log-container"></div>
{{> partials/suffix }}
<script>
const logs = []
const logTable = document.getElementById('logs')
const logContainer = document.getElementById('log-container')
const followButton = document.getElementById('follow-button')
const filterText = document.getElementById('filter-text')
const statusOrb = document.getElementById('socket-status')
let followLogs = false
let programaticScroll = false
let filter = null
function main() {
initWebSocket()
setFollowState(true)
logContainer.innerHTML = ''
filterText.addEventListener('input', onFilterChanged)
logContainer.addEventListener('scroll', () => {
if (programaticScroll) {
programaticScroll = false
} else {
setFollowState(false)
}
})
}
function onFollowClicked() {
setFollowState(true)
scrollToBottom()
}
function onFilterChanged(event) {
filter = event.target.value
logContainer.innerHTML = ''
logs
.filter(it => logMatches(it, filter))
.map(it => logToDiv(it))
.forEach(it => logContainer.appendChild(it))
setFollowState(true)
scrollToBottom()
}
function initWebSocket() {
const websocket = new WebSocket(`ws://${window.location.host}/logs/websocket`)
let keepAliveTimer = null
statusOrb.className = 'connecting'
websocket.onopen = () => {
console.log("[open] Connection established");
console.log("Sending to server");
statusOrb.className = 'connected'
keepAliveTimer = setInterval(() => websocket.send('keepalive'), 1000)
}
websocket.onclose = () => {
console.log('[close] Closed!')
statusOrb.className = 'disconnected'
if (keepAliveTimer != null) {
clearInterval(keepAliveTimer)
keepAliveTimer = null
}
setTimeout(() => initWebSocket(), 1000)
}
websocket.onmessage = onWebSocketMessage
}
function onWebSocketMessage(event) {
const log = JSON.parse(event.data)
logs.push(log)
if (logs.length > 5_000) {
logs.shift()
}
if (filter == null || logMatches(log, filter)) {
logContainer.appendChild(logToDiv(log))
}
if (followLogs) {
scrollToBottom()
}
}
function logToDiv(log) {
const div = document.createElement('div')
const linePrefix = `[${log.thread}] ${log.time} ${log.tag} ${log.level} `
let stackTraceString = log.stackTrace
if (stackTraceString != null) {
stackTraceString = ' \n' + stackTraceString
}
let textContent = `${linePrefix}${log.message || ''}${stackTraceString || ''}`
textContent = indentOverflowLines(textContent, linePrefix.length)
div.textContent = textContent
div.classList.add(levelToClass(log.level))
return div
}
function levelToClass(level) {
switch (level) {
case 'V': return 'log-verbose'
case 'D': return 'log-debug'
case 'I': return 'log-info'
case 'W': return 'log-warn'
case 'E': return 'log-error'
default: return ''
}
}
function setFollowState(value) {
if (followLogs === value) {
return
}
followLogs = value
if (followLogs) {
followButton.classList.add('enabled')
followButton.disabled = true
} else {
followButton.classList.remove('enabled')
followButton.disabled = false
}
}
function scrollToBottom() {
programaticScroll = true
logContainer.scrollTop = logContainer.scrollHeight
}
function indentOverflowLines(text, indent) {
const lines = text.split('\n')
if (lines.length > 1) {
const spaces = ' '.repeat(indent)
const overflow = lines.slice(1)
const indented = overflow.map(it => spaces + it).join('\n')
return lines[0] + '\n' + indented
} else {
return text
}
}
function logMatches(log, filter) {
if (log.tag != null && log.tag.indexOf(filter) >= 0) {
return true
}
if (log.message != null && log.message.indexOf(filter) >= 0) {
return true
}
if (log.stackTrace != null && log.stackTrace.indexOf(filter) >= 0) {
return true
}
return false
}
main()
</script>
</body>
</html>

View file

@ -23,6 +23,7 @@
<li {{#if isBrowse}}class="active"{{/if}}><a href="/browse?db={{database}}">Browse</a></li> <li {{#if isBrowse}}class="active"{{/if}}><a href="/browse?db={{database}}">Browse</a></li>
<li {{#if isQuery}}class="active"{{/if}}><a href="/query?db={{database}}">Query</a></li> <li {{#if isQuery}}class="active"{{/if}}><a href="/query?db={{database}}">Query</a></li>
<li {{#if isRecent}}class="active"{{/if}}><a href="/recent?db={{database}}">Recent</a></li> <li {{#if isRecent}}class="active"{{/if}}><a href="/recent?db={{database}}">Recent</a></li>
<li {{#if isLogs}}class="active"{{/if}}><a href="/logs?db={{database}}">Logs</a></li>
{{#each plugins}} {{#each plugins}}
<li {{#if (eq name activePlugin.name)}}class="active"{{/if}}><a href="{{path}}">{{name}}</a></li> <li {{#if (eq name activePlugin.name)}}class="active"{{/if}}><a href="{{path}}">{{name}}</a></li>
{{/each}} {{/each}}

View file

@ -68,6 +68,10 @@ object Spinner {
server.onSql(dbName, queryString) server.onSql(dbName, queryString)
} }
internal fun log(item: SpinnerLogItem) {
server.onLog(item)
}
private fun replaceQueryArgs(query: String, args: Array<Any>?): String { private fun replaceQueryArgs(query: String, args: Array<Any>?): String {
if (args == null) { if (args == null) {
return query return query

View file

@ -0,0 +1,55 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.spinner
import android.util.Log
import org.json.JSONObject
import org.signal.core.util.ExceptionUtil
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
internal data class SpinnerLogItem(
val level: Int,
val time: Long,
val thread: String,
val tag: String,
val message: String?,
val throwable: Throwable?
) {
fun serialize(): String {
val stackTrace: String? = throwable?.let { ExceptionUtil.convertThrowableToString(throwable) }
val formattedTime = dateFormat.format(Date(time))
val paddedTag: String = when {
tag.length > 23 -> tag.substring(0, 23)
tag.length < 23 -> tag.padEnd(23)
else -> tag
}
val levelString = when (level) {
Log.VERBOSE -> "V"
Log.DEBUG -> "D"
Log.INFO -> "I"
Log.WARN -> "W"
Log.ERROR -> "E"
else -> "?"
}
val out = JSONObject()
out.put("level", levelString)
out.put("time", formattedTime)
out.put("thread", thread)
out.put("tag", paddedTag)
message?.let { out.put("message", it) }
stackTrace?.let { out.put("stackTrace", it) }
return out.toString(0)
}
companion object {
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.spinner
import android.annotation.SuppressLint
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import fi.iki.elonen.NanoWSD.WebSocket
import java.io.IOException
import java.util.LinkedList
import java.util.Queue
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@SuppressLint("LogNotSignal")
internal class SpinnerLogWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : WebSocket(handshakeRequest) {
companion object {
private val TAG = "SpinnerLogWebSocket"
private const val MAX_LOGS = 5_000
private val logs: Queue<SpinnerLogItem> = LinkedList()
private val openSockets: MutableList<SpinnerLogWebSocket> = mutableListOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val logThread: LogThread = LogThread().also { it.start() }
fun onLog(item: SpinnerLogItem) {
lock.withLock {
logs += item
if (logs.size > MAX_LOGS) {
logs.remove()
}
condition.signal()
}
}
}
override fun onOpen() {
Log.d(TAG, "onOpen()")
lock.withLock {
openSockets += this
condition.signal()
}
}
override fun onClose(code: NanoWSD.WebSocketFrame.CloseCode, reason: String?, initiatedByRemote: Boolean) {
Log.d(TAG, "onClose()")
lock.withLock {
openSockets -= this
}
}
override fun onMessage(message: NanoWSD.WebSocketFrame) {
Log.d(TAG, "onMessage()")
}
override fun onPong(pong: NanoWSD.WebSocketFrame) {
Log.d(TAG, "onPong()")
}
override fun onException(exception: IOException) {
Log.d(TAG, "onException()", exception)
}
private class LogThread : Thread("SpinnerLog") {
override fun run() {
while (true) {
val (sockets, log) = lock.withLock {
while (logs.isEmpty() || openSockets.isEmpty()) {
condition.await()
}
openSockets.toList() to logs.remove()
}
sockets.forEach { socket ->
try {
socket.send(log.serialize())
} catch (e: IOException) {
Log.w(TAG, "Failed to send a log to the socket!", e)
}
}
}
}
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.spinner
import android.os.Looper
import android.util.Log
import org.signal.core.util.logging.Log.Logger
class SpinnerLogger : Logger() {
private val cachedThreadString: ThreadLocal<String> = ThreadLocal()
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
Spinner.log(
SpinnerLogItem(
level = Log.VERBOSE,
time = System.currentTimeMillis(),
thread = getThreadString(),
tag = tag,
message = message,
throwable = t
)
)
}
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
Spinner.log(
SpinnerLogItem(
level = Log.DEBUG,
time = System.currentTimeMillis(),
thread = getThreadString(),
tag = tag,
message = message,
throwable = t
)
)
}
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
Spinner.log(
SpinnerLogItem(
level = Log.INFO,
time = System.currentTimeMillis(),
thread = getThreadString(),
tag = tag,
message = message,
throwable = t
)
)
}
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
Spinner.log(
SpinnerLogItem(
level = Log.WARN,
time = System.currentTimeMillis(),
thread = getThreadString(),
tag = tag,
message = message,
throwable = t
)
)
}
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
Spinner.log(
SpinnerLogItem(
level = Log.ERROR,
time = System.currentTimeMillis(),
thread = getThreadString(),
tag = tag,
message = message,
throwable = t
)
)
}
override fun flush() = Unit
fun getThreadString(): String {
var threadString = cachedThreadString.get()
if (cachedThreadString.get() == null) {
threadString = if (Looper.myLooper() == Looper.getMainLooper()) {
"main "
} else {
String.format("%-5s", Thread.currentThread().id)
}
cachedThreadString.set(threadString)
}
return threadString!!
}
}

View file

@ -7,12 +7,14 @@ import com.github.jknack.handlebars.Handlebars
import com.github.jknack.handlebars.Template import com.github.jknack.handlebars.Template
import com.github.jknack.handlebars.helper.ConditionalHelpers import com.github.jknack.handlebars.helper.ConditionalHelpers
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.signal.core.util.ExceptionUtil import org.signal.core.util.ExceptionUtil
import org.signal.core.util.ForeignKeyConstraint import org.signal.core.util.ForeignKeyConstraint
import org.signal.core.util.getForeignKeys import org.signal.core.util.getForeignKeys
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.spinner.Spinner.DatabaseConfig import org.signal.spinner.Spinner.DatabaseConfig
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
import java.security.NoSuchAlgorithmException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -33,7 +35,7 @@ internal class SpinnerServer(
deviceInfo: Map<String, () -> String>, deviceInfo: Map<String, () -> String>,
private val databases: Map<String, DatabaseConfig>, private val databases: Map<String, DatabaseConfig>,
private val plugins: Map<String, Plugin> private val plugins: Map<String, Plugin>
) : NanoHTTPD(5000) { ) : NanoWSD(5000) {
companion object { companion object {
private val TAG = Log.tag(SpinnerServer::class.java) private val TAG = Log.tag(SpinnerServer::class.java)
@ -69,6 +71,8 @@ internal class SpinnerServer(
session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam) session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam)
session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, dbConfig, session) session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, dbConfig, session)
session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam) session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam)
session.method == Method.GET && session.uri == "/logs" -> getLogs(dbParam)
isWebsocketRequested(session) && session.uri == "/logs/websocket" -> getLogWebSocket(session)
else -> { else -> {
val plugin = plugins[session.uri] val plugin = plugins[session.uri]
if (plugin != null && session.method == Method.GET) { if (plugin != null && session.method == Method.GET) {
@ -84,6 +88,10 @@ internal class SpinnerServer(
} }
} }
override fun openWebSocket(handshake: IHTTPSession): WebSocket {
return SpinnerLogWebSocket(handshake)
}
fun onSql(dbName: String, sql: String) { fun onSql(dbName: String, sql: String) {
val commands: Queue<QueryItem> = recentSql[dbName] ?: ConcurrentLinkedQueue() val commands: Queue<QueryItem> = recentSql[dbName] ?: ConcurrentLinkedQueue()
@ -95,6 +103,10 @@ internal class SpinnerServer(
recentSql[dbName] = commands recentSql[dbName] = commands
} }
fun onLog(item: SpinnerLogItem) {
SpinnerLogWebSocket.onLog(item)
}
private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response { private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response {
return renderTemplate( return renderTemplate(
"overview", "overview",
@ -205,6 +217,38 @@ internal class SpinnerServer(
) )
} }
private fun getLogs(dbName: String): Response {
return renderTemplate(
"logs",
LogsPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList()
)
)
}
private fun getLogWebSocket(session: IHTTPSession): Response {
val headers = session.headers
val webSocket = openWebSocket(session)
val handshakeResponse = webSocket.handshakeResponse
try {
handshakeResponse.addHeader(HEADER_WEBSOCKET_ACCEPT, makeAcceptKey(headers[HEADER_WEBSOCKET_KEY]))
} catch (e: NoSuchAlgorithmException) {
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "The SHA-1 Algorithm required for websockets is not available on the server.")
}
if (headers.containsKey(HEADER_WEBSOCKET_PROTOCOL)) {
handshakeResponse.addHeader(HEADER_WEBSOCKET_PROTOCOL, headers[HEADER_WEBSOCKET_PROTOCOL]!!.split(",")[0])
}
return webSocket.handshakeResponse
}
private fun postQuery(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response { private fun postQuery(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response {
val action: String = session.parameters["action"]?.get(0).toString() val action: String = session.parameters["action"]?.get(0).toString()
val rawQuery: String = session.parameters["query"]?.get(0).toString() val rawQuery: String = session.parameters["query"]?.get(0).toString()
@ -448,6 +492,14 @@ internal class SpinnerServer(
val recentSql: List<RecentQuery>? val recentSql: List<RecentQuery>?
) : PrefixPageData ) : PrefixPageData
data class LogsPageModel(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>
) : PrefixPageData
data class PluginPageModel( data class PluginPageModel(
override val environment: String, override val environment: String,
override val deviceInfo: Map<String, String>, override val deviceInfo: Map<String, String>,