diff --git a/bridge-protocol/build.gradle b/bridge-protocol/build.gradle index 6b537fc..44006f7 100644 --- a/bridge-protocol/build.gradle +++ b/bridge-protocol/build.gradle @@ -1,5 +1,5 @@ group = 'asys' -version = '0.3-SNAPSHOT' +version = '0.4-SNAPSHOT' task jar(type: Jar, overwrite: true) { // не собирать jar diff --git a/bridge-protocol/src/main/java/asys/mcsmanager/packets/SC_Command.java b/bridge-protocol/src/main/java/asys/mcsmanager/packets/SC_Command.java new file mode 100644 index 0000000..1cca1bb --- /dev/null +++ b/bridge-protocol/src/main/java/asys/mcsmanager/packets/SC_Command.java @@ -0,0 +1,32 @@ +/* + * DmitriyMX + * 2017-06-05 + */ +package asys.mcsmanager.packets; + +import io.netty.buffer.ByteBuf; + +public class SC_Command extends Packet { + private String command; + + public SC_Command() { + } + + public SC_Command(String command) { + this.command = command; + } + + public String getCommand() { + return command; + } + + @Override + public void readSelfData(ByteBuf buffer) { + command = readString(buffer); + } + + @Override + public void writeSelfData(ByteBuf buffer) { + writeString(buffer, command); + } +} diff --git a/bridge-protocol/src/main/java/asys/mcsmanager/packets/codec/PacketDecoder.java b/bridge-protocol/src/main/java/asys/mcsmanager/packets/codec/PacketDecoder.java index ea926a8..cdcd832 100644 --- a/bridge-protocol/src/main/java/asys/mcsmanager/packets/codec/PacketDecoder.java +++ b/bridge-protocol/src/main/java/asys/mcsmanager/packets/codec/PacketDecoder.java @@ -19,12 +19,15 @@ public class PacketDecoder extends ReplayingDecoder { protected void decode(ChannelHandlerContext contect, ByteBuf inBuf, List out) throws Exception { int id = inBuf.readUnsignedByte(); Class pktClass = contect.channel().attr(KNOWN_PACKETS).get().get(id); - if (pktClass == null) return; + if (pktClass == null) return; //TODO надо бы в логгере писать про отсутствующий пакет if (contect.channel().attr(KNOWN_HANDLERS).get().containsKey(pktClass)) { Packet packet = pktClass.newInstance(); packet.readSelfData(inBuf); out.add(packet); + } else { + //TODO по хорошему, надо информровать, что отсутствует обработчик пакета + inBuf.skipBytes(inBuf.readableBytes()); } } } diff --git a/bridge/src/main/java/asys/bridge/bukkit/BridgePlugin.java b/bridge/src/main/java/asys/bridge/bukkit/BridgePlugin.java index 030b125..4f7b821 100644 --- a/bridge/src/main/java/asys/bridge/bukkit/BridgePlugin.java +++ b/bridge/src/main/java/asys/bridge/bukkit/BridgePlugin.java @@ -13,25 +13,19 @@ import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.plugin.java.JavaPlugin; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - public class BridgePlugin extends JavaPlugin { public static BridgePlugin INSTANCE; private String appConnect = "ASys"; private Client client; - private ScheduledExecutorService ses; - private ScheduledFuture sesFuture, sesPingFuture; + private TaskTicker connectTicker, pingTicker; private int tryConnect = 0; private BridgeLoggerAppender loggerAppender; + private boolean needReconnect = true; @Override public void onLoad() { - saveDefaultConfig(); if (getConfig().getInt("mode") == 1) { - ((Logger) LogManager.getRootLogger()).addAppender(loggerAppender = new BridgeLoggerAppender()); + ((Logger)LogManager.getRootLogger()).addAppender(loggerAppender = new BridgeLoggerAppender()); } else { this.appConnect = "Zond"; } @@ -42,16 +36,16 @@ public class BridgePlugin extends JavaPlugin { public void onEnable() { if (INSTANCE == null) { INSTANCE = this; + saveDefaultConfig(); + startReconnect(); } } @Override public void onDisable() { - if (sesFuture != null) { - sesFuture.cancel(false); - } - + setNeedReconnect(false); + stopReconnect(); stopPing(); if (client.isConnected()) { @@ -70,8 +64,8 @@ public class BridgePlugin extends JavaPlugin { public void startReconnect() { client = new Client(); - ses = Executors.newScheduledThreadPool(2); - sesFuture = ses.scheduleAtFixedRate(() -> { + connectTicker = new TaskTicker().setStepTimeMs(5000L); + connectTicker.setTask(() -> { getLogger().info(String.format("Connect(%d) to %s...", ++tryConnect, appConnect)); if (getConfig().getInt("mode") == 1) { client.connect(getConfig().getString("host"), getConfig().getInt("port")); @@ -84,13 +78,12 @@ public class BridgePlugin extends JavaPlugin { getLogger().warning( String.format("Connection(%d) fail. Try reconnect...", tryConnect)); } - }, 0L, 5L, TimeUnit.SECONDS); + }).start(); } public void stopReconnect() { - if (sesFuture != null) { - sesFuture.cancel(false); - sesFuture = null; + if (connectTicker != null) { + connectTicker.stop(); tryConnect = 0; } } @@ -99,7 +92,8 @@ public class BridgePlugin extends JavaPlugin { if (getConfig().getInt("mode") == 1) { getLoggerAppender().setChannel(channel); } - sesPingFuture = ses.scheduleAtFixedRate(() -> { + pingTicker = new TaskTicker().setStepTimeMs(5000L); + pingTicker.setTask(() -> { channel.write(new CS_Ping( System.currentTimeMillis(), 20.0D, //FIXME @@ -115,19 +109,27 @@ public class BridgePlugin extends JavaPlugin { getLogger().warning("Try reconnect..."); startReconnect(); } - }, 0L, 5L, TimeUnit.SECONDS); + }).start(); } public void stopPing() { if (getConfig().getInt("mode") == 1) { getLoggerAppender().setChannel(null); } - if (sesPingFuture != null) { - sesPingFuture.cancel(false); + if (pingTicker != null) { + pingTicker.stop(); } } - private BridgeLoggerAppender getLoggerAppender() { + public BridgeLoggerAppender getLoggerAppender() { return loggerAppender; } + + public boolean isNeedReconnect() { + return needReconnect; + } + + public void setNeedReconnect(boolean needReconnect) { + this.needReconnect = needReconnect; + } } diff --git a/bridge/src/main/java/asys/bridge/bukkit/TaskTicker.java b/bridge/src/main/java/asys/bridge/bukkit/TaskTicker.java new file mode 100644 index 0000000..3799467 --- /dev/null +++ b/bridge/src/main/java/asys/bridge/bukkit/TaskTicker.java @@ -0,0 +1,48 @@ +/* + * DmitriyMX + * 2017-05-18 + */ +package asys.bridge.bukkit; + +public class TaskTicker implements Runnable { + private Runnable task; + private long stepTimeMs = 1000L; + private Thread thread; + private boolean loop = false; + + TaskTicker setTask(Runnable task) { + this.task = task; + return this; + } + + TaskTicker setStepTimeMs(long stepTimeMs) { + this.stepTimeMs = stepTimeMs; + return this; + } + + void start() { + thread = new Thread(this, "TaskTicker"); + loop = true; + thread.start(); + } + + void stop() { + loop = false; + if (thread != null) { + thread.interrupt(); + } + } + + @Override + public void run() { + while (loop || !Thread.currentThread().isInterrupted()) { + task.run(); + + try { + Thread.sleep(stepTimeMs); + } catch (InterruptedException e) { + break; + } + } + } +} diff --git a/bridge/src/main/java/asys/bridge/client/ClientPacketHandler.java b/bridge/src/main/java/asys/bridge/client/ClientPacketHandler.java index 4d8bfba..2771d97 100644 --- a/bridge/src/main/java/asys/bridge/client/ClientPacketHandler.java +++ b/bridge/src/main/java/asys/bridge/client/ClientPacketHandler.java @@ -26,13 +26,15 @@ public class ClientPacketHandler extends ChannelInboundHandlerAdapter implements private static final BiMap> pingPackets = ImmutableBiMap.of( 3, CS_Ping.class, - 4, CS_ConsoleMessage.class + 4, CS_ConsoleMessage.class, + 5, SC_Command.class ); ClientPacketHandler() { if (handshakeHandlers == null) { handshakeHandlers = ImmutableMap.of( - SC_HandshakeResult.class, this + SC_HandshakeResult.class, this, + SC_Command.class, this ); } } @@ -61,10 +63,11 @@ public class ClientPacketHandler extends ChannelInboundHandlerAdapter implements @Override public void channelInactive(ChannelHandlerContext context) throws Exception { if (BridgePlugin.INSTANCE != null) { - BridgePlugin.INSTANCE.getLogger().warning("Lost connection!"); BridgePlugin.INSTANCE.stopPing(); - BridgePlugin.INSTANCE.getLogger().warning("Try reconnect..."); - BridgePlugin.INSTANCE.startReconnect(); + if (BridgePlugin.INSTANCE.isNeedReconnect()) { + BridgePlugin.INSTANCE.getLogger().warning("Lost connection! Try reconnect..."); + BridgePlugin.INSTANCE.startReconnect(); + } } context.channel().attr(KNOWN_PACKETS).remove(); context.channel().attr(KNOWN_HANDLERS).remove(); @@ -74,16 +77,32 @@ public class ClientPacketHandler extends ChannelInboundHandlerAdapter implements @Override public void handle(Packet packet, ChannelHandlerContext context) { BridgePlugin.INSTANCE.getLogger().info("handle : " + packet.getClass().getSimpleName()); - if (BridgePlugin.INSTANCE.getConfig().getInt("mode") == 1) { - SC_HandshakeResult pkt = (SC_HandshakeResult) packet; - if (pkt.getErrorCode() != 0) { - BridgePlugin.INSTANCE.getLogger().severe( - String.format("Handshake: #%d %s", pkt.getErrorCode(), pkt.getMessage())); - } else { - context.channel().attr(KNOWN_PACKETS).set(pingPackets); - BridgePlugin.INSTANCE.getLogger().info("Handshake: OK"); - BridgePlugin.INSTANCE.startPing(context.channel()); + if (packet instanceof SC_HandshakeResult) { + if (BridgePlugin.INSTANCE.getConfig().getInt("mode") == 1) { + handleHandshakeResult((SC_HandshakeResult) packet, context); } + } else if (packet instanceof SC_Command) { + handleCommand((SC_Command) packet); } } + + private void handleHandshakeResult(SC_HandshakeResult packet, ChannelHandlerContext context) { + if (packet.getErrorCode() != 0) { + BridgePlugin.INSTANCE.getLogger().severe( + String.format("Handshake: #%d %s", packet.getErrorCode(), packet.getMessage())); + BridgePlugin.INSTANCE.setNeedReconnect(false); + } else { + context.channel().attr(KNOWN_PACKETS).set(pingPackets); + BridgePlugin.INSTANCE.getLogger().info("Handshake: OK"); + BridgePlugin.INSTANCE.startPing(context.channel()); + } + } + + private void handleCommand(SC_Command packet) { + BridgePlugin.INSTANCE.getLogger().info("Command: " + packet.getCommand()); + BridgePlugin.INSTANCE.getServer().dispatchCommand( + BridgePlugin.INSTANCE.getServer().getConsoleSender(), + packet.getCommand() + ); + } } diff --git a/mcserver-manager/build.gradle b/mcserver-manager/build.gradle index e72c9bc..e47c984 100644 --- a/mcserver-manager/build.gradle +++ b/mcserver-manager/build.gradle @@ -1,5 +1,5 @@ group = 'asys' -version = '0.8.7-SNAPSHOT' +version = '0.8.13-SNAPSHOT' apply plugin: 'osgi' @@ -16,6 +16,7 @@ jar { '!asys.mcsmanager.packets.*', 'io.netty.buffer;version="[4.0,5)"', 'io.netty.handler.codec;version="[4.0,5)"', + 'io.netty.handler.codec.http;version="[4.0,5)"', '*' } @@ -32,4 +33,5 @@ dependencies { compile project(':webinterface') include files(project(':bridge-protocol').sourceSets.main.output.classesDir) compile group: 'io.netty', name: 'netty-codec', version: nettyVersion + compile group: 'io.netty', name: 'netty-codec-http', version: nettyVersion } diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/Activator.java b/mcserver-manager/src/main/java/asys/mcsmanager/Activator.java index 91d7f86..676eaff 100644 --- a/mcserver-manager/src/main/java/asys/mcsmanager/Activator.java +++ b/mcserver-manager/src/main/java/asys/mcsmanager/Activator.java @@ -5,7 +5,6 @@ package asys.mcsmanager; import asys.api.Config; -import asys.mcsmanager.server.Server; import asys.webinterface.api.Webinterface; import org.osgi.framework.BundleActivator; import org.osgi.framework.BundleContext; @@ -20,7 +19,8 @@ public class Activator implements BundleActivator, ServiceListener { private ServiceTracker serviceTracker; private MCSM_WebModule module; private Webinterface webinterface; - private Server serverManager; + private asys.mcsmanager.server.Server serverManager; + private asys.mcsmanager.websocket.Server webconsoleServer; @Override public void start(BundleContext context) throws Exception { @@ -43,13 +43,21 @@ public class Activator implements BundleActivator, ServiceListener { int port = config.getInt("asys.mcsmanager.port", 8779); String passcode = config.getString("asys.mcsmanager.passcode", "testpasscode"); logger.debug("Start server manager: {}:{}", host, port); - serverManager = new Server(); + serverManager = new asys.mcsmanager.server.Server(); serverManager.start(host, port, passcode, manager); + + host = config.getString("asys.mcsmanager.webconsole.host", "127.0.0.1"); + port = config.getInt("asys.mcsmanager.webconsole.port", 8770); + logger.debug("Start webconsole server: {}:{}", host, port); + webconsoleServer = new asys.mcsmanager.websocket.Server(); + webconsoleServer.start(host, port, manager); + serviceConfigTracker.close(); } @Override public void stop(BundleContext context) throws Exception { + webconsoleServer.shutdown(); serverManager.shutdown(); if (webinterface != null) { diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/MCSM_WebModule.java b/mcserver-manager/src/main/java/asys/mcsmanager/MCSM_WebModule.java index 95fde21..758a1cc 100644 --- a/mcserver-manager/src/main/java/asys/mcsmanager/MCSM_WebModule.java +++ b/mcserver-manager/src/main/java/asys/mcsmanager/MCSM_WebModule.java @@ -26,6 +26,7 @@ public class MCSM_WebModule extends WebModule { private final String MODULE_NAME = "mcsmanager"; private final String MODULE_URL = "/"+MODULE_NAME; private final Pattern URL_PATTERN_JS = Pattern.compile(MODULE_URL+"/(\\w+)\\.js"); + private final Pattern URL_PATTERN_CSS = Pattern.compile(MODULE_URL+"/(\\w+)\\.css"); private Manager manager; MCSM_WebModule(Manager manager) { @@ -66,6 +67,19 @@ public class MCSM_WebModule extends WebModule { this.sendContent(httpExchange, 0, stream); return true; } + + //FIXME дублирование кода + matcher = URL_PATTERN_CSS.matcher(urlPath); + if (matcher.find()) { + InputStream stream = getClass().getResourceAsStream("/" + matcher.group(1) + ".css"); + if (stream == null) { + this.sendHttpCode(httpExchange, 404, "not found"); + return true; + } + httpExchange.getResponseHeaders().add("Content-Type", "text/css;charset=utf-8"); + this.sendContent(httpExchange, 0, stream); + return true; + } } return false; diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/Manager.java b/mcserver-manager/src/main/java/asys/mcsmanager/Manager.java index e05e6af..4f06e68 100644 --- a/mcserver-manager/src/main/java/asys/mcsmanager/Manager.java +++ b/mcserver-manager/src/main/java/asys/mcsmanager/Manager.java @@ -4,7 +4,11 @@ */ package asys.mcsmanager; +import asys.mcsmanager.packets.CS_ConsoleMessage; import asys.mcsmanager.packets.CS_Ping; +import asys.mcsmanager.packets.SC_Command; +import io.netty.channel.Channel; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,19 +17,43 @@ import java.util.*; public class Manager { private Logger logger = LoggerFactory.getLogger(Manager.class); private Map serversMap = new HashMap<>(); + private Map serverChannels = new HashMap<>(); + private List webconsoleListener = new ArrayList<>(); /** * Добавляем в список ClientID * @param clientId id сервера. Чувствителен к регистру * @return false, если сервер с таким id уже имеется */ - public boolean addClientId(String clientId) { + public boolean addClientId(String clientId, Channel channel) { if (serversMap.containsKey(clientId)) { return false; } logger.debug("addClientId: {}", clientId); serversMap.put(clientId, new ServerInfo(clientId)); + serverChannels.put(clientId, channel); + return true; + } + + public void removeClientId(String clientId) { + if (clientId != null) { + serverChannels.remove(clientId); + } + } + + public boolean sendCommand(String clientId, String command) { +/* + if (serverChannels.containsKey(clientId)) { + serverChannels.get(clientId).writeAndFlush(new SC_Command(command)); + return true; + } else { + return false; + } +*/ + //FIXME временный костыль + Channel channel = serverChannels.entrySet().iterator().next().getValue(); + channel.writeAndFlush(new SC_Command(command)); return true; } @@ -47,4 +75,22 @@ public class Manager { public ServerInfo getInfo(String clientId) { return serversMap.get(clientId); } + + public void addWebConsoleListener(Channel channel) { + this.webconsoleListener.add(channel); + } + + public void removeWebConsoleListener(Channel channel) { + this.webconsoleListener.remove(channel); + } + + public void sendBroadcastToWebConsoleListeners(CS_ConsoleMessage message) { + for (Channel channel : this.webconsoleListener) { + channel.writeAndFlush(new TextWebSocketFrame(String.format( + "[L:%d] %s", + message.getLevel(), + message.getMessage() + ))); + } + } } diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/server/ServerPacketHandler.java b/mcserver-manager/src/main/java/asys/mcsmanager/server/ServerPacketHandler.java index bed9a46..3e81c14 100644 --- a/mcserver-manager/src/main/java/asys/mcsmanager/server/ServerPacketHandler.java +++ b/mcserver-manager/src/main/java/asys/mcsmanager/server/ServerPacketHandler.java @@ -28,7 +28,8 @@ class ServerPacketHandler extends ChannelInboundHandlerAdapter implements IPacke private static final BiMap> pingPackets = ImmutableBiMap.of( 3, CS_Ping.class, - 4, CS_ConsoleMessage.class + 4, CS_ConsoleMessage.class, + 5, SC_Command.class ); private static Map, IPacketHandler> pingHandlers; @@ -56,6 +57,12 @@ class ServerPacketHandler extends ChannelInboundHandlerAdapter implements IPacke super.channelActive(context); } + @Override + public void channelInactive(ChannelHandlerContext context) throws Exception { + manager.removeClientId(context.channel().attr(CLIENTID).get()); + super.channelInactive(context); + } + @Override public void handle(Packet packet, ChannelHandlerContext context) { if (packet.getClass() == CS_Handshake.class) { @@ -77,7 +84,7 @@ class ServerPacketHandler extends ChannelInboundHandlerAdapter implements IPacke return; } - if (!manager.addClientId(packet.getClientId())) { + if (!manager.addClientId(packet.getClientId(), context.channel())) { try { context.channel().writeAndFlush(HandshakeResult.CLIENTID_EXISTS).sync().channel().close(); } catch (InterruptedException ignore) { @@ -98,6 +105,6 @@ class ServerPacketHandler extends ChannelInboundHandlerAdapter implements IPacke } private void handleCSConsoleMessage(CS_ConsoleMessage packet) { - LoggerFactory.getLogger(getClass()).debug("[L:{}] {}", packet.getLevel(), packet.getMessage()); + manager.sendBroadcastToWebConsoleListeners(packet); } } diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/websocket/FrameHandler.java b/mcserver-manager/src/main/java/asys/mcsmanager/websocket/FrameHandler.java new file mode 100644 index 0000000..1dbd305 --- /dev/null +++ b/mcserver-manager/src/main/java/asys/mcsmanager/websocket/FrameHandler.java @@ -0,0 +1,42 @@ +/* + * DmitriyMX + * 2017-05-09 + */ +package asys.mcsmanager.websocket; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static asys.mcsmanager.websocket.Server.manager; + +public class FrameHandler extends SimpleChannelInboundHandler { + private final Logger logger = LoggerFactory.getLogger(FrameHandler.class); + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + manager.addWebConsoleListener(ctx.channel()); + super.channelActive(ctx); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + manager.removeWebConsoleListener(ctx.channel()); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { + if (frame instanceof TextWebSocketFrame) { + String requestText = ((TextWebSocketFrame)frame).text(); + if (requestText.startsWith(":")) { + //FIXME убрать костыли + manager.sendCommand(null, requestText.substring(1)); + } + } else { + logger.warn("unsupport frame type: {}", frame.getClass().getName()); + } + } +} diff --git a/mcserver-manager/src/main/java/asys/mcsmanager/websocket/Server.java b/mcserver-manager/src/main/java/asys/mcsmanager/websocket/Server.java new file mode 100644 index 0000000..a9b30c4 --- /dev/null +++ b/mcserver-manager/src/main/java/asys/mcsmanager/websocket/Server.java @@ -0,0 +1,59 @@ +/* + * DmitriyMX + * 2017-05-08 + */ +package asys.mcsmanager.websocket; + +import asys.mcsmanager.Manager; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; + +public class Server { + static Manager manager; + private EventLoopGroup bossGroup, workerGroup; + + public void start(String host, int port, Manager manager) { + Server.manager = manager; + bossGroup = new NioEventLoopGroup(1); + workerGroup = new NioEventLoopGroup(); + + ServerBootstrap serverBootstrap = createServerBootstrap(); + serverBootstrap.bind(host, port); + } + + public void shutdown() { + workerGroup.shutdownGracefully(); + bossGroup.shutdownGracefully(); + } + + private ServerBootstrap createServerBootstrap() { + ServerBootstrap bootstrap = new ServerBootstrap(); + + bootstrap.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(createChannelInitializer()); + + return bootstrap; + } + + private ChannelInitializer createChannelInitializer() { + return new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel socketChannel) throws Exception { + socketChannel.pipeline().addLast( + new HttpServerCodec(), + new HttpObjectAggregator(65536), + new WebSocketServerProtocolHandler("/", null, true), + new FrameHandler() + ); + } + }; + } +} diff --git a/mcserver-manager/src/main/resources/ansi_up.js b/mcserver-manager/src/main/resources/ansi_up.js new file mode 100644 index 0000000..4d695c2 --- /dev/null +++ b/mcserver-manager/src/main/resources/ansi_up.js @@ -0,0 +1,333 @@ +/* ansi_up.js + * author : Dru Nelson + * license : MIT + * http://github.com/drudru/ansi_up + */ +(function (factory) { + var v; + if (typeof module === "object" && typeof module.exports === "object") { + v = factory(require, exports); + if ("undefined" !== typeof v) module.exports = v; + } + else if ("function" === typeof define && define.amd) { + define(["require", "exports"], factory); + } + else { + var req, exp = {}; + v = factory(req, exp); + window.AnsiUp = exp.default; + } +})(function (require, exports) { + +"use strict"; +function rgx(tmplObj) { + var subst = []; + for (var _i = 1; _i < arguments.length; _i++) { + subst[_i - 1] = arguments[_i]; + } + var regexText = tmplObj.raw[0]; + var wsrgx = /^\s+|\s+\n|\s+#[\s\S]+?\n/gm; + var txt2 = regexText.replace(wsrgx, ''); + return new RegExp(txt2, 'm'); +} +var AnsiUp = (function () { + function AnsiUp() { + this.VERSION = "2.0.0"; + this.ansi_colors = [ + [ + { rgb: [0, 0, 0], class_name: "ansi-black" }, + { rgb: [187, 0, 0], class_name: "ansi-red" }, + { rgb: [0, 187, 0], class_name: "ansi-green" }, + { rgb: [187, 187, 0], class_name: "ansi-yellow" }, + { rgb: [0, 0, 187], class_name: "ansi-blue" }, + { rgb: [187, 0, 187], class_name: "ansi-magenta" }, + { rgb: [0, 187, 187], class_name: "ansi-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-white" } + ], + [ + { rgb: [85, 85, 85], class_name: "ansi-bright-black" }, + { rgb: [255, 85, 85], class_name: "ansi-bright-red" }, + { rgb: [0, 255, 0], class_name: "ansi-bright-green" }, + { rgb: [255, 255, 85], class_name: "ansi-bright-yellow" }, + { rgb: [85, 85, 255], class_name: "ansi-bright-blue" }, + { rgb: [255, 85, 255], class_name: "ansi-bright-magenta" }, + { rgb: [85, 255, 255], class_name: "ansi-bright-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-bright-white" } + ] + ]; + this.htmlFormatter = { + transform: function (fragment, instance) { + var txt = fragment.text; + if (txt.length === 0) + return txt; + if (instance._escape_for_html) + txt = instance.old_escape_for_html(txt); + if (!fragment.bright && fragment.fg === null && fragment.bg === null) + return txt; + var styles = []; + var classes = []; + var fg = fragment.fg; + var bg = fragment.bg; + if (fg === null && fragment.bright) + fg = instance.ansi_colors[1][7]; + if (!instance._use_classes) { + if (fg) + styles.push("color:rgb(" + fg.rgb.join(',') + ")"); + if (bg) + styles.push("background-color:rgb(" + bg.rgb + ")"); + } + else { + if (fg) { + if (fg.class_name !== 'truecolor') { + classes.push(fg.class_name + "-fg"); + } + else { + styles.push("color:rgb(" + fg.rgb.join(',') + ")"); + } + } + if (bg) { + if (bg.class_name !== 'truecolor') { + classes.push(bg.class_name + "-bg"); + } + else { + styles.push("background-color:rgb(" + bg.rgb.join(',') + ")"); + } + } + } + var class_string = ''; + var style_string = ''; + if (classes.length) + class_string = " class=\"" + classes.join(' ') + "\""; + if (styles.length) + style_string = " style=\"" + styles.join(';') + "\""; + return "" + txt + ""; + }, + compose: function (segments, instance) { + return segments.join(""); + } + }; + this.textFormatter = { + transform: function (fragment, instance) { + return fragment.text; + }, + compose: function (segments, instance) { + return segments.join(""); + } + }; + this.setup_256_palette(); + this._use_classes = false; + this._escape_for_html = true; + this.bright = false; + this.fg = this.bg = null; + this._buffer = ''; + } + Object.defineProperty(AnsiUp.prototype, "use_classes", { + get: function () { + return this._use_classes; + }, + set: function (arg) { + this._use_classes = arg; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(AnsiUp.prototype, "escape_for_html", { + get: function () { + return this._escape_for_html; + }, + set: function (arg) { + this._escape_for_html = arg; + }, + enumerable: true, + configurable: true + }); + AnsiUp.prototype.setup_256_palette = function () { + var _this = this; + this.palette_256 = []; + this.ansi_colors.forEach(function (palette) { + palette.forEach(function (rec) { + _this.palette_256.push(rec); + }); + }); + var levels = [0, 95, 135, 175, 215, 255]; + for (var r = 0; r < 6; ++r) { + for (var g = 0; g < 6; ++g) { + for (var b = 0; b < 6; ++b) { + var col = { rgb: [levels[r], levels[g], levels[b]], class_name: 'truecolor' }; + this.palette_256.push(col); + } + } + } + var grey_level = 8; + for (var i = 0; i < 24; ++i, grey_level += 10) { + var gry = { rgb: [grey_level, grey_level, grey_level], class_name: 'truecolor' }; + this.palette_256.push(gry); + } + }; + AnsiUp.prototype.old_escape_for_html = function (txt) { + return txt.replace(/[&<>]/gm, function (str) { + if (str === "&") + return "&"; + if (str === "<") + return "<"; + if (str === ">") + return ">"; + }); + }; + AnsiUp.prototype.old_linkify = function (txt) { + return txt.replace(/(https?:\/\/[^\s]+)/gm, function (str) { + return "" + str + ""; + }); + }; + AnsiUp.prototype.detect_incomplete_ansi = function (txt) { + return !(/.*?[\x40-\x7e]/.test(txt)); + }; + AnsiUp.prototype.detect_incomplete_link = function (txt) { + var found = false; + for (var i = txt.length - 1; i > 0; i--) { + if (/\s|\x1B/.test(txt[i])) { + found = true; + break; + } + } + if (!found) { + if (/(https?:\/\/[^\s]+)/.test(txt)) + return 0; + else + return -1; + } + var prefix = txt.substr(i + 1, 4); + if (prefix.length === 0) + return -1; + if ("http".indexOf(prefix) === 0) + return (i + 1); + }; + AnsiUp.prototype.ansi_to = function (txt, formatter) { + var pkt = this._buffer + txt; + this._buffer = ''; + var raw_text_pkts = pkt.split(/\x1B\[/); + if (raw_text_pkts.length === 1) + raw_text_pkts.push(''); + this.handle_incomplete_sequences(raw_text_pkts); + var first_chunk = this.with_state(raw_text_pkts.shift()); + var blocks = new Array(raw_text_pkts.length); + for (var i = 0, len = raw_text_pkts.length; i < len; ++i) { + blocks[i] = (formatter.transform(this.process_ansi(raw_text_pkts[i]), this)); + } + if (first_chunk.text.length > 0) + blocks.unshift(formatter.transform(first_chunk, this)); + return formatter.compose(blocks, this); + }; + AnsiUp.prototype.ansi_to_html = function (txt) { + return this.ansi_to(txt, this.htmlFormatter); + }; + AnsiUp.prototype.ansi_to_text = function (txt) { + return this.ansi_to(txt, this.textFormatter); + }; + AnsiUp.prototype.with_state = function (text) { + return { bright: this.bright, fg: this.fg, bg: this.bg, text: text }; + }; + AnsiUp.prototype.handle_incomplete_sequences = function (chunks) { + var last_chunk = chunks[chunks.length - 1]; + if ((last_chunk.length > 0) && this.detect_incomplete_ansi(last_chunk)) { + this._buffer = "\x1B[" + last_chunk; + chunks.pop(); + chunks.push(''); + } + else { + if (last_chunk.slice(-1) === "\x1B") { + this._buffer = "\x1B"; + console.log("raw", chunks); + chunks.pop(); + chunks.push(last_chunk.substr(0, last_chunk.length - 1)); + console.log(chunks); + console.log(last_chunk); + } + if (chunks.length === 2 && + chunks[1] === "" && + chunks[0].slice(-1) === "\x1B") { + this._buffer = "\x1B"; + last_chunk = chunks.shift(); + chunks.unshift(last_chunk.substr(0, last_chunk.length - 1)); + } + } + }; + AnsiUp.prototype.process_ansi = function (block) { + if (!this._sgr_regex) { + this._sgr_regex = (_a = ["\n ^ # beginning of line\n ([!<-?]?) # a private-mode char (!, <, =, >, ?)\n ([d;]*) # any digits or semicolons\n ([ -/]? # an intermediate modifier\n [@-~]) # the command\n ([sS]*) # any text following this CSI sequence\n "], _a.raw = ["\n ^ # beginning of line\n ([!\\x3c-\\x3f]?) # a private-mode char (!, <, =, >, ?)\n ([\\d;]*) # any digits or semicolons\n ([\\x20-\\x2f]? # an intermediate modifier\n [\\x40-\\x7e]) # the command\n ([\\s\\S]*) # any text following this CSI sequence\n "], rgx(_a)); + } + var matches = block.match(this._sgr_regex); + if (!matches) { + return this.with_state(block); + } + var orig_txt = matches[4]; + if (matches[1] !== '' || matches[3] !== 'm') { + return this.with_state(orig_txt); + } + var sgr_cmds = matches[2].split(';'); + while (sgr_cmds.length > 0) { + var sgr_cmd_str = sgr_cmds.shift(); + var num = parseInt(sgr_cmd_str, 10); + if (isNaN(num) || num === 0) { + this.fg = this.bg = null; + this.bright = false; + } + else if (num === 1) { + this.bright = true; + } + else if (num === 39) { + this.fg = null; + } + else if (num === 49) { + this.bg = null; + } + else if ((num >= 30) && (num < 38)) { + var bidx = this.bright ? 1 : 0; + this.fg = this.ansi_colors[bidx][(num - 30)]; + } + else if ((num >= 90) && (num < 98)) { + this.fg = this.ansi_colors[1][(num - 90)]; + } + else if ((num >= 40) && (num < 48)) { + this.bg = this.ansi_colors[0][(num - 40)]; + } + else if ((num >= 100) && (num < 108)) { + this.bg = this.ansi_colors[1][(num - 100)]; + } + else if (num === 38 || num === 48) { + if (sgr_cmds.length > 0) { + var is_foreground = (num === 38); + var mode_cmd = sgr_cmds.shift(); + if (mode_cmd === '5' && sgr_cmds.length > 0) { + var palette_index = parseInt(sgr_cmds.shift(), 10); + if (palette_index >= 0 && palette_index <= 255) { + if (is_foreground) + this.fg = this.palette_256[palette_index]; + else + this.bg = this.palette_256[palette_index]; + } + } + if (mode_cmd === '2' && sgr_cmds.length > 2) { + var r = parseInt(sgr_cmds.shift(), 10); + var g = parseInt(sgr_cmds.shift(), 10); + var b = parseInt(sgr_cmds.shift(), 10); + if ((r >= 0 && r <= 255) && (g >= 0 && g <= 255) && (b >= 0 && b <= 255)) { + var c = { rgb: [r, g, b], class_name: 'truecolor' }; + if (is_foreground) + this.fg = c; + else + this.bg = c; + } + } + } + } + } + return this.with_state(orig_txt); + var _a; + }; + return AnsiUp; +}()); +//# sourceMappingURL=ansi_up.js.map + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = AnsiUp; +}); \ No newline at end of file diff --git a/mcserver-manager/src/main/resources/components.js b/mcserver-manager/src/main/resources/components.js index f51f1b2..5e9e803 100644 --- a/mcserver-manager/src/main/resources/components.js +++ b/mcserver-manager/src/main/resources/components.js @@ -29,10 +29,177 @@ var NvLineChart = React.createClass({ this.d3ChartData.datum(this.props.datum); this.d3ChartData.transition().duration(500).call(this.chart); nv.utils.windowResize(this.chart.update); + }, + componentWillUnmount: function() { + var nvtooltip = document.querySelector('div[class~="nvtooltip"]'); + if (nvtooltip !== null) { + nvtooltip.parentElement.removeChild(nvtooltip) + } + } +}); + +var Tabs = React.createClass({ + onTabClick: function(idx){ + this.setState({ activeTab: idx }); + }, + /*--------------------*/ + getInitialState: function(){return{ + activeTab: 0 + }}, + render: function(){ + var _this = this; + + var tabsElm = []; + this.props.tabs.forEach(function(title, i){ + tabsElm.push(ce('li', (i === _this.state.activeTab ? {className: 'active'} : null), + ce('a', {href: '#tab'+(i+1), onClick: _this.onTabClick.bind(_this, i)}, title))); + }); + + var showElement = this.props.children.map(function(child, i){ + return ce('div', (i !== _this.state.activeTab ? {style: {display: 'none'}} : null), child); + }); + + return(ce('div', null, + ce('ul', {className: 'nav nav-tabs', id: 'tabs'}, tabsElm), + showElement + )) + }, + componentDidUpdate: function() { + if (this.props.stateCallback !== null) { + this.props.stateCallback(this.state.activeTab); + } + } +}); + +var ScrollingContent = React.createClass({ + totalHeight: 0, + ownHeight: 0, + scrollRatio: 0, + lastPageY: 0, + updateScrollParams: function () { + this.totalHeight = this.refs.content.scrollHeight; + this.ownHeight = this.refs.content.clientHeight; + this.scrollRatio = this.ownHeight / this.totalHeight; + if (isNaN(this.scrollRatio)) this.scrollRatio = 0; + + this.refs.scroll.style.height = this.scrollRatio * 100 + "%"; + this.refs.scroll.style.top = (this.refs.content.scrollTop / this.totalHeight) * 100 + "%"; + }, + toggleSelect: function (value, event) { return value; }, + handleScrollMouseDown: function (event) { + this.lastPageY = event.pageY; + document.body.classList.add('scroll-grabbed'); + this.refs.scroll.classList.add('scroll-grabbed'); + + document.onselectstart = this.toggleSelect.bind(null, false); + document.addEventListener('mousemove', this.handleScrollMouseMove); + document.addEventListener('mouseup', this.handleScrollMouseUp); + }, + handleScrollMouseMove: function (event) { + var delta = event.pageY - this.lastPageY; + this.lastPageY = event.pageY; + + var _this = this; + window.requestAnimationFrame(function(){ + _this.refs.content.scrollTop += delta / _this.scrollRatio; + _this.refs.scroll.style.top = (_this.refs.content.scrollTop / _this.totalHeight) * 100 + "%"; + }); + }, + handleScrollMouseUp: function (event) { + document.body.classList.remove('scroll-grabbed'); + this.refs.scroll.classList.remove('scroll-grabbed'); + + document.onselectstart = this.toggleSelect.bind(null, true); + document.removeEventListener('mousemove', this.handleScrollMouseMove); + document.removeEventListener('mouseup', this.handleScrollMouseUp); + }, + /*--------------------*/ + render: function () { + return ( + ce('div', {className: this.props.className}, + ce('div', {className: 'wrapper'}, + ce('div', {className: 'content', ref: 'content'}, this.props.children) + ), + ce('div', {className: 'scroll', ref: 'scroll'}) + ) + ); + }, + componentDidMount: function () { + this.updateScrollParams(); + this.refs.scroll.addEventListener('mousedown', this.handleScrollMouseDown); + + var _this = this; + this.refs.content.addEventListener('scroll', function(){ + window.requestAnimationFrame(function(){ + _this.refs.scroll.style.top = (_this.refs.content.scrollTop / _this.totalHeight) * 100 + "%"; + }); + }); + }, + componentDidUpdate: function () { + this.updateScrollParams(); + } +}); + +var WebConsole = React.createClass({ + ws: null, + connect: function(){ + if (this.ws !== null) return; + var _this = this; + + this.ws = new WebSocket("ws://127.0.0.1:8770"); //FIXME указывать ip:port из настроек + this.ws.onopen = function(){ console.debug('WS: open...'); }; + this.ws.onclose = function(){ console.debug('WS: close...'); }; + this.ws.onerror = function(e){ console.debug('WS: error'); console.error(e); }; + this.ws.onmessage = function(event){ + _this.setState({ lines: _this.state.lines.concat([event.data]) }); //TODO необходимо ограничить кол-во строк + }; + }, + disconnect: function() { + if (this.ws === null) return; + this.ws.close(); + this.ws = null; + }, + focusInput: function() { + this.refs.input.focus(); + }, + handleKeyInput: function(event) { + if (event.key === 'Enter') { + console.debug("send command '" + this.refs.input.value + "'"); + this.ws.send(':'+this.refs.input.value); + this.refs.input.value = ''; + } + }, + /*--------------------*/ + getInitialState: function(){return{ + lines: [] + }}, + render: function(){ + var ansi_up = new AnsiUp; + + return( + ce('div', {id: 'webconsole'}, + ce(ScrollingContent, {className: 'output'}, + this.state.lines.map(function(line){ + return ce('p', {dangerouslySetInnerHTML: {__html: ansi_up.ansi_to_html(line)}}); + }) + ), + ce('input', {ref: 'input', 'onKeyPress': this.handleKeyInput}) + ) + ) + }, + componentWillUnmount: function(){ + this.disconnect(); } }); var ServerInfo = React.createClass({ + tabStateWebConsole: function(state) { + if (state === 1) { + this.refs.webconsole.connect(); + this.refs.webconsole.focusInput(); + } + }, + /*--------------------*/ getInitialState: function(){return { title: null, data: [] @@ -44,12 +211,15 @@ var ServerInfo = React.createClass({ return( ce('div', null, ce('h2', {style: {'margin-top': '0px'}}, this.state.title), - ce(NvLineChart, {datum: [{ - key: 'Online players', - color: '#37d668', - area: true, - values: this.state.data - }]}) + ce(Tabs, {tabs: ['Онлайн', 'Консоль'], stateCallback: this.tabStateWebConsole}, + ce(NvLineChart, {datum: [{ + key: 'Online players', + color: '#37d668', + area: true, + values: this.state.data + }]}), + ce(WebConsole, {ref: 'webconsole'}) + ) ) ) } diff --git a/mcserver-manager/src/main/resources/module.js b/mcserver-manager/src/main/resources/module.js index 0b42a4b..d8c052b 100644 --- a/mcserver-manager/src/main/resources/module.js +++ b/mcserver-manager/src/main/resources/module.js @@ -60,6 +60,13 @@ var ContentModule = React.createClass({ function(){ _this.setState({nvScriptReady: -5}); console.error('d3 - error'); } ); + loadScript("/mcsmanager/ansi_up.js", + function(){ console.debug('ansi_up - ok'); }, + function(){ console.debug('ansi_up - error'); } + ); + + loadStyle("/mcsmanager/moduleStyle.css"); + this.requestServerList(); }, render: function(){ diff --git a/mcserver-manager/src/main/resources/moduleStyle.css b/mcserver-manager/src/main/resources/moduleStyle.css new file mode 100644 index 0000000..063e814 --- /dev/null +++ b/mcserver-manager/src/main/resources/moduleStyle.css @@ -0,0 +1,57 @@ +#webconsole .output { + background-color: #1e1e1e; + color: #eee; + min-height: 500px; + height: 1px; + padding: 8px; + font-family: monospace; + position: relative; +} + +#webconsole .output .wrapper { + overflow: hidden; + height: 100%; +} + +#webconsole .output .wrapper .content { + overflow: auto; + height: 100%; + position: relative; + right: -18px; + margin-left: -18px; +} + +#webconsole .output .wrapper .content p { + margin: 0; +} + +#webconsole .output .scroll { + width: 9px; + background: #f00; + position: absolute; + top: 0; + height: 21.0836%; + cursor: -webkit-grab; + cursor: -moz-grab; + right: 0; +} + +.scroll-grabbed, +.scroll-grabbed * { + cursor: -webkit-grabbing !important; + cursor: -moz-grabbing !important; +} + +#webconsole input { + background-color: #1e1e1e; + background-image: url('data:image/svg+xml;utf-8,>'); + background-repeat: no-repeat; + color: #eee; + border: none; + padding: 8px 8px 8px 1.5em; + width: 100%; +} + +#webconsole input:focus { + outline: none; +} \ No newline at end of file diff --git a/webinterface/build.gradle b/webinterface/build.gradle index b10045b..198e942 100644 --- a/webinterface/build.gradle +++ b/webinterface/build.gradle @@ -1,5 +1,5 @@ group = 'asys' -version = '0.18.3-SNAPSHOT' +version = '0.18.4-SNAPSHOT' buildscript { repositories { diff --git a/webinterface/src/main/resources/index.html b/webinterface/src/main/resources/index.html index 7bc0e36..b70d987 100644 --- a/webinterface/src/main/resources/index.html +++ b/webinterface/src/main/resources/index.html @@ -2,6 +2,7 @@ + ASys: Web interface