Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

View file

@ -0,0 +1,18 @@
plugins {
id("signal-library")
}
android {
namespace = "org.signal.spinner"
}
dependencies {
implementation(project(":core-util"))
implementation(libs.jknack.handlebars)
implementation(libs.nanohttpd.webserver)
implementation(libs.nanohttpd.websocket)
implementation(libs.androidx.sqlite)
testImplementation(testLibs.junit.junit)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -0,0 +1,75 @@
<html>
{{> partials/head title="Browse" }}
<body>
{{> partials/prefix isBrowse=true}}
<!-- Table Selector -->
<form action="browse" method="post">
<select name="table">
{{#each tableNames}}
<option value="{{this}}" {{eq table this yes="selected" no=""}}>{{this}}</option>
{{/each}}
</select>
<input type="hidden" name="db" value="{{database}}" />
<input type="submit" value="browse" />
</form>
<!-- Data -->
{{#if table}}
<h1>{{table}}</h1>
{{else}}
<h1>Data</h1>
{{/if}}
{{#if queryResult}}
<p>Viewing rows {{pagingData.startRow}}-{{pagingData.endRow}} of {{pagingData.rowCount}}.</p>
<!-- Paging Controls -->
<form action="browse" method="post">
<input type="hidden" name="table" value="{{table}}" />
<input type="hidden" name="pageSize" value="{{pagingData.pageSize}}" />
<input type="hidden" name="pageIndex" value="{{pagingData.pageIndex}}" />
<input type="submit" name="action" value="first" {{#if pagingData.firstPage}}disabled{{/if}} />
<input type="submit" name="action" value="previous" {{#if pagingData.firstPage}}disabled{{/if}} />
<input type="submit" name="action" value="next" {{#if pagingData.lastPage}}disabled{{/if}} />
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
</form>
<!-- Data Rows -->
<table>
<tr>
{{#each queryResult.columns}}
<th>{{this}}</th>
{{/each}}
</tr>
{{#each queryResult.rows}}
<tr>
{{#each this}}
<td><pre>{{#if (eq this null)}}<em class="null">null</em>{{else}}{{{this}}}{{/if}}</pre></td>
{{/each}}
</tr>
{{/each}}
</table>
{{else}}
<div>Select a table from above and click 'browse'.</div>
{{/if}}
<br />
<!-- Paging Controls -->
<form action="browse" method="post">
<input type="hidden" name="table" value="{{table}}" />
<input type="hidden" name="pageSize" value="{{pagingData.pageSize}}" />
<input type="hidden" name="pageIndex" value="{{pagingData.pageIndex}}" />
<input type="submit" name="action" value="first" {{#if pagingData.firstPage}}disabled{{/if}} />
<input type="submit" name="action" value="previous" {{#if pagingData.firstPage}}disabled{{/if}} />
<input type="submit" name="action" value="next" {{#if pagingData.lastPage}}disabled{{/if}} />
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
</form>
{{> partials/suffix}}
</body>
</html>

View file

@ -0,0 +1,165 @@
:root {
--background-color: #fff;
--table-header-background-color: #f0f0f0;
--text-color: #000;
--border-color: #000;
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: #333;
--table-header-background-color: #444;
--text-color: #fff;
--border-color: #888;
}
a {
color: #aaf;
}
}
[data-theme="dark"] {
--background-color: #333;
--table-header-background-color: #444;
--text-color: #fff;
--border-color: #888;
a {
color: #aaf;
}
}
html, body {
font-family: 'Roboto Mono', monospace;
font-variant-ligatures: none;
font-size: 12px;
width: 100%;
background: var(--background-color);
color: var(--text-color);
}
body {
margin: 0;
padding: 8px;
}
select, input, button {
font-family: 'Roboto Mono', monospace;
font-variant-ligatures: none;
font-size: 1rem;
}
table, th, td {
border: 1px solid black;
font-size: 1rem;
}
th, td {
padding: 8px;
}
th {
position: sticky;
top: 0;
background: var(--background-color);
}
.handsontable th {
color: var(--text-color);
background: var(--table-header-background-color);
}
.handsontable thead th.ht__highlight {
color: var(--text-color);
background: var(--table-header-background-color);
}
.handsontable td {
color: var(--text-color);
background: var(--background-color);
}
.query-input {
width: calc(100% - 18px);
border: 1px solid var(--border-color);
border-radius: 4px;
height: 200px;
margin-bottom: 2px;
}
li.active {
font-weight: bold;
}
ol.tabs {
margin: 16px 0px 8px 0px;
padding: 0px;
font-size: 0px;
}
.tabs li {
list-style-type: none;
display: inline-block;
padding: 8px;
border-bottom: 1px solid var(--border-color);
font-size: 1rem;
}
.tabs li.active {
border: 1px solid var(--border-color);
border-bottom: 0;
}
.tabs a {
text-decoration: none;
color: var(--text-color);
}
.collapse-header {
cursor: pointer;
}
.collapse-header:before {
content: "⯈ ";
font-size: 1rem;
}
.collapse-header.active:before {
content: "⯆ ";
font-size: 1rem;
}
h2.collapse-header, h2.collapse-header+div {
margin-left: 16px;
}
.hidden {
display: none;
}
table.device-info {
margin-bottom: 16px;
}
table.device-info, table.device-info tr, table.device-info td {
border: 0;
padding: 2px;
font-size: 0.75rem;
font-style: italic;
}
.null {
color: #666
}
#grow-button {
width: calc(100% - 18px);
height: 0.75rem;
margin-bottom: 8px;
}
#theme-toggle {
position: absolute;
top: 8px;
right: 8px;
}

View file

@ -0,0 +1,8 @@
<html>
{{> partials/head title="Error :(" }}
<body>
Hit an exception while trying to serve the page :(
<hr/>
{{{this}}}
</body>
</html>

View file

@ -0,0 +1,33 @@
function init() {
document.querySelectorAll('.collapse-header').forEach(elem => {
elem.onclick = () => {
console.log('clicked');
elem.classList.toggle('active');
document.getElementById(elem.dataset.for).classList.toggle('hidden');
document.dispatchEvent(new CustomEvent('header-toggle', {
detail: document.getElementById(elem.dataset.for)
}))
}
});
document.querySelector('#database-selector').onchange = (e) => {
window.location.href = window.location.href.split('?')[0] + '?db=' + e.target.value;
}
document.querySelector('#theme-toggle').onclick = function() {
if (document.body.getAttribute('data-theme') === 'dark') {
document.body.removeAttribute('data-theme');
localStorage.removeItem('theme');
} else {
document.body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
}
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.body.setAttribute('data-theme', savedTheme);
}
}
init();

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

@ -0,0 +1,107 @@
<html>
{{> partials/head title="Home" }}
<style type="text/css">
h1.collapse-header {
font-size: 1.35rem;
}
h2.collapse-header {
font-size: 1.15rem;
}
</style>
<body>
{{> partials/prefix isOverview=true}}
<h1 class="collapse-header" data-for="table-creates">Tables</h1>
<div id="table-creates" class="hidden">
{{#if tables}}
{{#each tables}}
<h2 class="collapse-header" data-for="table-create-{{@index}}">{{name}}</h2>
<div id="table-create-{{@index}}" class="hidden">{{{sql}}}</div>
{{/each}}
{{else}}
None.
{{/if}}
</div>
<h1 class="collapse-header" data-for="index-creates">Indices</h1>
<div id="index-creates" class="hidden">
{{#if indices}}
{{#each indices}}
<h2 class="collapse-header active" data-for="index-create-{{@index}}">{{name}}</h2>
<div id="index-create-{{@index}}">{{{sql}}}</div>
{{/each}}
{{else}}
None.
{{/if}}
</div>
<h1 class="collapse-header" data-for="trigger-creates">Triggers</h1>
<div id="trigger-creates" class="hidden">
{{#if triggers}}
{{#each triggers}}
<h2 class="collapse-header active" data-for="trigger-create-{{@index}}">{{name}}</h2>
<div id="trigger-create-{{@index}}">{{{sql}}}</div>
{{/each}}
{{else}}
None.
{{/if}}
</div>
<h1 class="collapse-header" data-for="foreign-key-creates">Foreign Keys</h1>
<div id="foreign-key-creates" class="hidden">
{{#if foreignKeys}}
<table>
<tr>
<th>Column</th>
<th>Depends On</th>
<th>On Delete</th>
</tr>
{{#each foreignKeys}}
<tr>
<td>{{table}}.{{column}}</td>
<td>{{dependsOnTable}}.{{dependsOnColumn}}</td>
<td>{{onDelete}}</td>
</tr>
{{/each}}
</table>
<h2>Without Labels</h2>
<pre class="mermaid">
flowchart LR
{{#each foreignKeys}}
id_{{table}}[{{table}}] --> id_{{dependsOnTable}}[{{dependsOnTable}}]
{{/each}}
</pre>
<h2>With Labels</h2>
<pre class="mermaid">
flowchart LR
{{#each foreignKeys}}
id_{{table}}[{{table}}] -- "{{column}} 🠖 {{dependsOnColumn}}" --> id_{{dependsOnTable}}[{{dependsOnTable}}]
{{/each}}
</pre>
{{else}}
None.
{{/if}}
</div>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false });
document.addEventListener('header-toggle', (e) => {
if (e.detail.id === 'foreign-key-creates') {
mermaid.init('.mermaid')
}
})
</script>
{{> partials/suffix }}
</body>
</html>

View file

@ -0,0 +1,14 @@
<head>
<title>Spinner - {{ title }}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌀</text></svg>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<style type="text/css">
</style>
</head>

View file

@ -0,0 +1,38 @@
<h1>SPINNER - {{environment}}</h1>
<table class="device-info">
{{#each deviceInfo}}
<tr>
<td>{{@key}}</td>
<td>{{this}}</td>
</tr>
{{/each}}
</table>
<div>
Download Trace: <a href="/trace">Link</a>
</div>
<button id="theme-toggle">Toggle theme</button>
<br />
<div>
Database:
<select id="database-selector">
{{#each databases}}
<option value="{{this}}" {{eq database this yes="selected" no=""}}>{{this}}</option>
{{/each}}
</select>
</div>
<ol class="tabs">
<li {{#if isOverview}}class="active"{{/if}}><a href="/?db={{database}}">Overview</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 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}}
<li {{#if (eq name activePlugin.name)}}class="active"{{/if}}><a href="{{path}}">{{name}}</a></li>
{{/each}}
</ol>

View file

@ -0,0 +1 @@
<script src="/js/main.js" type="text/javascript"></script>

View file

@ -0,0 +1,31 @@
<html>
{{> partials/head title=activePlugin.name }}
<body>
{{> partials/prefix}}
{{#if (eq "table" pluginResult.type)}}
<h1>Data</h1>
{{pluginResult.rowCount}} row(s). <br />
<br />
<table>
<tr>
{{#each pluginResult.columns}}
<th>{{this}}</th>
{{/each}}
</tr>
{{#each pluginResult.rows}}
<tr>
{{#each this}}
<td><pre>{{{this}}}</pre></td>
{{/each}}
</tr>
{{/each}}
</table>
{{/if}}
{{#if (eq "string" pluginResult.type)}}
<p>{{pluginResult.text}}</p>
{{/if}}
{{> partials/suffix }}
</body>
</html>

View file

@ -0,0 +1,173 @@
<html>
{{> partials/head title="Query" }}
<body>
{{> partials/prefix isQuery=true}}
<!-- Query Input -->
<form action="query" method="post" id="query-form">
<div class="query-input">{{query}}</div>
<button id="grow-button" onclick="onGrowClicked(event)">&nbsp;</button>
<input type="hidden" name="query" id="query" />
<input type="hidden" name="db" value="{{database}}" />
<input type="submit" name="action" value="run" />
or
<input type="submit" name="action" value="analyze" />
or
<button onclick="onFormatClicked(event)">format</button>
</form>
<!-- Container for previous queries -->
<h1 class="collapse-header" data-for="history-container">Query History</h1>
<table id="history-container" class="hidden"></table>
<!-- Query Result -->
<h1>Data</h1>
{{#if queryResult}}
{{queryResult.rowCount}} row(s). <br />
{{queryResult.timeToFirstRow}} ms to read the first row. <br />
{{queryResult.timeToReadRows}} ms to read the rest of the rows. <br />
<br />
<table>
<tr>
{{#each queryResult.columns}}
<th>{{this}}</th>
{{/each}}
</tr>
{{#each queryResult.rows}}
<tr>
{{#each this}}
<td><pre>{{#if (eq this null)}}<em class="null">null</em>{{else}}{{{this}}}{{/if}}</pre></td>
{{/each}}
</tr>
{{/each}}
</table>
{{else}}
No data.
{{/if}}
{{> partials/suffix}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/ace.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/mode-sql.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/theme-github.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/theme-github_dark.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript">
let editor;
function main() {
//document.querySelector('.query-input').addEventListener('keypress', submitOnEnter);
document.getElementById('query-form').addEventListener('submit', onQuerySubmitted, false);
renderQueryHistory();
editor = ace.edit(document.querySelector('.query-input'), {
mode: 'ace/mode/sql',
theme: isDarkTheme() ? 'ace/theme/github_dark' : 'ace/theme/github',
selectionStyle: 'text',
showPrintMargin: false
});
editor.setFontSize(13);
editor.commands.addCommand({
name: "Run",
bindKey: {win: "Ctrl-Return", mac: "Command-Return"},
exec: triggerSubmit
});
editor.commands.addCommand({
name: "Run (Cody)",
bindKey: {win: "Shift-Return", mac: "Shift-Return"},
exec: triggerSubmit
});
editor.commands.addCommand({
name: "Format",
bindKey: {win: "Ctrl-Shift-F", mac: "Command-Shift-F"},
exec: formatSql
});
}
function onFormatClicked(e) {
formatSql();
e.preventDefault();
}
function formatSql() {
let formatted = sqlFormatter.format(editor.getValue()).replaceAll("! =", "!=").replaceAll("| |", "||");
editor.setValue(formatted, formatted.length);
}
function triggerSubmit() {
onQuerySubmitted();
document.getElementById('query-form').submit();
}
function onGrowClicked(e) {
let element = document.querySelector('.query-input');
let currentHeight = parseInt(element.style.height) || 200;
element.style.height = currentHeight + 100;
e.preventDefault();
}
function onQuerySubmitted() {
const query = editor.getValue();
document.getElementById('query').value = query;
let history = getQueryHistory();
if (history.length > 0 && history[0] === query) {
console.log('Query already at the top of the history, not saving.');
return;
}
history.unshift(query);
history = history.slice(0, 25);
localStorage.setItem('query-history', JSON.stringify(history));
}
function renderQueryHistory() {
const container = document.getElementById('history-container');
let history = getQueryHistory();
if (history.length > 0) {
let i = 0;
for (let item of history) {
container.innerHTML += `
<tr>
<td><button onclick="onHistoryItemClicked(${i})">^</button></td>
<td id="history-item-${i}">${item}</td>
</tr>
`;
i++;
}
} else {
container.innerHTML = '<em>None</em>';
}
}
function onHistoryItemClicked(i) {
let item = document.getElementById(`history-item-${i}`).innerText;
editor.setValue(item, item.length);
}
function getQueryHistory() {
const historyRaw = localStorage.getItem('query-history') || "[]";
return JSON.parse(historyRaw);
}
function isDarkTheme() {
return document.body.getAttribute('data-theme') === 'dark'
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,50 @@
<html>
{{> partials/head title="Home" }}
<style type="text/css">
h1.collapse-header {
font-size: 1.35rem;
}
h2.collapse-header {
font-size: 1.15rem;
}
table.recent {
width: 100%;
}
</style>
<body>
{{> partials/prefix isRecent=true}}
{{#if recentSql}}
<table class="recent">
{{#each recentSql}}
<tr>
<td>
{{formattedTime}}
</td>
<td>
<form action="query" method="post">
<input type="hidden" name="db" value="{{database}}" />
<input type="hidden" name="query" value="{{query}}" />
<input type="submit" name="action" value="run" />
<input type="submit" name="action" value="analyze" />
</form>
</td>
<td>{{query}}</td>
</tr>
{{/each}}
</table>
{{else}}
No recent queries.
{{/if}}
{{> partials/suffix }}
<script>
function onAnalyzeClicked(id) {
document.getElementById
}
</script>
</body>
</html>

View file

@ -0,0 +1,47 @@
package org.signal.spinner
import android.content.Context
import com.github.jknack.handlebars.io.StringTemplateSource
import com.github.jknack.handlebars.io.TemplateLoader
import com.github.jknack.handlebars.io.TemplateSource
import org.signal.core.util.StreamUtil
import java.nio.charset.Charset
/**
* A loader read handlebars templates from the assets directory.
*/
class AssetTemplateLoader(private val context: Context) : TemplateLoader {
override fun sourceAt(location: String): TemplateSource {
val content: String = StreamUtil.readFullyAsString(context.assets.open("$location.hbs"))
return StringTemplateSource(location, content)
}
override fun resolve(location: String): String {
return location
}
override fun getPrefix(): String {
return ""
}
override fun getSuffix(): String {
return ""
}
override fun setPrefix(prefix: String) {
TODO("Not yet implemented")
}
override fun setSuffix(suffix: String) {
TODO("Not yet implemented")
}
override fun setCharset(charset: Charset?) {
TODO("Not yet implemented")
}
override fun getCharset(): Charset {
return Charset.defaultCharset()
}
}

View file

@ -0,0 +1,18 @@
package org.signal.spinner
import android.database.Cursor
/**
* An interface to transform on column value into another. Useful for making certain data fields (like bitmasks) more readable.
*/
interface ColumnTransformer {
/**
* In certain circumstances (like some queries), the table name may not be guaranteed.
*/
fun matches(tableName: String?, columnName: String): Boolean
/**
* In certain circumstances (like some queries), the table name may not be guaranteed.
*/
fun transform(tableName: String?, columnName: String, cursor: Cursor): String?
}

View file

@ -0,0 +1,37 @@
package org.signal.spinner
import android.database.Cursor
import androidx.sqlite.db.SupportSQLiteDatabase
fun SupportSQLiteDatabase.getTableNames(): List<String> {
val out = mutableListOf<String>()
this.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name ASC").use { cursor ->
while (cursor.moveToNext()) {
out += cursor.getString(0)
}
}
return out
}
fun SupportSQLiteDatabase.getTables(): Cursor {
return this.query("SELECT * FROM sqlite_master WHERE type='table' ORDER BY name ASC")
}
fun SupportSQLiteDatabase.getIndexes(): Cursor {
return this.query("SELECT * FROM sqlite_master WHERE type='index' ORDER BY name ASC")
}
fun SupportSQLiteDatabase.getTriggers(): Cursor {
return this.query("SELECT * FROM sqlite_master WHERE type='trigger' ORDER BY name ASC")
}
fun SupportSQLiteDatabase.getTableRowCount(table: String): Int {
return this.query("SELECT COUNT(*) FROM $table").use {
if (it.moveToFirst()) {
it.getInt(0)
} else {
0
}
}
}

View file

@ -0,0 +1,19 @@
package org.signal.spinner
import android.database.Cursor
import org.signal.core.util.Base64
import org.signal.core.util.Hex
object DefaultColumnTransformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean {
return true
}
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? {
val index = cursor.getColumnIndex(columnName)
return when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> "Base64 with padding:<br>${Base64.encodeWithPadding(cursor.getBlob(index))}<br><br>Hex string:<br>${Hex.toStringCondensed(cursor.getBlob(index))}"
else -> cursor.getString(index)
}
}
}

View file

@ -0,0 +1,7 @@
package org.signal.spinner
interface Plugin {
fun get(): PluginResult
val name: String
val path: String
}

View file

@ -0,0 +1,13 @@
package org.signal.spinner
sealed class PluginResult(val type: String) {
data class TableResult(
val columns: List<String>,
val rows: List<List<String>>,
val rowCount: Int = rows.size
) : PluginResult("table")
data class StringResult(
val text: String
) : PluginResult("string")
}

View file

@ -0,0 +1,101 @@
package org.signal.spinner
import android.app.Application
import android.content.ContentValues
import android.database.sqlite.SQLiteQueryBuilder
import androidx.sqlite.db.SupportSQLiteDatabase
import org.signal.core.util.logging.Log
import java.io.IOException
/**
* A class to help initialize Spinner, our database debugging interface.
*/
object Spinner {
internal const val KEY_PREFIX = "spinner"
const val KEY_ENVIRONMENT = "$KEY_PREFIX:environment"
private val TAG: String = Log.tag(Spinner::class.java)
private lateinit var server: SpinnerServer
fun init(application: Application, deviceInfo: Map<String, () -> String>, databases: Map<String, DatabaseConfig>, plugins: Map<String, Plugin>) {
try {
server = SpinnerServer(application, deviceInfo, databases, plugins)
server.start()
} catch (e: IOException) {
Log.w(TAG, "Spinner server hit IO exception!", e)
}
}
fun onSql(dbName: String, sql: String, args: Array<Any>?) {
server.onSql(dbName, replaceQueryArgs(sql, args))
}
fun onQuery(dbName: String, distinct: Boolean, table: String, projection: Array<String>?, selection: String?, args: Array<Any>?, groupBy: String?, having: String?, orderBy: String?, limit: String?) {
val queryString = SQLiteQueryBuilder.buildQueryString(distinct, table, projection, selection, groupBy, having, orderBy, limit)
server.onSql(dbName, replaceQueryArgs(queryString, args))
}
fun onDelete(dbName: String, table: String, selection: String?, args: Array<Any>?) {
var query = "DELETE FROM $table"
if (selection != null) {
query += " WHERE $selection"
query = replaceQueryArgs(query, args)
}
server.onSql(dbName, query)
}
fun onUpdate(dbName: String, table: String, values: ContentValues, selection: String?, args: Array<Any>?) {
val query = StringBuilder("UPDATE $table SET ")
for (key in values.keySet()) {
query.append("$key = ${values.get(key)}, ")
}
query.delete(query.length - 2, query.length)
if (selection != null) {
query.append(" WHERE ").append(selection)
}
var queryString = query.toString()
if (args != null) {
queryString = replaceQueryArgs(queryString, args)
}
server.onSql(dbName, queryString)
}
internal fun log(item: SpinnerLogItem) {
server.onLog(item)
}
private fun replaceQueryArgs(query: String, args: Array<Any>?): String {
if (args == null) {
return query
}
val builder = StringBuilder()
var i = 0
var argIndex = 0
while (i < query.length) {
if (query[i] == '?' && argIndex < args.size) {
builder.append("'").append(args[argIndex]).append("'")
argIndex++
} else {
builder.append(query[i])
}
i++
}
return builder.toString()
}
data class DatabaseConfig(
val db: () -> SupportSQLiteDatabase,
val columnTransformers: List<ColumnTransformer> = emptyList()
)
}

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
object 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

@ -0,0 +1,568 @@
package org.signal.spinner
import android.app.Application
import android.database.Cursor
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.jknack.handlebars.Handlebars
import com.github.jknack.handlebars.Template
import com.github.jknack.handlebars.helper.ConditionalHelpers
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.signal.core.util.ExceptionUtil
import org.signal.core.util.ForeignKeyConstraint
import org.signal.core.util.getForeignKeys
import org.signal.core.util.logging.Log
import org.signal.core.util.tracing.Tracer
import org.signal.spinner.Spinner.DatabaseConfig
import java.io.ByteArrayInputStream
import java.lang.IllegalArgumentException
import java.security.NoSuchAlgorithmException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Queue
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
/**
* The workhorse of this lib. Handles all of our our web routing and response generation.
*
* In general, you add routes in [serve], and then build a response by creating a handlebars template (in the assets folder) and then passing in a data class
* to [renderTemplate].
*/
internal class SpinnerServer(
private val application: Application,
deviceInfo: Map<String, () -> String>,
private val databases: Map<String, DatabaseConfig>,
private val plugins: Map<String, Plugin>
) : NanoWSD(5000) {
companion object {
private val TAG = Log.tag(SpinnerServer::class.java)
}
private val deviceInfo: Map<String, () -> String> = deviceInfo.filterKeys { !it.startsWith(Spinner.KEY_PREFIX) }
private val environment: String = deviceInfo[Spinner.KEY_ENVIRONMENT]?.let { it() } ?: "UNKNOWN"
private val handlebars: Handlebars = Handlebars(AssetTemplateLoader(application)).apply {
registerHelper("eq", ConditionalHelpers.eq)
registerHelper("neq", ConditionalHelpers.neq)
}
private val recentSql: MutableMap<String, Queue<QueryItem>> = mutableMapOf()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US)
override fun serve(session: IHTTPSession): Response {
if (session.method == Method.POST) {
// Needed to populate session.parameters
session.parseBody(mutableMapOf())
}
val dbParam: String = session.queryParam("db") ?: session.parameters["db"]?.toString() ?: databases.keys.first()
val dbConfig: DatabaseConfig = databases[dbParam] ?: return internalError(IllegalArgumentException("Invalid db param!"))
try {
return when {
session.method == Method.GET && session.uri.startsWith("/css/") -> newFileResponse(session.uri.substring(1), "text/css")
session.method == Method.GET && session.uri.startsWith("/js/") -> newFileResponse(session.uri.substring(1), "text/javascript")
session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, dbConfig.db())
session.method == Method.GET && session.uri == "/browse" -> getBrowse(dbParam, dbConfig.db())
session.method == Method.POST && session.uri == "/browse" -> postBrowse(dbParam, dbConfig, session)
session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam)
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 == "/trace" -> getTrace()
session.method == Method.GET && session.uri == "/logs" -> getLogs(dbParam)
isWebsocketRequested(session) && session.uri == "/logs/websocket" -> getLogWebSocket(session)
else -> {
val plugin = plugins[session.uri]
if (plugin != null && session.method == Method.GET) {
getPlugin(dbParam, plugin)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found")
}
}
}
} catch (t: Throwable) {
Log.e(TAG, t)
return internalError(t)
}
}
override fun openWebSocket(handshake: IHTTPSession): WebSocket {
return SpinnerLogWebSocket(handshake)
}
fun onSql(dbName: String, sql: String) {
val commands: Queue<QueryItem> = recentSql[dbName] ?: ConcurrentLinkedQueue()
commands += QueryItem(System.currentTimeMillis(), sql)
if (commands.size > 500) {
commands.remove()
}
recentSql[dbName] = commands
}
fun onLog(item: SpinnerLogItem) {
SpinnerLogWebSocket.onLog(item)
}
private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response {
return renderTemplate(
"overview",
OverviewPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
tables = db.getTables().use { it.toTableInfo() },
indices = db.getIndexes().use { it.toIndexInfo() },
triggers = db.getTriggers().use { it.toTriggerInfo() },
foreignKeys = db.getForeignKeys(),
queryResult = db.getTables().use { it.toQueryResult() }
)
)
}
private fun getBrowse(dbName: String, db: SupportSQLiteDatabase): Response {
return renderTemplate(
"browse",
BrowsePageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
tableNames = db.getTableNames()
)
)
}
private fun postBrowse(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response {
val table: String = session.parameters["table"]?.get(0).toString()
val pageSize: Int = session.parameters["pageSize"]?.get(0)?.toInt() ?: 1000
var pageIndex: Int = session.parameters["pageIndex"]?.get(0)?.toInt() ?: 0
val action: String? = session.parameters["action"]?.get(0)
val rowCount = dbConfig.db().getTableRowCount(table)
val pageCount = ceil(rowCount.toFloat() / pageSize.toFloat()).toInt()
when (action) {
"first" -> pageIndex = 0
"next" -> pageIndex = min(pageIndex + 1, pageCount - 1)
"previous" -> pageIndex = max(pageIndex - 1, 0)
"last" -> pageIndex = pageCount - 1
}
val query = "select * from $table limit $pageSize offset ${pageSize * pageIndex}"
val queryResult = dbConfig.db().query(query).use { it.toQueryResult(columnTransformers = dbConfig.columnTransformers, table = table) }
return renderTemplate(
"browse",
BrowsePageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
tableNames = dbConfig.db().getTableNames(),
table = table,
queryResult = queryResult,
pagingData = PagingData(
rowCount = rowCount,
pageSize = pageSize,
pageIndex = pageIndex,
pageCount = pageCount,
startRow = pageSize * pageIndex,
endRow = min(pageSize * (pageIndex + 1), rowCount)
)
)
)
}
private fun getQuery(dbName: String): Response {
return renderTemplate(
"query",
QueryPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
query = ""
)
)
}
private fun getRecent(dbName: String): Response {
val queries: List<RecentQuery>? = recentSql[dbName]
?.map { it ->
RecentQuery(
formattedTime = dateFormat.format(Date(it.time)),
query = it.query
)
}
return renderTemplate(
"recent",
RecentPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
recentSql = queries?.reversed()
)
)
}
private fun getTrace(): Response {
return newChunkedResponse(
Response.Status.OK,
"application/octet-stream",
ByteArrayInputStream(Tracer.getInstance().serialize())
)
}
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 {
val action: String = session.parameters["action"]?.get(0).toString()
val rawQuery: String = session.parameters["query"]?.get(0).toString()
val query = if (action == "analyze") "EXPLAIN QUERY PLAN $rawQuery" else rawQuery
val startTimeNanos = System.nanoTime()
return renderTemplate(
"query",
QueryPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
query = rawQuery,
queryResult = dbConfig.db().query(query).use { it.toQueryResult(queryStartTimeNanos = startTimeNanos, columnTransformers = dbConfig.columnTransformers) }
)
)
}
private fun getPlugin(dbName: String, plugin: Plugin): Response {
return renderTemplate(
"plugin",
PluginPageModel(
environment = environment,
deviceInfo = deviceInfo.resolve(),
database = dbName,
databases = databases.keys.toList(),
plugins = plugins.values.toList(),
activePlugin = plugin,
pluginResult = plugin.get()
)
)
}
private fun internalError(throwable: Throwable): Response {
val stackTrace = ExceptionUtil.convertThrowableToString(throwable)
.split("\n")
.map { it.trim() }
.mapIndexed { index, s -> if (index == 0) s else "&nbsp;&nbsp;$s" }
.joinToString("<br />")
return renderTemplate("error", stackTrace)
}
private fun renderTemplate(assetName: String, model: Any): Response {
val template: Template = handlebars.compile(assetName)
val output: String = template.apply(model)
return newFixedLengthResponse(output)
}
private fun newFileResponse(assetPath: String, mimeType: String): Response {
return newChunkedResponse(
Response.Status.OK,
mimeType,
application.assets.open(assetPath)
)
}
private fun Cursor.toQueryResult(queryStartTimeNanos: Long = 0, columnTransformers: List<ColumnTransformer> = emptyList(), table: String? = null): QueryResult {
val numColumns = this.columnCount
val columns = mutableListOf<String>()
val transformers = mutableListOf<ColumnTransformer>()
for (i in 0 until numColumns) {
val columnName = getColumnName(i)
val customTransformer: ColumnTransformer? = columnTransformers.find { it.matches(table, columnName) }
columns += if (customTransformer != null) {
"$columnName *"
} else {
columnName
}
transformers += customTransformer ?: DefaultColumnTransformer
}
var timeOfFirstRowNanos = 0L
val rows = mutableListOf<List<String?>>()
while (moveToNext()) {
if (timeOfFirstRowNanos == 0L) {
timeOfFirstRowNanos = System.nanoTime()
}
val row = mutableListOf<String?>()
for (i in 0 until numColumns) {
val columnName: String = getColumnName(i)
try {
row += transformers[i].transform(null, columnName, this)
} catch (e: Exception) {
Log.w(TAG, "Failed to transform", e)
row += "*Failed to Transform*\n\n${DefaultColumnTransformer.transform(null, columnName, this)}"
}
}
rows += row
}
if (timeOfFirstRowNanos == 0L) {
timeOfFirstRowNanos = System.nanoTime()
}
return QueryResult(
columns = columns,
rows = rows,
timeToFirstRow = (max(timeOfFirstRowNanos - queryStartTimeNanos, 0) / 1_000_000.0f).roundForDisplay(3),
timeToReadRows = (max(System.nanoTime() - timeOfFirstRowNanos, 0) / 1_000_000.0f).roundForDisplay(3)
)
}
fun Float.roundForDisplay(decimals: Int = 2): String {
return "%.${decimals}f".format(this)
}
private fun Cursor.toTableInfo(): List<TableInfo> {
val tables = mutableListOf<TableInfo>()
while (moveToNext()) {
val name = getString(getColumnIndexOrThrow("name"))
tables += TableInfo(
name = name ?: "null",
sql = getString(getColumnIndexOrThrow("sql"))?.formatAsSqlCreationStatement(name) ?: "null"
)
}
return tables
}
private fun Cursor.toIndexInfo(): List<IndexInfo> {
val indices = mutableListOf<IndexInfo>()
while (moveToNext()) {
indices += IndexInfo(
name = getString(getColumnIndexOrThrow("name")) ?: "null",
sql = getString(getColumnIndexOrThrow("sql")) ?: "null"
)
}
return indices
}
private fun Cursor.toTriggerInfo(): List<TriggerInfo> {
val indices = mutableListOf<TriggerInfo>()
while (moveToNext()) {
indices += TriggerInfo(
name = getString(getColumnIndexOrThrow("name")) ?: "null",
sql = getString(getColumnIndexOrThrow("sql")) ?: "null"
)
}
return indices
}
/** Takes a SQL table creation statement and formats it using HTML */
private fun String.formatAsSqlCreationStatement(name: String): String {
val fields = substring(indexOf("(") + 1, this.length - 1).split(",")
val fieldStrings = fields.map { s -> "&nbsp;&nbsp;${s.trim()},<br>" }.toMutableList()
if (fieldStrings.isNotEmpty()) {
fieldStrings[fieldStrings.lastIndex] = "&nbsp;&nbsp;${fields.last().trim()}<br>"
}
return "CREATE TABLE $name (<br/>" +
fieldStrings.joinToString("") +
")"
}
private fun IHTTPSession.queryParam(name: String): String? {
if (queryParameterString == null) {
return null
}
val params: Map<String, String> = queryParameterString
.split("&")
.mapNotNull { part ->
val parts = part.split("=")
if (parts.size == 2) {
parts[0] to parts[1]
} else {
null
}
}
.toMap()
return params[name]
}
private fun Map<String, () -> String>.resolve(): Map<String, String> {
return this.mapValues { entry -> entry.value() }.toMap()
}
interface PrefixPageData {
val environment: String
val deviceInfo: Map<String, String>
val database: String
val databases: List<String>
val plugins: List<Plugin>
}
data class OverviewPageModel(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>,
val tables: List<TableInfo>,
val indices: List<IndexInfo>,
val triggers: List<TriggerInfo>,
val foreignKeys: List<ForeignKeyConstraint>,
val queryResult: QueryResult? = null
) : PrefixPageData
data class BrowsePageModel(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>,
val tableNames: List<String>,
val table: String? = null,
val queryResult: QueryResult? = null,
val pagingData: PagingData? = null
) : PrefixPageData
data class QueryPageModel(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>,
val query: String = "",
val queryResult: QueryResult? = null
) : PrefixPageData
data class RecentPageModel(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>,
val recentSql: List<RecentQuery>?
) : 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(
override val environment: String,
override val deviceInfo: Map<String, String>,
override val database: String,
override val databases: List<String>,
override val plugins: List<Plugin>,
val activePlugin: Plugin,
val pluginResult: PluginResult
) : PrefixPageData
data class QueryResult(
val columns: List<String>,
val rows: List<List<String?>>,
val rowCount: Int = rows.size,
val timeToFirstRow: String,
val timeToReadRows: String
)
data class TableInfo(
val name: String,
val sql: String
)
data class IndexInfo(
val name: String,
val sql: String
)
data class TriggerInfo(
val name: String,
val sql: String
)
data class PagingData(
val rowCount: Int,
val pageSize: Int,
val pageIndex: Int,
val pageCount: Int,
val firstPage: Boolean = pageIndex == 0,
val lastPage: Boolean = pageIndex == pageCount - 1,
val startRow: Int,
val endRow: Int
)
data class QueryItem(
val time: Long,
val query: String
)
data class RecentQuery(
val formattedTime: String,
val query: String
)
}