Merge branch 'webui'

# Conflicts:
#	src/main/kotlin/mdnet/base/web/Application.kt
This commit is contained in:
carbotaniuman 2020-06-13 16:11:04 -05:00
commit a86b313ad5
31 changed files with 3302 additions and 243 deletions

View file

@ -22,8 +22,12 @@ dependencies {
implementation group: "org.http4k", name: "http4k-core", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-client-apache", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-format-gson", version: "3.249.0"
implementation group: "commons-io", name: "commons-io", version: "2.7"
implementation group: 'org.java-websocket', name: 'Java-WebSocket', version: '1.5.1'
implementation "ch.qos.logback:logback-classic:$logback_version"
runtimeOnly 'io.netty:netty-tcnative-boringssl-static:2.0.30.Final'
}
@ -35,12 +39,14 @@ java {
spotless {
java {
indentWithSpaces(4)
eclipse()
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
}
kotlin {
indentWithSpaces(4)
ktlint()
trimTrailingWhitespace()
endWithNewline()

View file

@ -1,8 +1,12 @@
{
"client_secret": "7rc7p00md0n0xsvqnv4rv17fthvjjrzpdghak1yq45833zvdvnb0",
"client_secret": "s76t1dazfctvtgq9dyvgw9herxc4gcz39q0q0y3taxpkgg0ahq8g",
"max_cache_size_mib": 2048,
"client_port": 8080,
"max_burst_rate_kib_per_second": 100,
"max_bandwidth_mib_per_hour": 1,
"threads_per_cpu": 32
"client_port": 443,
"max_burst_rate_kib_per_second": 0,
"max_bandwidth_mib_per_hour": 0,
"threads_per_cpu": 32,
"web_settings":
{
"ui_port": 8080
}
}

View file

@ -1,22 +1,30 @@
package mdnet.base;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import mdnet.base.settings.ClientSettings;
import mdnet.base.web.ApplicationKt;
import mdnet.base.web.WebUiKt;
import mdnet.cache.DiskLruCache;
import org.http4k.server.Http4kServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
public class MangaDexClient {
private final static Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final static Logger LOGGER = LoggerFactory.getLogger(MangaDexClient.class);
// This lock protects the Http4kServer from concurrent restart attempts
@ -24,13 +32,25 @@ public class MangaDexClient {
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
private final ServerHandler serverHandler;
private final ClientSettings clientSettings;
private final AtomicReference<Statistics> statistics;
private ServerSettings serverSettings;
// if this is null, then the server has shutdown
private Http4kServer engine;
private final Map<Instant, Statistics> statsMap = Collections
.synchronizedMap(new LinkedHashMap<Instant, Statistics>(80) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return this.size() > 80;
}
});
private final AtomicReference<Statistics> statistics;
private ServerSettings serverSettings;
private Http4kServer engine; // if this is null, then the server has shutdown
private Http4kServer webUi;
private DiskLruCache cache;
// these variables are for runLoop();
private int counter = 0;
private long lastBytesSent = 0;
public MangaDexClient(ClientSettings clientSettings) {
this.clientSettings = clientSettings;
this.serverHandler = new ServerHandler(clientSettings);
@ -39,14 +59,22 @@ public class MangaDexClient {
try {
cache = DiskLruCache.open(new File("cache"), 3, 3,
clientSettings.getMaxCacheSizeMib() * 1024 * 1024 /* MiB to bytes */);
DiskLruCache.Snapshot snapshot = cache.get("statistics");
if (snapshot != null) {
String json = snapshot.getString(0);
snapshot.close();
statistics.set(GSON.fromJson(json, Statistics.class));
} else {
statistics.set(new Statistics());
}
lastBytesSent = statistics.get().getBytesSent();
} catch (IOException e) {
MangaDexClient.dieWithError(e);
}
}
// This function also does most of the program initialization.
public void runLoop() {
statistics.set(new Statistics());
loginAndStartServer();
if (serverSettings.getLatestBuild() > Constants.CLIENT_BUILD) {
if (LOGGER.isWarnEnabled()) {
@ -55,23 +83,21 @@ public class MangaDexClient {
}
}
statsMap.put(Instant.now(), statistics.get());
if (clientSettings.getWebSettings() != null) {
webUi = WebUiKt.getUiServer(clientSettings.getWebSettings(), statistics, statsMap);
webUi.start();
}
if (LOGGER.isInfoEnabled()) {
LOGGER.info("MDNet initialization completed successfully. Starting normal operation.");
}
// we don't really care about the Atomic part here
AtomicInteger counter = new AtomicInteger();
// ping keep-alive every 45 seconds
executorService.scheduleAtFixedRate(() -> {
int num = counter.get();
if (num == 80) {
counter.set(0);
// if server is stopped due to egress limits, restart it
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Hourly update: refreshing statistics");
}
statistics.set(new Statistics());
if (counter == 80) {
counter = 0;
lastBytesSent = statistics.get().getBytesSent();
if (engine == null) {
if (LOGGER.isInfoEnabled()) {
@ -81,16 +107,30 @@ public class MangaDexClient {
loginAndStartServer();
}
} else {
counter.set(num + 1);
counter++;
}
statsMap.put(Instant.now(), statistics.get());
try {
DiskLruCache.Editor editor = cache.edit("statistics");
if (editor != null) {
String json = GSON.toJson(statistics.get(), Statistics.class);
editor.setString(0, json);
editor.setString(1, "");
editor.setString(2, "");
editor.commit();
}
} catch (IOException ignored) {}
// if the server is offline then don't try and refresh certs
if (engine == null) {
return;
}
if (clientSettings.getMaxBandwidthMibPerHour() != 0 && clientSettings.getMaxBandwidthMibPerHour() * 1024
* 1024 /* MiB to bytes */ < statistics.get().getBytesSent().get()) {
long currentBytesSent = statistics.get().getBytesSent() - lastBytesSent;
if (clientSettings.getMaxBandwidthMibPerHour() != 0
&& clientSettings.getMaxBandwidthMibPerHour() * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Shutting down server as hourly bandwidth limit reached");
}
@ -164,6 +204,12 @@ public class MangaDexClient {
logoutAndStopServer();
}
webUi.close();
try {
cache.close();
} catch (IOException e) {
LOGGER.error("Cache failed to close", e);
}
}
public static void main(String[] args) {
@ -171,37 +217,36 @@ public class MangaDexClient {
+ ") initializing\n");
System.out.println("Copyright (c) 2020, MangaDex Network");
try {
String file = "settings.json";
if (args.length == 1) {
file = args[0];
} else if (args.length != 0) {
MangaDexClient.dieWithError("Expected one argument: path to config file, or nothing");
}
ClientSettings settings = new Gson().fromJson(new FileReader(file), ClientSettings.class);
if (!ClientSettings.isSecretValid(settings.getClientSecret()))
MangaDexClient.dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters");
if (settings.getClientPort() == 0) {
MangaDexClient.dieWithError("Config Error: Invalid port number");
}
if (settings.getMaxCacheSizeMib() < 1024) {
MangaDexClient.dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)");
}
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Client settings loaded: {}", settings);
}
MangaDexClient client = new MangaDexClient(settings);
Runtime.getRuntime().addShutdownHook(new Thread(client::shutdown));
client.runLoop();
} catch (FileNotFoundException e) {
MangaDexClient.dieWithError(e);
String file = "settings.json";
if (args.length == 1) {
file = args[0];
} else if (args.length != 0) {
MangaDexClient.dieWithError("Expected one argument: path to config file, or nothing");
}
ClientSettings settings;
try {
settings = GSON.fromJson(new FileReader(file), ClientSettings.class);
} catch (FileNotFoundException ignored) {
settings = new ClientSettings();
LOGGER.warn("Settings file {} not found, generating file", file);
try (FileWriter writer = new FileWriter(file)) {
writer.write(GSON.toJson(settings));
} catch (IOException e) {
MangaDexClient.dieWithError(e);
}
}
validateSettings(settings);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Client settings loaded: {}", settings);
}
MangaDexClient client = new MangaDexClient(settings);
Runtime.getRuntime().addShutdownHook(new Thread(client::shutdown));
client.runLoop();
}
public static void dieWithError(Throwable e) {
@ -213,8 +258,48 @@ public class MangaDexClient {
public static void dieWithError(String error) {
if (LOGGER.isErrorEnabled()) {
LOGGER.error("Critical Error: " + error);
LOGGER.error("Critical Error: {}", error);
}
System.exit(1);
}
public static void validateSettings(ClientSettings settings) {
if (!isSecretValid(settings.getClientSecret()))
MangaDexClient.dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters");
if (settings.getClientPort() == 0) {
MangaDexClient.dieWithError("Config Error: Invalid port number");
}
if (settings.getMaxCacheSizeMib() < 1024) {
MangaDexClient.dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)");
}
if (settings.getThreads() < 4) {
MangaDexClient.dieWithError("Config Error: Invalid number of threads, must be >= 8");
}
if (settings.getMaxBandwidthMibPerHour() < 0) {
MangaDexClient.dieWithError("Config Error: Max bandwidth must be >= 0");
}
if (settings.getMaxBurstRateKibPerSecond() < 0) {
MangaDexClient.dieWithError("Config Error: Max burst rate must be >= 0");
}
if (settings.getWebSettings() != null) {
if (settings.getWebSettings().getUiPort() == 0) {
MangaDexClient.dieWithError("Config Error: Invalid UI port number");
}
if (settings.getWebSettings().getUiWebsocketPort() == 0) {
MangaDexClient.dieWithError("Config Error: Invalid websocket port number");
}
}
}
public static boolean isSecretValid(String clientSecret) {
final int CLIENT_KEY_LENGTH = 52;
return Pattern.matches("^[a-zA-Z0-9]{" + CLIENT_KEY_LENGTH + "}$", clientSecret);
}
}

View file

@ -3,6 +3,7 @@ package mdnet.base;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
import kong.unirest.json.JSONObject;
import mdnet.base.settings.ClientSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View file

@ -1,40 +0,0 @@
package mdnet.base;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class Statistics {
private final AtomicInteger requestsServed;
private final AtomicInteger cacheHits;
private final AtomicInteger cacheMisses;
private final AtomicLong bytesSent;
public Statistics() {
requestsServed = new AtomicInteger();
cacheHits = new AtomicInteger();
cacheMisses = new AtomicInteger();
bytesSent = new AtomicLong();
}
public AtomicInteger getRequestsServed() {
return requestsServed;
}
public AtomicInteger getCacheHits() {
return cacheHits;
}
public AtomicInteger getCacheMisses() {
return cacheMisses;
}
public AtomicLong getBytesSent() {
return bytesSent;
}
@Override
public String toString() {
return "Statistics{" + "requestsServed=" + requestsServed + ", cacheHits=" + cacheHits + ", cacheMisses="
+ cacheMisses + ", bytesSent=" + bytesSent + '}';
}
}

View file

@ -1,9 +1,8 @@
package mdnet.base;
package mdnet.base.settings;
import com.google.gson.annotations.SerializedName;
import java.util.Objects;
import java.util.regex.Pattern;
public final class ClientSettings {
@SerializedName("max_cache_size_mib")
@ -18,15 +17,28 @@ public final class ClientSettings {
private final String clientSecret;
@SerializedName("threads")
private final int threads;
@SerializedName("web_settings")
private final WebSettings webSettings;
public ClientSettings() {
this.maxCacheSizeMib = 20480;
this.maxBandwidthMibPerHour = 0;
this.maxBurstRateKibPerSecond = 0;
this.clientPort = 1200;
this.clientSecret = "PASTE-YOUR-SECRET-HERE";
this.threads = 32;
this.webSettings = new WebSettings();
}
public ClientSettings(long maxCacheSizeMib, long maxBandwidthMibPerHour, long maxBurstRateKibPerSecond,
int clientPort, String clientSecret, int threads) {
int clientPort, String clientSecret, int threads, WebSettings webSettings) {
this.maxCacheSizeMib = maxCacheSizeMib;
this.maxBandwidthMibPerHour = maxBandwidthMibPerHour;
this.maxBurstRateKibPerSecond = maxBurstRateKibPerSecond;
this.clientPort = clientPort;
this.clientSecret = Objects.requireNonNull(clientSecret);
this.threads = threads;
this.webSettings = webSettings;
}
public long getMaxCacheSizeMib() {
@ -48,9 +60,12 @@ public final class ClientSettings {
public String getClientSecret() {
return clientSecret;
}
public WebSettings getWebSettings() {
return webSettings;
}
public int getThreads() {
return (threads > 0) ? threads : 16;
return threads;
}
@Override
@ -59,9 +74,4 @@ public final class ClientSettings {
+ maxBandwidthMibPerHour + ", maxBurstRateKibPerSecond=" + maxBurstRateKibPerSecond + ", clientPort="
+ clientPort + ", clientSecret='" + "<hidden>" + '\'' + ", threads=" + getThreads() + '}';
}
public static boolean isSecretValid(String clientSecret) {
final int CLIENT_KEY_LENGTH = 52;
return Pattern.matches("^[a-zA-Z0-9]{" + CLIENT_KEY_LENGTH + "}$", clientSecret);
}
}

View file

@ -0,0 +1,33 @@
package mdnet.base.settings;
import com.google.gson.annotations.SerializedName;
public final class WebSettings {
@SerializedName("ui_websocket_port")
private final int uiWebsocketPort;
@SerializedName("ui_port")
private final int uiPort;
public WebSettings() {
this.uiWebsocketPort = 33333;
this.uiPort = 8080;
}
public WebSettings(int uiWebsocketPort, int uiPort) {
this.uiWebsocketPort = uiWebsocketPort;
this.uiPort = uiPort;
}
public int getUiWebsocketPort() {
return uiWebsocketPort;
}
public int getUiPort() {
return uiPort;
}
@Override
public String toString() {
return "WebSettings{" + "uiWebsocketPort=" + uiWebsocketPort + ", uiPort=" + uiPort + '}';
}
}

View file

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package mdnet.base;
package mdnet.cache;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.ProxyInputStream;

View file

@ -82,10 +82,9 @@ import java.util.regex.Pattern;
* <li>When an entry is being <strong>edited</strong>, it is not necessary to
* supply data for every value; values default to their previous value.
* </ul>
* Every {@link #editImpl} call must be matched by a call to
* {@link Editor#commit} or {@link Editor#abort}. Committing is atomic: a read
* observes the full set of values as they were before or after the commit, but
* never a mix of values.
* Every {@link #edit} call must be matched by a call to {@link Editor#commit}
* or {@link Editor#abort}. Committing is atomic: a read observes the full set
* of values as they were before or after the commit, but never a mix of values.
*
* <p>
* Clients call {@link #get} to read a snapshot of an entry. The read will
@ -412,7 +411,7 @@ public final class DiskLruCache implements Closeable {
return getImpl(key);
}
public synchronized Snapshot getImpl(String key) throws IOException {
private synchronized Snapshot getImpl(String key) throws IOException {
checkNotClosed();
Entry entry = lruEntries.get(key);
if (entry == null) {
@ -967,20 +966,7 @@ public final class DiskLruCache implements Closeable {
Path oldCache = Paths.get(directory + File.separator + key + "." + i);
Path newCache = Paths.get(directory + subKeyPath + File.separator + key + "." + i);
File newCacheDirectory = new File(directory + subKeyPath, key + "." + i + ".tmp");
newCacheDirectory.getParentFile().mkdirs();
if (Files.exists(oldCache)) {
try {
Files.move(oldCache, newCache, StandardCopyOption.ATOMIC_MOVE);
} catch (FileAlreadyExistsException faee) {
try {
Files.delete(oldCache);
} catch (IOException ex) {
}
} catch (IOException ex) {
}
}
migrateCacheFile(i, oldCache, newCache);
return new File(directory + subKeyPath, key + "." + i);
}
@ -990,6 +976,12 @@ public final class DiskLruCache implements Closeable {
Path oldCache = Paths.get(directory + File.separator + key + "." + i + ".tmp");
Path newCache = Paths.get(directory + subKeyPath + File.separator + key + "." + i + ".tmp");
migrateCacheFile(i, oldCache, newCache);
return new File(directory + subKeyPath, key + "." + i + ".tmp");
}
private void migrateCacheFile(int i, Path oldCache, Path newCache) {
File newCacheDirectory = new File(directory + subKeyPath, key + "." + i + ".tmp");
newCacheDirectory.getParentFile().mkdirs();
@ -999,13 +991,11 @@ public final class DiskLruCache implements Closeable {
} catch (FileAlreadyExistsException faee) {
try {
Files.delete(oldCache);
} catch (IOException ex) {
} catch (IOException ignored) {
}
} catch (IOException ex) {
} catch (IOException ignored) {
}
}
return new File(directory + subKeyPath, key + "." + i + ".tmp");
}
}
}

View file

@ -0,0 +1,107 @@
package mdnet.webui;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.concurrent.atomic.AtomicReference;
import mdnet.base.Statistics;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public abstract class WebConsole extends WebSocketServer {
private final static Logger LOGGER = LoggerFactory.getLogger(WebConsole.class);
public WebConsole(int port) {
super(new InetSocketAddress(port));
}
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
LOGGER.info("Webclient {} connected", conn);
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
LOGGER.info("Webclient {} disconnected: {} ", conn, reason);
}
@Override
public void onMessage(WebSocket conn, String message) {
parseMessage(message);
}
@Override
public void onMessage(WebSocket conn, ByteBuffer message) {
// parseMessage(message.array().toString());
}
@Override
public void onError(WebSocket conn, Exception ex) {
ex.printStackTrace();
if (conn != null) {
// some errors like port binding failed may not be assignable to a specific
// websocket
}
}
@Override
public void onStart() {
LOGGER.info("Listening for connections on port: {}", this.getPort());
setConnectionLostTimeout(0);
setConnectionLostTimeout(100);
}
protected abstract void parseMessage(String message);
// void parseCommand(String x) {
// switch (x) {
// case "help":
// this.broadcast(formatMessage("command", "Available commands:"));
// this.broadcast(formatMessage("command", "you"));
// this.broadcast(formatMessage("command", "are"));
// this.broadcast(formatMessage("command", "big"));
// this.broadcast(formatMessage("command", "gay"));
// break;
// case "stop":
// this.broadcast(formatMessage("command", "Mangadex Client has shut down,
// shutting down web client now"));
// return;
// default:
// this.broadcast(formatMessage("command", "That command was not recognized"));
// this.broadcast(formatMessage("command", "Try help for a list of available
// commands"));
// break;
// }
// }
public void sendMessage(String type, Object message) {
// JSONObject out = new JSONObject();
// switch (type) {
// case "command" :
// out.put("type", "command");
// out.put("data", message.toString());
// break;
// case "stats" :
// out.put("type", "stats");
// AtomicReference<Statistics> temp = (AtomicReference<Statistics>) message;
// out.put("hits", temp.get().getCacheHits());
// out.put("misses", temp.get().getCacheMisses());
// out.put("bytes_sent", temp.get().getBytesSent());
// out.put("req_served", temp.get().getRequestsServed());
// out.put("dataval", "empty");
// out.put("dataval", "empty");
// out.put("dataval", "empty");
// break;
// case "auth" :
// break;
// default :
// out.put("type", "command");
// out.put("data", message.toString());
// break;
// }
// broadcast(out.toString());
}
}

View file

@ -18,6 +18,7 @@ import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.stream.ChunkedWriteHandler
import io.netty.handler.traffic.GlobalTrafficShapingHandler
import io.netty.handler.traffic.TrafficCounter
import mdnet.base.settings.ClientSettings
import org.http4k.core.HttpHandler
import org.http4k.server.Http4kChannelHandler
import org.http4k.server.Http4kServer
@ -36,33 +37,33 @@ import javax.net.ssl.SSLException
private val LOGGER = LoggerFactory.getLogger("Application")
class Netty(private val tls: ServerSettings.TlsCert, private val clientSettings: ClientSettings, private val stats: AtomicReference<Statistics>) : ServerConfig {
private val threadsToAllocate = clientSettings.getThreads()
class Netty(private val tls: ServerSettings.TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
private val masterGroup = NioEventLoopGroup(threadsToAllocate)
private val workerGroup = NioEventLoopGroup(threadsToAllocate)
private val masterGroup = NioEventLoopGroup(clientSettings.threads)
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
private lateinit var closeFuture: ChannelFuture
private lateinit var address: InetSocketAddress
private val burstLimiter = object : GlobalTrafficShapingHandler(
workerGroup, 1024 * clientSettings.maxBurstRateKibPerSecond, 0, 50) {
workerGroup, 1024 * clientSettings.maxBurstRateKibPerSecond, 0, 50) {
override fun doAccounting(counter: TrafficCounter) {
stats.get().bytesSent.getAndAdd(counter.cumulativeWrittenBytes())
statistics.getAndUpdate {
it.copy(bytesSent = it.bytesSent + counter.cumulativeWrittenBytes())
}
counter.resetCumulativeTime()
}
}
override fun start(): Http4kServer = apply {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Starting webserver with {} threads", threadsToAllocate)
LOGGER.info("Starting webserver with {} threads", clientSettings.threads)
}
val (mainCert, chainCert) = getX509Certs(tls.certificate)
val sslContext = SslContextBuilder
.forServer(getPrivateKey(tls.privateKey), mainCert, chainCert)
.protocols("TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1")
.build()
.forServer(getPrivateKey(tls.privateKey), mainCert, chainCert)
.protocols("TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1")
.build()
val bootstrap = ServerBootstrap()
bootstrap.group(masterGroup, workerGroup)

View file

@ -0,0 +1,12 @@
package mdnet.base
import com.google.gson.annotations.SerializedName
data class Statistics(
@field:SerializedName("requests_served") val requestsServed: Int = 0,
@field:SerializedName("cache_hits") val cacheHits: Int = 0,
@field:SerializedName("cache_misses") val cacheMisses: Int = 0,
@field:SerializedName("browser_cached") val browserCached: Int = 0,
@field:SerializedName("bytes_sent") val bytesSent: Long = 0,
@field:SerializedName("bytes_on_disk") val bytesOnDisk: Long = 0
)

View file

@ -1,14 +1,18 @@
/* ktlint-disable no-wildcard-imports */
package mdnet.base
package mdnet.base.web
import mdnet.base.Constants
import mdnet.base.Netty
import mdnet.base.ServerSettings
import mdnet.base.Statistics
import mdnet.base.settings.ClientSettings
import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.ApacheClient
import org.http4k.core.BodyMode
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
@ -27,10 +31,6 @@ import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.InputStream
import java.security.MessageDigest
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher
@ -39,7 +39,7 @@ import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
private val LOGGER = LoggerFactory.getLogger("Application")
private val THREADS_TO_ALLOCATE = 262144 // 2**18 // Honestly, no reason to not just let 'er rip. Inactive connections will expire on their own :D
private const val THREADS_TO_ALLOCATE = 262144 // 2**18 // Honestly, no reason to not just let 'er rip. Inactive connections will expire on their own :D
fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSettings: ClientSettings, statistics: AtomicReference<Statistics>): Http4kServer {
val executor = Executors.newCachedThreadPool()
@ -49,16 +49,15 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
}
val client = ApacheClient(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
.setDefaultRequestConfig(RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.setDefaultRequestConfig(RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.build())
.setMaxConnTotal(THREADS_TO_ALLOCATE)
.setMaxConnPerRoute(THREADS_TO_ALLOCATE)
.build())
.setMaxConnTotal(THREADS_TO_ALLOCATE)
.setMaxConnPerRoute(THREADS_TO_ALLOCATE)
// Have it at the maximum open sockets a user can have in most modern OSes. No reason to limit this, just limit it at the Netty side.
.build())
val app = { dataSaver: Boolean ->
{ request: Request ->
@ -81,47 +80,43 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
md5Bytes("$chapterHash.$fileName")
}
val cacheId = printHexString(rc4Bytes)
statistics.get().requestsServed.incrementAndGet()
statistics.getAndUpdate {
it.copy(requestsServed = it.requestsServed + 1)
}
// Netty doesn't do Content-Length or Content-Type, so we have the pleasure of doing that ourselves
fun respondWithImage(input: InputStream, length: String?, type: String, lastModified: String?, cached: Boolean): Response =
Response(Status.OK)
.header("Content-Type", type)
.header("X-Content-Type-Options", "nosniff")
.header(
"Cache-Control",
listOf("public", MaxAgeTtl(Constants.MAX_AGE_CACHE).toHeaderValue()).joinToString(", ")
)
.header("Timing-Allow-Origin", "https://mangadex.org")
.let {
if (length != null) {
it.body(input, length.toLong()).header("Content-Length", length)
} else {
it.body(input).header("Transfer-Encoding", "chunked")
}
}
.let {
if (lastModified != null) {
it.header("Last-Modified", lastModified)
} else {
it
}
}
.let {
if (cached != null && cached == true) {
it.header("X-Cache", "HIT")
} else {
it.header("X-Cache", "MISS")
}
}
fun respondWithImage(input: InputStream, length: String?, type: String, lastModified: String?): Response =
Response(Status.OK)
.header("Content-Type", type)
.header("X-Content-Type-Options", "nosniff")
.header(
"Cache-Control",
listOf("public", MaxAgeTtl(Constants.MAX_AGE_CACHE).toHeaderValue()).joinToString(", ")
)
.header("Timing-Allow-Origin", "https://mangadex.org")
.let {
if (length != null) {
it.body(input, length.toLong()).header("Content-Length", length)
} else {
it.body(input).header("Transfer-Encoding", "chunked")
}
}
.let {
if (lastModified != null) {
it.header("Last-Modified", lastModified)
} else {
it
}
}
val snapshot = cache.get(cacheId)
if (snapshot != null) {
statistics.get().cacheHits.incrementAndGet()
// our files never change, so it's safe to use the browser cache
if (request.header("If-Modified-Since") != null) {
statistics.getAndUpdate {
it.copy(browserCached = it.browserCached + 1)
}
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri cached by browser")
}
@ -130,20 +125,26 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
snapshot.close()
Response(Status.NOT_MODIFIED)
.header("Last-Modified", lastModified)
.header("Last-Modified", lastModified)
} else {
statistics.getAndUpdate {
it.copy(cacheHits = it.cacheHits + 1)
}
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri hit cache")
}
respondWithImage(
CipherInputStream(BufferedInputStream(snapshot.getInputStream(0)), getRc4(rc4Bytes)),
snapshot.getLength(0).toString(), snapshot.getString(1), snapshot.getString(2),
true
CipherInputStream(BufferedInputStream(snapshot.getInputStream(0)), getRc4(rc4Bytes)),
snapshot.getLength(0).toString(), snapshot.getString(1), snapshot.getString(2)
)
}
} else {
statistics.get().cacheMisses.incrementAndGet()
statistics.getAndUpdate {
it.copy(cacheMisses = it.cacheMisses + 1)
}
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri missed cache")
}
@ -176,8 +177,8 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
editor.setString(2, lastModified)
val tee = CachingInputStream(
mdResponse.body.stream,
executor, CipherOutputStream(BufferedOutputStream(editor.newOutputStream(0)), getRc4(rc4Bytes))
mdResponse.body.stream,
executor, CipherOutputStream(BufferedOutputStream(editor.newOutputStream(0)), getRc4(rc4Bytes))
) {
// Note: if neither of the options get called/are in the log
// check that tee gets closed and for exceptions in this lambda
@ -195,14 +196,14 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
editor.abort()
}
}
respondWithImage(tee, contentLength, contentType, lastModified, false)
respondWithImage(tee, contentLength, contentType, lastModified)
} else {
editor?.abort()
if (LOGGER.isTraceEnabled) {
LOGGER.trace("Request for $sanitizedUri is being served")
}
respondWithImage(mdResponse.body.stream, contentLength, contentType, lastModified, false)
respondWithImage(mdResponse.body.stream, contentLength, contentType, lastModified)
}
}
}
@ -212,17 +213,17 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
CachingFilters
return catchAllHideDetails()
.then(ServerFilters.CatchLensFailure)
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to app(false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to app(true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to app(false),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to app(true)
.then(ServerFilters.CatchLensFailure)
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to app(false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to app(true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to app(false),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to app(true)
)
)
)
.asServer(Netty(serverSettings.tls, clientSettings, statistics))
.asServer(Netty(serverSettings.tls, clientSettings, statistics))
}
private fun getRc4(key: ByteArray): Cipher {
@ -231,33 +232,6 @@ private fun getRc4(key: ByteArray): Cipher {
return rc4
}
private val HTTP_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss O", Locale.ENGLISH)
private fun addCommonHeaders(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
response.header("Date", HTTP_TIME_FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)))
.header("Server", "Mangadex@Home Node ${Constants.CLIENT_VERSION} (${Constants.CLIENT_BUILD})")
}
}
}
private fun catchAllHideDetails(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
try {
next(request)
} catch (e: Exception) {
if (LOGGER.isWarnEnabled) {
LOGGER.warn("Request error detected", e)
}
Response(Status.INTERNAL_SERVER_ERROR)
}
}
}
}
private fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())

View file

@ -0,0 +1,47 @@
/* ktlint-disable no-wildcard-imports */
package mdnet.base.web
import mdnet.base.Statistics
import mdnet.base.settings.WebSettings
import org.http4k.core.Body
import org.http4k.core.Method
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.then
import org.http4k.filter.ServerFilters
import org.http4k.routing.ResourceLoader
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.routing.singlePageApp
import org.http4k.server.Http4kServer
import org.http4k.server.Netty
import org.http4k.server.asServer
import java.util.concurrent.atomic.AtomicReference
import org.http4k.format.Gson.auto
import java.time.Instant
fun getUiServer(
webSettings: WebSettings,
statistics: AtomicReference<Statistics>,
statsMap: Map<Instant, Statistics>
): Http4kServer {
val statsMapLens = Body.auto<Map<Instant, Statistics>>().toLens()
return catchAllHideDetails()
.then(ServerFilters.CatchLensFailure)
.then(addCommonHeaders())
.then(
routes(
"/api/stats" bind Method.GET to {
statsMapLens(mapOf(Instant.now() to statistics.get()), Response(Status.OK))
},
"/api/pastStats" bind Method.GET to {
synchronized(statsMap) {
statsMapLens(statsMap, Response(Status.OK))
}
},
singlePageApp(ResourceLoader.Classpath("/webui"))
)
)
.asServer(Netty(webSettings.uiPort))
}

View file

@ -0,0 +1,43 @@
/* ktlint-disable no-wildcard-imports */
package mdnet.base.web
import mdnet.base.Constants
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
import org.slf4j.LoggerFactory
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
private val HTTP_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss O", Locale.ENGLISH)
private val LOGGER = LoggerFactory.getLogger("Application")
fun addCommonHeaders(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
response.header("Date", HTTP_TIME_FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)))
.header("Server", "Mangadex@Home Node ${Constants.CLIENT_VERSION} (${Constants.CLIENT_BUILD})")
}
}
}
fun catchAllHideDetails(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
try {
next(request)
} catch (e: Exception) {
if (LOGGER.isWarnEnabled) {
LOGGER.warn("Request error detected", e)
}
Response(Status.INTERNAL_SERVER_ERROR)
}
}
}
}

View file

@ -1,4 +1,5 @@
<configuration>
<shutdownHook/>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${file-level:-TRACE}</level>

View file

@ -0,0 +1,705 @@
let connection;
let theme;
let style;
let port;
let ip;
let refreshRate;
let maxConsoleLines;
let graphTimeFrame;
let showConsoleLatest;
let doAnimations;
let lockDash;
//non-option var
let statRequest;
//stat vars
let hitmiss,
byte,
cached,
req;
//dockable things
let config = {
settings: {
hasHeaders: true,
constrainDragToContainer: false,
reorderEnabled: true,
selectionEnabled: false,
popoutWholeStack: false,
blockedPopoutsThrowError: true,
closePopoutsOnUnload: true,
showPopoutIcon: false,
showMaximiseIcon: false,
showCloseIcon: lockDash
},
dimensions: {
borderWidth: 20,
minItemHeight: 10,
minItemWidth: 10,
headerHeight: 20,
dragProxyWidth: 300,
dragProxyHeight: 200
},
labels: {
close: 'close',
maximise: 'maximise',
minimise: 'minimise',
popout: 'open in new window'
},
content: [{
type: 'column',
content: [{
type: 'row',
content: [{
type: 'column',
content: [{
type: 'row',
content: [{
type: 'component',
componentName: 'Hit Percent',
width: 50,
componentState: {label: 'F'}
}, {
type: 'component',
componentName: 'Hits',
componentState: {label: 'B'}
}, {
type: 'component',
componentName: 'Misses',
componentState: {label: 'C'}
}]
}, {
type: 'row',
content: [{
type: 'component',
componentName: 'Requests Served',
componentState: {label: 'B'}
}, {
type: 'component',
componentName: 'Bytes Sent',
componentState: {label: 'C'}
}]
}]
}, {
type: 'column',
content: [{
type: 'component',
componentName: 'Network Utilization',
componentState: {label: 'B'}
}, {
type: 'component',
componentName: 'CPU Utilization',
componentState: {label: 'C'}
}, {
type: 'component',
componentName: 'Disk Utilization',
componentState: {label: 'D'}
}, {
type: 'component',
componentName: 'RAM Utilization',
componentState: {label: 'E'}
}]
}]
}, {
type: 'row',
height: 20,
content: [{
type: 'component',
componentName: 'Cache Size',
componentState: {label: 'F'}
}]
}]
}]
};
let dashlayout;
function loadDash() {
let savedState = localStorage.getItem("dashState");
if (savedState !== null) {
dashlayout = new GoldenLayout(JSON.parse(savedState), $("#dashboard"));
} else {
dashlayout = new GoldenLayout(config, $("#dashboard"));
}
//graphs
dashlayout.registerComponent('Network Utilization', function (container, state) {
container.getElement().append('<div id="networkUtil" class="line_graph_data"></div>');
});
dashlayout.registerComponent('CPU Utilization', function (container, state) {
container.getElement().append('<div id="cpuUtil" class="line_graph_data"></div>');
});
dashlayout.registerComponent('Disk Utilization', function (container, state) {
container.getElement().append('<div id="discUtil" class="line_graph_data"></div>');
});
dashlayout.registerComponent('Cache Size', function (container, state) {
container.getElement().append('<div id="cacheSize" class="line_graph_data"></div>');
});
dashlayout.registerComponent('RAM Utilization', function (container, state) {
container.getElement().append(' <div id="ramUtil" class="line_graph_data"></div>');
});
// numbers
dashlayout.registerComponent('Hits', function (container, state) {
container.getElement().append('<div id="hits" class="numerical_data"></div>');
});
dashlayout.registerComponent('Misses', function (container, state) {
container.getElement().append('<div id="misses" class="numerical_data"></div>');
});
dashlayout.registerComponent('Requests Served', function (container, state) {
container.getElement().append('<div id="reqServed" class="numerical_data"></div>');
});
dashlayout.registerComponent('Bytes Sent', function (container, state) {
container.getElement().append('<div id="bytesSent" class="numerical_data"></div>');
});
dashlayout.registerComponent('Hit Percent', function (container, state) {
container.getElement().append('<div id="hitPercent" class="numerical_data"></div>');
});
dashlayout.init();
dashlayout.on('stateChanged', function () {
localStorage.setItem('dashState', JSON.stringify(dashlayout.toConfig()));
});
}
jQuery(document).ready(function () {
loadDash();
loadOptions();
$(window).resize(function () {
let dash = $("#dashboard");
dashlayout.updateSize(dash.width(), dash.height());
});
$("#theme").attr("href", "themes/" + theme + ".css");
$("#style").attr("href", "themes/" + style + ".css");
if (doAnimations) {
$(".optionInput").addClass("smooth");
$(".slider").addClass("smoothslider").addClass("smooth");
$(".content").addClass("slide_up");
$(".sideOption").addClass("smooth");
$(".button").addClass("smooth");
}
if (showConsoleLatest)
$("#consoleLatest").attr("hidden", false);
reconnect();
$("#console_input").keyup(function (e) {
if (e.keyCode === 13) {
sendCommand($(this).text());
$(this).text("");
$('#console_text').scrollTop($("#console_text")[0].scrollHeight)
}
});
loadStuff();
fetch("/api/allStats")
.then(response => async function () {
let respj = JSON.parse(await response.text());
updateValues(respj);
console.log(respj);
});
statRequest = setInterval(getStats, refreshRate);
});
function loadStuff() {
hitmiss = new Chart(document.getElementById('hitpie').getContext('2d'), {
type: 'doughnut',
data: {
datasets: [{
data: [0, 0, 0]
}],
labels: [
'Hits',
'Misses',
'Browser Cached'
]
},
options: {}
});
req = new Chart(document.getElementById('requestsserved').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Requests Served',
backgroundColor: "#f00",
borderColor: "#f00",
data: [],
fill: false
}]
},
options: {
maintainAspectRatio: false,
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
}
}
});
byte = new Chart(document.getElementById('bytessent').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Bytes Sent',
backgroundColor: "#f00",
borderColor: "#f00",
data: [],
fill: false
}]
},
options: {
maintainAspectRatio: false,
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
}
}
});
cached = new Chart(document.getElementById('browsercached').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Bytes On Disk',
backgroundColor: "#f00",
borderColor: "#f00",
data: [],
fill: false
}]
},
options: {
maintainAspectRatio: false,
scales: {
yAxes: [{
ticks: {
beginAtZero: true
}
}]
}
}
});
}
//site functions, no connections involved
$(window).on("click", function () {
let sideBar = $("#sideBar");
if (sideBar.hasClass("expanded")) {
sideBar.removeClass("expanded").addClass("retract").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("retract").removeClass("expanded");
}
);
}
});
function loadOptions() {
let options = JSON.parse(localStorage.getItem("options"));
if (options === null) {
options = {
refresh_rate: 5000,
theme: "lightTheme",
style: "sharpStyle",
client_port: 33333,
client_ip: "localhost",
max_console_lines: 1000,
show_console_latest: false,
graph_time_frame: 30000,
do_animations: true,
lock_dashboard: true
}
}
theme = options.theme;
style = options.style;
port = options.client_port;
ip = options.client_ip;
refreshRate = options.refresh_rate;
maxConsoleLines = options.max_console_lines;
graphTimeFrame = options.graph_time_frame;
showConsoleLatest = options.show_console_latest;
doAnimations = options.do_animations;
lockDash = options.lock_dashboard;
$("#dataRefreshRate").val(refreshRate);
$("#port").val(port);
$("#ip").val(ip);
$("#maxConsoleLines").val(maxConsoleLines);
$("#graphTimeFrame").val(graphTimeFrame);
$("#themeIn").val(theme);
$("#styleIn").val(style);
$("#newestconsole").prop("checked", showConsoleLatest);
$("#doAnimations").prop("checked", doAnimations);
$("#lockDash").prop("checked", lockDash)
}
function resetOptions() {
if (confirm("Do you really want to reset all customizations to defaults?")) {
$("#dataRefreshRate").val(5000);
$("#port").val(33333);
$("#ip").val("localhost");
$("#maxConsoleLines").val(1000);
$("#graphTimeFrame").val(30000);
$("#themeIn").val("lightTheme");
$("#styleIn").val("sharpStyle");
$("#newestconsole").prop("checked", false);
$("#doAnimations").prop("checked", true);
dashlayout.destroy();
localStorage.removeItem('dashState');
loadDash();
selectTab('dash', 'dashb');
let dash = $("#dashboard");
dashlayout.updateSize(dash.width(), dash.height());
applyOptions()
}
}
function applyOptions() {
let options = {
refresh_rate: parseInt($("#dataRefreshRate").val()),
theme: $("#themeIn").val(),
style: $("#styleIn").val(),
client_port: parseInt($("#port").val()),
client_ip: $("#ip").val(),
max_console_lines: parseInt($("#maxConsoleLines").val()),
show_console_latest: $("#newestconsole").prop("checked"),
graph_time_frame: parseInt($("#graphTimeFrame").val()),
do_animations: $("#doAnimations").prop("checked"),
lock_dashboard: $("#lockDash").prop("checked")
};
if (options.do_animations !== doAnimations) {
doAnimations = options.do_animations;
if (doAnimations) {
$(".optionInput").addClass("smooth");
$(".slider").addClass("smoothslider").addClass("smooth");
$(".content").addClass("slide_up");
$(".sideOption").addClass("smooth");
$(".button").addClass("smooth");
} else {
$(".optionInput").removeClass("smooth");
$(".slider").removeClass("smoothslider").removeClass("smooth");
$(".content").removeClass("slide_up");
$(".sideOption").removeClass("smooth");
$(".button").removeClass("smooth");
}
$("#doAnimationscb").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).prop("checked", doAnimations);
}
if (options.refresh_rate !== refreshRate) {
console.log(options.refresh_rate + " " + refreshRate);
refreshRate = Math.max(options.refresh_rate, 500);
$("#dataRefreshRate").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).val(refreshRate);
}
if (options.style !== style) {
style = options.style;
applyStyle(options.style);
$("#styleIn").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
);
}
if (options.theme !== theme) {
theme = options.theme;
applyTheme(options.theme);
$("#themeIn").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
);
}
if (options.client_port !== port) {
port = options.client_port;
$("#port").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).val(port);
reconnect();
}
if (options.client_ip !== ip) {
ip = options.client_ip;
$("#ip").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).val(ip);
reconnect();
}
if (options.graph_time_frame !== graphTimeFrame) {
graphTimeFrame = Math.max(options.graph_time_frame, 5000);
$("#graphTimeFrame").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).val(graphTimeFrame);
}
if (options.max_console_lines !== maxConsoleLines) {
maxConsoleLines = Math.max(options.max_console_lines, 100);
$("#maxConsoleLines").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).val(maxConsoleLines);
}
if (options.show_console_latest !== showConsoleLatest) {
showConsoleLatest = options.show_console_latest;
if (showConsoleLatest)
$("#consoleLatest").attr("hidden", false);
else
$("#consoleLatest").attr("hidden", true);
$("#newestconsolecb").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).prop("checked", showConsoleLatest);
}
if (options.lock_dashboard !== lockDash) {
lockDash = options.lock_dashboard;
config.settings.showCloseIcon = !lockDash;
// localStorage.setItem('dashState', JSON.stringify(dashlayout.toConfig()));
// $("#dashboard").empty();
// loadDash();
$("#lockDashcb").addClass("updated").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("updated");
}
).prop("checked", showConsoleLatest);
}
localStorage.setItem("options", JSON.stringify(options));
}
function selectTab(t, l) {
let sideBar = $("#sideBar");
sideBar.children("div").each(function () {
let tmp = $(this);
if (tmp.attr("id") === t) {
tmp.addClass("sideSelected");
} else
tmp.removeClass("sideSelected");
});
$("#content").children("div").each(function () {
let tmp = $(this);
if (tmp.attr("id") === l) {
tmp.attr("hidden", false);
} else
tmp.attr("hidden", true);
});
if (sideBar.hasClass("expanded")) {
sideBar.removeClass("expanded").addClass("retract").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("retract").removeClass("expanded");
}
);
}
}
function expSide() {
let sideBar = $("#sideBar");
if (sideBar.hasClass("expanded")) {
sideBar.removeClass("expanded").addClass("retract").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).removeClass("retract").removeClass("expanded");
}
);
} else {
sideBar.addClass("expand").on(
"animationend webkitAnimationEnd oAnimationEnd MSAnimationEnd",
function () {
$(this).addClass("expanded").removeClass("expand");
}
);
}
}
function applyTheme(t) {
if (doAnimations)
$(document.body).children().each(function () {
if (!($(this).attr("hidden")))
$(this).addClass("tempsmooth").on(
"webkitTransitionEnd otransitionend oTransitionEnd msTransitionEnd transitionend",
function () {
$(this).removeClass("tempsmooth");
}
);
});
$("#theme").attr("href", "themes/" + t + ".css");
}
function applyStyle(s) {
if (doAnimations)
$(document.body).children().each(function () {
if (!($(this).attr("hidden")))
$(this).addClass("tempsmooth").on(
"webkitTransitionEnd otransitionend oTransitionEnd msTransitionEnd transitionend",
function () {
$(this).removeClass("tempsmooth");
}
);
});
$("#style").attr("href", "themes/" + s + ".css");
}
//update data functions
function updateWithMessage(m) {
//TODO: get this to talk with client
let result;
try {
result = JSON.parse(m);
switch (result.type) {
case "command":
updateConsole(result.data, 2);
break;
case "stats":
updateValues();
break;
default:
updateConsole("[WEB-INFO] The message received is improperly formatted: " + result.data, 2);
break;
}
} catch (e) {
updateConsole("[WEB-INFO] There was an error parsing the data \n" + e, 2);
}
}
function getStats() {
fetch("/api/stats")
.then(response => response.json())
.then(response => {
updateValues(response);
console.log(response);
});
//TODO: use values and update web info
}
function updateValues(data) {
for (let key in data) {
if (data.hasOwnProperty(key)) {
let x = data[key];
hitmiss.data.datasets[0].data[0] = x.cache_hits;
hitmiss.data.datasets[0].data[1] = x.cache_misses;
hitmiss.data.datasets[0].data[2] = x.browser_cached;
hitmiss.update()
req.data.labels.push(key);
req.data.datasets.forEach((dataset) => {
dataset.data.push(x.requests_served);
});
req.update()
byte.data.labels.push(key);
byte.data.datasets.forEach((dataset) => {
dataset.data.push(x.bytes_sent);
});
byte.update()
cached.data.labels.push(key);
cached.data.datasets.forEach((dataset) => {
dataset.data.push(x.bytes_on_disk);
});
cached.update()
}
}
}
//console functions
function updateConsole(x, status) {
let scroll = false;
let temp = $('#console_text');
let latest = $("#consoleLatest");
if (temp.scrollTop() === (temp[0].scrollHeight - temp[0].clientHeight))
scroll = true;
switch (status) {
case 1:
temp.append('<div class="consoleLine sent">' + x + '</div>');
break;
case 0:
temp.append('<div class="consoleLine unsent">' + x + '</div>');
break;
default:
temp.append('<div class="consoleLine">' + x + '</div>');
latest.html('<div class="consoleLine">' + x + '</div>');
}
let childs = temp.children();
if (childs.length > maxConsoleLines) {
let length = childs.length;
for (let i = 0; i < length - maxConsoleLines; i++) {
childs[i].remove();
}
}
if (scroll)
temp.scrollTop(temp[0].scrollHeight);
}
function sendCommand(x) {
if (x === "")
return;
if (connection.readyState === "OPEN") {
let data = {
type: "command",
data: x
};
let message = JSON.stringify(data);
connection.send(message);
} else {
updateConsole(x, 0);
}
}
//network commuication
function reconnect() {
if (connection != null)
connection.close();
updateConsole("[WEB-CONSOLE] Attempting to connect to client on " + ip + ":" + port, 2);
connection = new WebSocket("ws://" + ip + ":" + port);
$("#connection").removeClass("disconnected").removeClass("connected").addClass("connecting").text("Connecting");
addListeners(connection)
}
function addListeners(c) {
let opened = false;
c.onopen = function (event) {
$("#connection").removeClass("disconnected").removeClass("connecting").addClass("connected").text("Connected");
opened = true;
updateConsole("[WEB-CONSOLE] Successfully to connect to client on " + ip + ":" + port, 2);
};
c.onclose = function (event) {
$("#connection").addClass("disconnected").removeClass("connecting").removeClass("connected").text("Disconnected");
if (opened)
updateConsole("[WEB-CONSOLE] Disconnected from client");
else
updateConsole("[WEB-CONSOLE] Failed to connect to client on " + ip + ":" + port, 2);
// clearInterval(statRequest);
};
c.onmessage = function (event) {
updateWithMessage(event.data());
};
}
function requestStats() {
let req = {type: "stats"};
connection.send(JSON.stringify(req));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View file

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MD@H Client</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0"></script>
<script type="text/javascript" src="https://golden-layout.com/files/latest/js/goldenlayout.min.js"></script>
<script src="dataReceive.js"></script>
<link rel="stylesheet" type="text/css" href="layout.css">
<link rel="stylesheet" type="text/css" href="" id="style">
<link rel="stylesheet" type="text/css" href="themes/darkTheme.css" id="theme">
</head>
<body>
<div id="pageBar">
<a href="https://mangadex.org/">
<img src="https://mangadex.org/images/misc/navbar.svg?3" alt="mangadex" width="65px"
height="65px" style="float: left; padding: 5px; border-radius: 50%">
</a>
<h1 style="position: absolute; left: 75px; margin-left: 10px">MangaDex@Home Client Interface</h1>
<div id="consoleLatest" hidden></div>
<button id="connection" class="connecting button" onclick="reconnect()">Disconnected</button>
</div>
<div id="sideBar" >
<div id="expSide" class="sideOption" onclick="expSide()">
<img src="icons/showmore.png" alt="dash" width="30px" height="30px" style="padding: 10px" class="img">
<h2 style="position: absolute; left: 50px; top: 0; margin: calc((50px - 29px)/2)">Menu</h2>
</div>
<div id="dash" class="sideOption sideSelected" onclick="selectTab('dash','dashb')">
<img src="icons/dashboard.png" alt="dash" width="30px" height="30px" style="padding: 10px" class="img">
<h2 style="position: absolute; left: 50px; top: 50px; margin: calc((50px - 29px)/2)">Dashboard</h2>
</div>
<div id="opt" class="sideOption" onclick="selectTab('opt','dashOptions')">
<img src="icons/options.png" alt="dash" width="30px" height="30px" style="padding: 10px" class="img">
<h2 style="position: absolute; left: 50px; top: 100px; margin: calc((50px - 29px)/2)">Options</h2>
</div>
</div>
<div id="content">
<div id="dashb" class="content" style="overflow-y: scroll">
<div class="contentHeader">
<h1 style="margin: 0;position:absolute; top: 42px; padding-left: 30px">Dashboard</h1>
</div>
<div id="dashboard" hidden></div>
<div id="pleasejustwork" style="height: calc(100% - 140px); width: calc(100% - 40px); margin: 20px">
<div id="thelonelynumber" style="position: absolute; width: 30%; margin: 20px;">
<div class="line_graph_data">
<canvas id="hitpie"></canvas>
</div>
</div>
<div id="thegraphfamily"
style="position: absolute; width: calc(70% - 80px); margin: 20px; left: calc(30% + 40px); top: 100px">
<div style="margin: 20px; width: calc(100% - 40px)" class="line_graph_data">
<canvas id="bytessent" style="height: 250px"></canvas>
</div>
<div style="margin: 20px; width: calc(100% - 40px)" class="line_graph_data">
<canvas id="requestsserved" style="height: 250px"></canvas>
</div>
<div style="margin: 20px; width: calc(100% - 40px)" class="line_graph_data">
<canvas id="browsercached" style="height: 250px"></canvas>
</div>
</div>
</div>
</div>
<div id="console" class="content" hidden>
<div class="contentHeader">
<h1 style="margin: 0;position:absolute; top: 42px; padding-left: 30px">Console</h1>
</div>
<div id="buttonBoard">
<!-- client control stuffs-->
<h2 style="margin-left: 40px">Client Status: Stopped</h2>
</div>
<div id="liveConsole">
<div id="console_text"></div>
<div id="console_input" contenteditable="true"></div>
</div>
</div>
<div id="dashOptions" class="content" hidden>
<div class="contentHeader">
<h1 style="margin: 0;position:absolute; top: 42px; padding-left: 30px">Options</h1>
</div>
<div id="options">
<h3>General</h3>
<div class="option">
<h4>Data Refresh Rate</h4>
<label><input id="dataRefreshRate" type="number" class="optionInput input" placeholder="5000" min="500"></label>
</div>
<h3>Display</h3>
<div class="option">
<h4>Theme</h4>
<label><select id="themeIn" class="optionInput input">
<option value="lightTheme">Light</option>
<option value="darkTheme">Dark</option>
<option value="midnightTheme">Midnight</option>
<option value="eyekillerTheme">High Vibrancy (not maintained)</option>
</select></label>
</div>
<div class="option">
<h4>Style</h4>
<label><select id="styleIn" class="optionInput input">
<option value="sharpStyle">Sharp</option>
<option value="softStyle">Soft</option>
</select></label>
</div>
<div class="option">
<h4>Animations</h4>
<label class="switch switchInput">
<input id="doAnimations" type="checkbox">
<span id="doAnimationscb" class="slider"></span>
</label>
</div>
</div>
<button id="apply" class="button" onclick="applyOptions()">Apply</button>
<button id="reset" class="button" onclick="resetOptions()">Reset</button>
</div>
<div id="info" class="content" hidden>
<div class="contentHeader">
<h1 style="margin: 0;position:absolute; top: 42px; padding-left: 30px">Client Info</h1>
</div>
<div id="information">
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,680 @@
body {
margin: 0;
font-family: Calibri, serif;
overflow: hidden;
}
.smooth {
-webkit-transition: .4s;
transition: .4s;
}
.tempsmooth {
-webkit-transition: .4s;
transition: .4s;
}
/*Content holder positions*/
#pageBar {
height: 75px;
width: 100%;
position: absolute;
}
#consoleLatest {
position: absolute;
min-height: 20px;
width: calc(100% - 755px);
margin: 30px 20px 25px 20px;
left: 545px;
overflow-x: scroll;
overflow-y: hidden;
}
#consoleLatest::-webkit-scrollbar {
height: 3px;
}
#connection {
position: absolute;
right: 0;
margin: 20px 20px 20px 20px;
height: 35px;
border-style: solid;
outline: none;
}
.connecting {
animation: connecting 1.5s;
animation-iteration-count: infinite;
}
@keyframes connecting {
0%, 100% {
filter: brightness(120%)
}
50% {
filter: brightness(80%)
}
}
#sideBar {
height: calc(100% - 75px);
width: 50px;
top: 75px;
position: absolute;
z-index: 10;
overflow: hidden;
user-select: none;
}
.sideOption {
width: 100%;
height: 50px;
float: left;;
}
.expand {
animation: expand 150ms ease-out;
-webkit-animation-fill-mode: forwards;
}
.expanded {
width: 200px !important;
}
.retract {
animation: expand 150ms reverse ease-in;
-webkit-animation-fill-mode: forwards;
}
@keyframes expand {
0% {
width: 50px
}
100% {
width: 200px
}
}
#content {
height: calc(100% - 75px);
width: calc(100% - 50px);
top: 75px;
left: 50px;
position: absolute;
/*overflow-y: auto;*/
}
.contentHeader {
width: calc(100% - 40px);
height: 80px;
margin: 20px;
}
/*Main dashboard positions*/
#dashb {
width: 100%;
height: 100%;
position: absolute;
}
#dashboard{
position: absolute;
margin: 20px;
width: calc(100% - 40px);
height: calc(100% - 140px);
top: 100px;
}
.numerical_data {
height: 100%;
width: 100%;
}
#gDat {
height: calc(100% - 140px);
width: calc(60% - 60px);
margin: 20px;
position: absolute;
top: 100px;
left: calc(40% + 20px);
overflow-y: scroll;
}
/*.line_graph_data {*/
/* height: 100%;*/
/* width: 100%;*/
/*}*/
/*Console and options positions*/
#console {
width: 100%;
height: 100%;
position: absolute;
}
#buttonBoard {
width: calc(100% - 40px);
height: calc(40% - 20px);
margin: 20px;
position: absolute;
top: 100px;
}
#liveConsole {
width: calc(100% - 40px);
height: calc(60% - 180px);
margin: 20px;
position: absolute;
top: calc(40% + 100px);
padding-bottom: 40px;
font-family: monospace;
}
.consoleLine {
width: calc(100% - 5px);
float: left;
margin: 0 5px 0;
left: 0;
white-space: nowrap;
}
.consoleLine > p {
margin: 0;
}
#console_input {
position: absolute;
width: calc(100% - 30px);
height: 20px;
bottom: 10px;
left: 0;
margin: 0 15px;
padding-top: 10px;
padding-bottom: 10px;
white-space: nowrap;
border-width: 0;
outline: none;
background-color: inherit;
overflow: hidden;
}
#console_text {
height: calc(100% - 40px);
width: calc(100% - 20px);
position: absolute;
outline: none;
border-width: 0;
resize: none;
font-family: monospace;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 40px;
background-color: rgba(0, 0, 0, 0);
overflow: scroll;
}
/*Web option positions*/
#dashOptions {
width: 100%;
height: 100%;
position: absolute;
}
#options {
width: calc(100% - 80px);
height: calc(100% - 140px);
position: absolute;
top: 100px;
margin: 20px 20px 20px 60px;
}
#apply {
position: fixed;
bottom: 20px;
right: 20px;
width: 150px;
height: 30px;
}
#reset{
position: fixed;
bottom: 20px;
right: 190px;
}
.option {
height: 40px;
margin: 10px;
}
.option > h4 {
margin: 0;
float: left;
font-weight: normal;
}
.optionLabel {
}
.optionInput {
left: 200px;
position: absolute;
border-style: solid;
border-width: 2px;
}
.switchInput > span {
left: 200px;
position: absolute;
border-style: solid;
}
.updated {
animation: fade 1.5s linear;
}
@keyframes fade {
0%, 100% {
filter: alpha(100%);
}
100% {
filter: alpha(0%);
}
}
/*misc modifications*/
.input {
outline: 0;
}
.button {
outline: none;
border-width: 2px;
width: 150px;
height: 30px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
}
.slider:before {
/*border-width: 1px;*/
position: absolute;
content: "";
}
.smoothslider:before {
position: absolute;
content: "";
-webkit-transition: .4s;
transition: .4s;
}
/*Webkit modifications*/
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-button {
width: 0;
height: 0;
}
::-webkit-scrollbar-thumb {
}
::-webkit-scrollbar-track {
}
::-webkit-scrollbar-corner {
background-color: rgba(0, 0, 0, 0);
}
/*animations*/
.slide_up {
animation: slideup .1s ease-out;
}
@keyframes slideup {
0% {
transform: translateY(50px);
opacity: 0;
}
100% {
transform: translateY(0px);
opacity: 1;
}
}
/*golden layout*/
.lm_root {
position: relative
}
.lm_row > .lm_item {
float: left
}
.lm_content {
overflow: hidden;
position: relative
}
.lm_dragging, .lm_dragging * {
cursor: move !important;
user-select: none
}
.lm_maximised {
position: absolute;
top: 0;
left: 0;
z-index: 40
}
.lm_maximise_placeholder {
display: none
}
.lm_splitter {
position: relative;
z-index: 20
}
.lm_splitter:hover, .lm_splitter.lm_dragging {
background: orange
}
.lm_splitter.lm_vertical .lm_drag_handle {
width: 100%;
height: 15px;
position: absolute;
top: -5px;
cursor: ns-resize
}
.lm_splitter.lm_horizontal {
float: left;
height: 100%
}
.lm_splitter.lm_horizontal .lm_drag_handle {
width: 15px;
height: 100%;
position: absolute;
left: -5px;
cursor: ew-resize
}
.lm_header {
overflow: visible;
position: relative;
z-index: 1
}
.lm_header [class^=lm_] {
box-sizing: content-box !important
}
.lm_header .lm_controls {
position: absolute;
right: 3px
}
.lm_header .lm_controls > li {
cursor: pointer;
float: left;
width: 18px;
height: 18px;
text-align: center
}
.lm_header ul {
margin: 0;
padding: 0;
list-style-type: none
}
.lm_header .lm_tabs {
position: absolute;
}
.lm_header .lm_tab {
cursor: pointer;
float: left;
height: 14px;
padding: 0 25px 5px 10px;
top: 1px;
position: relative
}
.lm_header .lm_tab i {
width: 2px;
height: 19px;
position: absolute
}
.lm_header .lm_tab i.lm_left {
top: 0;
left: -2px
}
.lm_header .lm_tab i.lm_right {
top: 0;
right: -2px
}
.lm_header .lm_tab .lm_title {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis
}
.lm_header .lm_tab .lm_close_tab {
width: 14px;
height: 14px;
position: absolute;
top: 0;
right: 0;
text-align: center
}
.lm_stack.lm_left .lm_header, .lm_stack.lm_right .lm_header {
height: 100%
}
.lm_dragProxy.lm_left .lm_header, .lm_dragProxy.lm_right .lm_header, .lm_stack.lm_left .lm_header, .lm_stack.lm_right .lm_header {
width: 20px;
float: left;
vertical-align: top
}
.lm_dragProxy.lm_left .lm_header .lm_tabs, .lm_dragProxy.lm_right .lm_header .lm_tabs, .lm_stack.lm_left .lm_header .lm_tabs, .lm_stack.lm_right .lm_header .lm_tabs {
transform-origin: left top;
top: 0;
width: 1000px
}
.lm_dragProxy.lm_left .lm_header .lm_controls, .lm_dragProxy.lm_right .lm_header .lm_controls, .lm_stack.lm_left .lm_header .lm_controls, .lm_stack.lm_right .lm_header .lm_controls {
bottom: 0
}
.lm_dragProxy.lm_left .lm_items, .lm_dragProxy.lm_right .lm_items, .lm_stack.lm_left .lm_items, .lm_stack.lm_right .lm_items {
float: left
}
.lm_dragProxy.lm_left .lm_header .lm_tabs, .lm_stack.lm_left .lm_header .lm_tabs {
transform: rotate(-90deg) scaleX(-1);
left: 0
}
.lm_dragProxy.lm_left .lm_header .lm_tabs .lm_tab, .lm_stack.lm_left .lm_header .lm_tabs .lm_tab {
transform: scaleX(-1);
margin-top: 1px
}
.lm_dragProxy.lm_left .lm_header .lm_tabdropdown_list, .lm_stack.lm_left .lm_header .lm_tabdropdown_list {
top: initial;
right: initial;
left: 20px
}
.lm_dragProxy.lm_right .lm_content {
float: left
}
.lm_dragProxy.lm_right .lm_header .lm_tabs, .lm_stack.lm_right .lm_header .lm_tabs {
transform: rotate(90deg) scaleX(1);
left: 100%;
margin-left: 0
}
.lm_dragProxy.lm_right .lm_header .lm_controls, .lm_stack.lm_right .lm_header .lm_controls {
left: 3px
}
.lm_dragProxy.lm_right .lm_header .lm_tabdropdown_list, .lm_stack.lm_right .lm_header .lm_tabdropdown_list {
top: initial;
right: 20px
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab, .lm_stack.lm_bottom .lm_header .lm_tab {
margin-top: 0;
border-top: none
}
.lm_dragProxy.lm_bottom .lm_header .lm_controls, .lm_stack.lm_bottom .lm_header .lm_controls {
top: 3px
}
.lm_dragProxy.lm_bottom .lm_header .lm_tabdropdown_list, .lm_stack.lm_bottom .lm_header .lm_tabdropdown_list {
top: initial;
bottom: 20px
}
.lm_drop_tab_placeholder {
float: left;
width: 100px;
height: 10px;
visibility: hidden
}
.lm_header .lm_controls .lm_tabdropdown:before {
content: '';
width: 0;
height: 0;
vertical-align: middle;
display: inline-block;
border-top: 5px dashed;
border-right: 5px solid transparent;
border-left: 5px solid transparent;
color: white
}
.lm_header .lm_tabdropdown_list {
position: absolute;
top: 20px;
right: 0;
z-index: 5;
overflow: hidden
}
.lm_header .lm_tabdropdown_list .lm_tab {
clear: both;
padding-right: 10px;
margin: 0
}
.lm_header .lm_tabdropdown_list .lm_tab .lm_title {
width: 100px
}
.lm_header .lm_tabdropdown_list .lm_close_tab {
display: none !important
}
.lm_dragProxy {
position: absolute;
top: 0;
left: 0;
z-index: 30
}
.lm_dragProxy .lm_header {
background: transparent
}
.lm_dragProxy .lm_content {
border-top: none;
overflow: hidden
}
.lm_dropTargetIndicator {
display: none;
position: absolute;
z-index: 20
}
.lm_dropTargetIndicator .lm_inner {
width: 100%;
height: 100%;
position: relative;
top: 0;
left: 0
}
.lm_transition_indicator {
display: none;
width: 20px;
height: 20px;
position: absolute;
top: 0;
left: 0;
z-index: 20
}
.lm_popin {
width: 20px;
height: 20px;
position: absolute;
bottom: 0;
right: 0;
z-index: 9999
}
.lm_popin > * {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0
}
.lm_popin > .lm_bg {
z-index: 10
}
.lm_popin > .lm_icon {
z-index: 20
}
/*# sourceMappingURL=goldenlayout-base.css.map */

View file

@ -0,0 +1,334 @@
body {
background-color: #404040;
color: #f0f0f0;
}
#pageBar {
background-color: #303030;
}
#consoleLatest{
background-color: black;
color: #f0f0f0;
}
.connected {
border-color: #0fff00 !important;
}
.disconnected {
border-color: #e50100 !important;
}
.connecting {
border-color: #e5d700 !important;
}
#sideBar {
background-color: #303030;
}
.sideOption {
background-color: #303030;
}
.sideOption:hover {
background-color: #404040;
}
.sideSelected {
background-color: #606060;
}
#content {
background-color: #404040;
}
.contentHeader {
background-color: #606060;
}
/*Main dashboard colors*/
#dashb {
}
#nDat {
}
.numerical_data {
background-color: #555555;
}
#gDat {
}
.line_graph_data {
background-color: #555555;
}
/*Console and options colors*/
#liveConsole {
background-color: black;
caret-color: #f0f0f0;
}
#console_text {
color: #f0f0f0;
}
#console_input {
color: #f0f0f0;
}
.unsent {
color: #e50100;
}
.sent {
color: #0fff00;
}
/*Web option colors*/
#dashOptions {
}
#options {
}
.option {
}
.optionLabel {
}
.optionInput {
border-color: rgba(0, 0, 0, 0);
background-color: #606060;
color: #f0f0f0;
}
/*misc*/
.button{
border-color: rgba(0, 0, 0, 0);
background-color: #606060;
color: #f0f0f0;
}
.button:hover{
background-color: #909090;
}
.img {
filter: invert(100%);
}
.updated {
border-color: #1ec70d !important;
}
.slider {
border-color: rgba(0, 0, 0, 0);
background-color: #606060;
}
.slider::before{
background-color: #f0f0f0;
}
input:checked + .slider {
background-color: #909090;
}
/*Webkit colors*/
::-webkit-scrollbar {
}
::-webkit-scrollbar-button {
}
::-webkit-scrollbar-thumb {
background-color: #555555;
}
::-webkit-scrollbar-thumb:hover {
background-color: #888888;
}
::-webkit-scrollbar-track {
}
/*golden layout*/
.lm_goldenlayout {
background: rgba(0, 0, 0, 0);
}
.lm_content {
background: rgba(0, 0, 0, 0);
}
.lm_dragProxy .lm_content {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.9)
}
.lm_dropTargetIndicator {
box-shadow: inset 0 0 30px #000000;
outline: 1px dashed #cccccc;
transition: all 200ms ease
}
.lm_dropTargetIndicator .lm_inner {
background: #000000;
opacity: .2
}
.lm_splitter {
background: inherit;
opacity: .001;
transition: opacity 200ms ease
}
.lm_splitter:hover, .lm_splitter.lm_dragging {
background: #303030;
opacity: 1
}
.lm_header {
background-color: #606060;
height: 20px;
user-select: none
}
.lm_header.lm_selectable {
cursor: pointer
}
.lm_header .lm_tab {
font-size: 12px;
color: #f0f0f0;
background-color: #303030;
margin-right: 2px;
padding-bottom: 2px;
padding-top: 1px
}
.lm_header .lm_tab .lm_close_tab {
width: 9px;
height: 9px;
background-image: url("../icons/close.png");
filter: invert(100%);
background-size: 14px 14px;
background-position: center center;
background-repeat: no-repeat;
top: 4px;
right: 6px;
opacity: .4
}
.lm_header .lm_tab .lm_close_tab:hover {
opacity: 1
}
.lm_header .lm_tab.lm_active {
background-color: #909090;
}
.lm_header .lm_tab.lm_active .lm_close_tab:hover {
opacity: 1
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab, .lm_stack.lm_bottom .lm_header .lm_tab {
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3)
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab.lm_active, .lm_stack.lm_bottom .lm_header .lm_tab.lm_active {
box-shadow: 0 2px 2px #000000
}
.lm_selected .lm_header {
background-color: #452500
}
.lm_tab:hover, .lm_tab.lm_active {
background: #202020;
color: #f0f0f0
}
.lm_header .lm_controls .lm_tabdropdown:before {
color: #ffffff
}
.lm_controls > li {
position: relative;
background-position: center center;
background-repeat: no-repeat;
opacity: .4;
transition: opacity 300ms ease
}
.lm_controls > li:hover {
opacity: 1
}
.lm_controls .lm_popout {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAPklEQVR4nI2Q0QoAIAwCNfr/X7aXCpGN8snBdgejJOzckpkxs9jR6K6T5JpU0nWl5pSXTk7qwh8SnNT+CAAWCgkKFpuSWsUAAAAASUVORK5CYII=)
}
.lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKElEQVR4nGP8////fwYCgImQAgYGBgYWKM2IR81/okwajIpgvsMbVgAwgQYRVakEKQAAAABJRU5ErkJggg==)
}
.lm_controls .lm_close {
top: 2px;
width: 18px !important;
height: 18px !important;
background-image: url("../icons/close.png");
background-size: 16px 16px;
background-position: center center;
filter: invert(100%);
}
.lm_maximised .lm_header {
background-color: #000000
}
.lm_maximised .lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAJ0lEQVR4nGP8//8/AzGAiShVI1YhCwMDA8OsWbPwBmZaWhoj0SYCAN1lBxMAX4n0AAAAAElFTkSuQmCC)
}
.lm_transition_indicator {
background-color: #000000;
border: 1px dashed #555555
}
.lm_popin {
cursor: pointer
}
.lm_popin .lm_bg {
background: #ffffff;
opacity: .3
}
.lm_popin .lm_icon {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAJCAYAAADpeqZqAAAAWklEQVR4nJWOyw3AIAxDHcQC7L8jbwT3AlJBfNp3SiI7dtRaLSlKKeoA1oEsKSQZCEluexw8Tm3ohk+E7bnOUHUGcNh+HwbBygw4AZ7FN/Lt84p0l+yTflV8AKQyLdcCRJi/AAAAAElFTkSuQmCC);
background-position: center center;
background-repeat: no-repeat;
border-left: 1px solid #eeeeee;
border-top: 1px solid #eeeeee;
opacity: .7
}
.lm_popin:hover .lm_icon {
opacity: 1
}
/*# sourceMappingURL=goldenlayout-dark-theme.css.map */

View file

@ -0,0 +1,97 @@
body {
background-color: #ffffff;
color: #202020;
}
#pageBar {
background-color: #faff00;
}
#sideBar {
background-color: #faff00;
}
.sideOption {
}
.sideOption:hover {
background-color: #faff00;
}
.sideSelected {
background-color: #faff00;
}
#content {
background-color: #0fff00;
}
.contentHeader {
background-color: #ff00af;
}
/*Main dashboard colors*/
#dashb {
}
#nDat {
}
.numerical_data {
background-color: #00ffec;
}
#gDat {
}
.line_graph_data {
background-color: #00ffec;
}
/*Console and options colors*/
#liveConsole {
background-color: black;
caret-color: #f0f0f0;
}
#console_text {
color: #f0f0f0;
}
#console_input {
color: #f0f0f0;
}
/*misc*/
.img {
}
.updated {
border-color: #1ec70d;
}
/*Webkit colors*/
::-webkit-scrollbar {
}
::-webkit-scrollbar-button {
}
::-webkit-scrollbar-thumb {
background-color: #aaaaaa;
}
::-webkit-scrollbar-thumb:hover {
background-color: #888888;
}
::-webkit-scrollbar-track {
}

View file

@ -0,0 +1,329 @@
body {
background-color: #ffffff;
color: #202020;
}
#pageBar {
background-color: #f8f9fa;
}
#consoleLatest{
background-color: black;
color: #f0f0f0;
}
.connected {
border-color: #0fff00 !important;
}
.disconnected {
border-color: #e50100 !important;
}
.connecting {
border-color: #e5d700 !important;
}
#sideBar {
background-color: #f8f9fa;
}
.sideOption {
}
.sideOption:hover {
background-color: #eeeeee;
}
.sideSelected {
background-color: #e1e1e1;
}
#content {
background-color: #ffffff;
}
.contentHeader {
background-color: #ededed;
}
/*Main dashboard colors*/
#dashb {
}
#nDat {
}
.numerical_data {
background-color: #ededed;
}
#gDat {
}
.line_graph_data {
background-color: #ededed;
}
/*Console and options colors*/
#liveConsole {
background-color: black;
caret-color: #f0f0f0;
}
#console_text {
color: #f0f0f0;
}
#console_input {
color: #f0f0f0;
}
.unsent {
color: #cb0000;
}
.sent {
color: #1ec70d;
}
/*Web option colors*/
#dashOptions {
}
#options {
}
.option {
}
.optionLabel {
}
.optionInput {
border-color: rgba(0, 0, 0, 0);
background-color: #eaeaea;
color: #202020;
}
/*misc*/
.button{
border-color: rgba(0, 0, 0, 0);
background-color: #eaeaea;
color: #202020;
}
.button:hover{
background-color: #dadada;
}
.img {
}
.updated {
border-color: #1ec70d !important;
}
.slider {
border-color: rgba(0, 0, 0, 0);
background-color: #eaeaea;
}
.slider::before{
background-color: #202020;
}
input:checked + .slider {
background-color: #adadad;
}
/*Webkit colors*/
::-webkit-scrollbar {
}
::-webkit-scrollbar-button {
}
::-webkit-scrollbar-thumb {
background-color: #cacaca;
}
::-webkit-scrollbar-thumb:hover {
background-color: #dedede;
}
::-webkit-scrollbar-track {
}
/*golden layout overrides*/
.lm_goldenlayout {
background: rgba(0, 0, 0, 0);
}
.lm_content {
background: rgba(0, 0, 0, 0);
}
.lm_dragProxy .lm_content {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.9)
}
.lm_dropTargetIndicator {
box-shadow: inset 0 0 30px #000000;
outline: 1px dashed #cccccc;
transition: all 200ms ease
}
.lm_dropTargetIndicator .lm_inner {
background: #000000;
opacity: .2
}
.lm_splitter {
background: inherit;
opacity: .001;
transition: opacity 200ms ease
}
.lm_splitter:hover, .lm_splitter.lm_dragging {
background-color: #dadada;
opacity: 1
}
.lm_header {
background-color: #ededed;
height: 20px;
user-select: none
}
.lm_header.lm_selectable {
cursor: pointer
}
.lm_header .lm_tab {
font-size: 12px;
color: #202020;
background-color: #f8f9fa;
margin-right: 2px;
padding-bottom: 2px;
padding-top: 1px
}
.lm_header .lm_tab .lm_close_tab {
width: 9px;
height: 9px;
background-image: url("../icons/close.png");
background-position: center center;
background-repeat: no-repeat;
top: 4px;
right: 6px;
opacity: .4
}
.lm_header .lm_tab .lm_close_tab:hover {
opacity: 1
}
.lm_header .lm_tab.lm_active {
background-color: #e1e1e1;
}
.lm_header .lm_tab.lm_active .lm_close_tab:hover {
opacity: 1
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab, .lm_stack.lm_bottom .lm_header .lm_tab {
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3)
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab.lm_active, .lm_stack.lm_bottom .lm_header .lm_tab.lm_active {
box-shadow: 0 2px 2px #000000
}
.lm_selected .lm_header {
background-color: #452500
}
.lm_tab:hover, .lm_tab.lm_active {
background-color: #dadada;
}
.lm_header .lm_controls .lm_tabdropdown:before {
color: #ffffff
}
.lm_controls > li {
position: relative;
background-position: center center;
background-repeat: no-repeat;
opacity: .4;
transition: opacity 300ms ease
}
.lm_controls > li:hover {
opacity: 1
}
.lm_controls .lm_popout {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAPklEQVR4nI2Q0QoAIAwCNfr/X7aXCpGN8snBdgejJOzckpkxs9jR6K6T5JpU0nWl5pSXTk7qwh8SnNT+CAAWCgkKFpuSWsUAAAAASUVORK5CYII=)
}
.lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKElEQVR4nGP8////fwYCgImQAgYGBgYWKM2IR81/okwajIpgvsMbVgAwgQYRVakEKQAAAABJRU5ErkJggg==)
}
.lm_controls .lm_close {
top: 2px;
width: 10px !important;
height: 10px !important;
background-image: url("../icons/close.png");
background-position: center center;
}
.lm_maximised .lm_header {
background-color: #000000
}
.lm_maximised .lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAJ0lEQVR4nGP8//8/AzGAiShVI1YhCwMDA8OsWbPwBmZaWhoj0SYCAN1lBxMAX4n0AAAAAElFTkSuQmCC)
}
.lm_transition_indicator {
background-color: #000000;
border: 1px dashed #555555
}
.lm_popin {
cursor: pointer
}
.lm_popin .lm_bg {
background: #ffffff;
opacity: .3
}
.lm_popin .lm_icon {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAJCAYAAADpeqZqAAAAWklEQVR4nJWOyw3AIAxDHcQC7L8jbwT3AlJBfNp3SiI7dtRaLSlKKeoA1oEsKSQZCEluexw8Tm3ohk+E7bnOUHUGcNh+HwbBygw4AZ7FN/Lt84p0l+yTflV8AKQyLdcCRJi/AAAAAElFTkSuQmCC);
background-position: center center;
background-repeat: no-repeat;
border-left: 1px solid #eeeeee;
border-top: 1px solid #eeeeee;
opacity: .7
}
.lm_popin:hover .lm_icon {
opacity: 1
}
/*# sourceMappingURL=goldenlayout-dark-theme.css.map */

View file

@ -0,0 +1,327 @@
body {
background-color: #101010;
color: #bfbfbf;
}
#pageBar {
background-color: #202020;
}
#consoleLatest {
background-color: black;
color: #f0f0f0;
}
.connected {
border-color: #0fff00 !important;
}
.disconnected {
border-color: #e50100 !important;
}
.connecting {
border-color: #e5d700 !important;
}
#sideBar {
background-color: #202020;
}
.sideOption:hover {
background-color: #404040;
}
.sideSelected {
background-color: #505050;
}
#content {
background-color: #101010;
}
.contentHeader {
background-color: #404040;
}
/*Main dashboard colors*/
#dashb {
}
.numerical_data {
background-color: #353535;
}
.line_graph_data {
background-color: #353535;
}
/*Console and options colors*/
#liveConsole {
background-color: black;
caret-color: #f0f0f0;
}
#console_text {
color: #f0f0f0;
}
#console_input {
color: #f0f0f0;
}
.unsent {
color: #e50100;
}
.sent {
color: #0fff00;
}
/*Web option colors*/
#dashOptions {
}
#options {
}
.option {
}
.optionLabel {
}
.optionInput {
border-color: rgba(0, 0, 0, 0);
background-color: #404040;
color: #bfbfbf;
}
/*misc*/
.button{
border-color: rgba(0, 0, 0, 0);
background-color: #404040;
color: #bfbfbf;
}
.button:hover{
background-color: #797979;
}
.img {
filter: invert(100%);
}
.updated {
border-color: #1ec70d !important;
}
.slider {
border-color: rgba(0, 0, 0, 0);
background-color: #404040;
}
.slider::before {
background-color: #bfbfbf;
}
input:checked + .slider {
background-color: #757575;
}
/*Webkit colors*/
::-webkit-scrollbar {
}
::-webkit-scrollbar-button {
}
::-webkit-scrollbar-thumb {
background-color: #555555;
}
::-webkit-scrollbar-thumb:hover {
background-color: #888888;
}
::-webkit-scrollbar-track {
}
/*golden layout*/
.lm_goldenlayout {
background: rgba(0, 0, 0, 0);
}
.lm_content {
background: rgba(0, 0, 0, 0);
}
.lm_dragProxy .lm_content {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.9)
}
.lm_dropTargetIndicator {
box-shadow: inset 0 0 30px #000000;
outline: 1px dashed #cccccc;
transition: all 200ms ease
}
.lm_dropTargetIndicator .lm_inner {
background: #000000;
opacity: .2
}
.lm_splitter {
background: inherit;
opacity: .001;
transition: opacity 200ms ease
}
.lm_splitter:hover, .lm_splitter.lm_dragging {
background: #303030;
opacity: 1
}
.lm_header {
background-color: #404040;
height: 20px;
user-select: none
}
.lm_header.lm_selectable {
cursor: pointer
}
.lm_header .lm_tab {
font-size: 12px;
color: #bfbfbf;
background-color: #404040;
margin-right: 2px;
padding-bottom: 2px;
padding-top: 1px
}
.lm_header .lm_tab .lm_close_tab {
width: 9px;
height: 9px;
background-image: url("../icons/close.png");
filter: invert(100%);
background-size: 14px 14px;
background-position: center center;
background-repeat: no-repeat;
top: 4px;
right: 6px;
opacity: .4
}
.lm_header .lm_tab .lm_close_tab:hover {
opacity: 1
}
.lm_header .lm_tab.lm_active {
background-color: #606060;
}
.lm_header .lm_tab.lm_active:hover {
background-color: #505050;
}
.lm_header .lm_tab.lm_active .lm_close_tab:hover {
opacity: 1
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab, .lm_stack.lm_bottom .lm_header .lm_tab {
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3)
}
.lm_dragProxy.lm_bottom .lm_header .lm_tab.lm_active, .lm_stack.lm_bottom .lm_header .lm_tab.lm_active {
box-shadow: 0 2px 2px #000000
}
.lm_selected .lm_header {
background-color: #452500
}
.lm_tab:hover{
background-color: #505050;
color: #bfbfbf
}
.lm_header .lm_controls .lm_tabdropdown:before {
color: #ffffff
}
.lm_controls > li {
position: relative;
background-position: center center;
background-repeat: no-repeat;
opacity: .4;
transition: opacity 300ms ease
}
.lm_controls > li:hover {
opacity: 1
}
.lm_controls .lm_popout {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAPklEQVR4nI2Q0QoAIAwCNfr/X7aXCpGN8snBdgejJOzckpkxs9jR6K6T5JpU0nWl5pSXTk7qwh8SnNT+CAAWCgkKFpuSWsUAAAAASUVORK5CYII=)
}
.lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKElEQVR4nGP8////fwYCgImQAgYGBgYWKM2IR81/okwajIpgvsMbVgAwgQYRVakEKQAAAABJRU5ErkJggg==)
}
.lm_controls .lm_close {
top: 2px;
width: 18px !important;
height: 18px !important;
background-image: url("../icons/close.png");
background-size: 16px 16px;
background-position: center center;
filter: invert(100%);
}
.lm_maximised .lm_header {
background-color: #000000
}
.lm_maximised .lm_controls .lm_maximise {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAJ0lEQVR4nGP8//8/AzGAiShVI1YhCwMDA8OsWbPwBmZaWhoj0SYCAN1lBxMAX4n0AAAAAElFTkSuQmCC)
}
.lm_transition_indicator {
background-color: #000000;
border: 1px dashed #555555
}
.lm_popin {
cursor: pointer
}
.lm_popin .lm_bg {
background: #ffffff;
opacity: .3
}
.lm_popin .lm_icon {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAJCAYAAADpeqZqAAAAWklEQVR4nJWOyw3AIAxDHcQC7L8jbwT3AlJBfNp3SiI7dtRaLSlKKeoA1oEsKSQZCEluexw8Tm3ohk+E7bnOUHUGcNh+HwbBygw4AZ7FN/Lt84p0l+yTflV8AKQyLdcCRJi/AAAAAElFTkSuQmCC);
background-position: center center;
background-repeat: no-repeat;
border-left: 1px solid #eeeeee;
border-top: 1px solid #eeeeee;
opacity: .7
}
.lm_popin:hover .lm_icon {
opacity: 1
}
/*# sourceMappingURL=goldenlayout-dark-theme.css.map */

View file

@ -0,0 +1,22 @@
.input {
padding-left: 2px;
}
.slider {
width: 60px;
height: 12px;
margin-top: 2px;
}
.slider:before {
height: 12px;
width: 30px;
left: 0;
bottom: 0;
}
input:checked + .slider:before {
-webkit-transform: translateX(30px);
-ms-transform: translateX(30px);
transform: translateX(30px);
}

View file

@ -0,0 +1,165 @@
/*Content holder positions*/
#pageBar {
}
#connection {
}
#consoleLatest {
border-radius: 10px;
}
#sideBar {
}
.sideSelected {
border-radius: 10px;
}
.sideOption:hover {
border-radius: 10px;
}
#expSide {
}
#dash {
}
#cons {
}
#opt {
}
#content {
}
.contentHeader {
border-radius: 10px;
}
/*Main dashboard positions*/
#dashb {
}
#nDat {
}
.numerical_data {
border-radius: 0 0 10px 10px;
}
#gDat {
border-radius: 10px;
}
.line_graph_data {
border-radius: 0 0 10px 10px;
}
/*Console and options positions*/
#console {
}
#buttonBoard {
}
#liveConsole {
border-radius: 10px;
}
#console_input {
}
#console_text {
border-radius: 10px;
}
/*Web option positions*/
#dashOptions {
}
#options {
}
#apply {
}
.option {
}
.optionLabel {
}
.optionInput {
}
/*misc modifications*/
.input {
padding-left: 5px;
border-radius: 10px;
}
.button{
border-radius: 10px;
}
.slider {
border-radius: 30px;
width: 60px;
height: 16px;
}
.slider:before {
border-radius: 7px;
height: 14px;
width: 28px;
left: 3px;
bottom: 1px;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/*Webkit modifications*/
::-webkit-scrollbar {
}
::-webkit-scrollbar-button {
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
}
::-webkit-scrollbar-track {
}
/*Golden Layout Overrides*/
.lm_header {
border-radius: 10px 10px 0 0;
}
.lm_tabs:first-child .lm_tab{
border-top-left-radius: 10px;
}
.lm_tabs:first-child .lm_tab ~ .lm_tab{
border-top-left-radius: 0;
}
.lm_splitter {
border-radius: 10px;
}