From 8899d29cb5c62ebf201b2289d170c36f8348b0a8 Mon Sep 17 00:00:00 2001 From: Paxton Hare Date: Wed, 8 Feb 2017 09:16:49 -0500 Subject: [PATCH] fixed #5 - add ability to read files off of filesystem fixed #2 - breakpoints working better fixed #1 - added consistent highlighting - added history to console commands - added support for ML < 8 --- build.gradle | 6 +- src/index.html | 3 +- .../com/marklogic/debugger/LoginInfo.java | 4 +- .../auth/ConnectionAuthenticationFilter.java | 14 ++- .../auth/ConnectionAuthenticationToken.java | 9 +- .../auth/MarkLogicAuthenticationManager.java | 6 +- .../marklogic/debugger/web/ApiController.java | 38 +++++- src/main/resources/modules/continue.xqy | 2 +- src/main/resources/modules/disable-server.xqy | 2 +- src/main/resources/modules/enable-server.xqy | 2 +- src/main/resources/modules/get-attached.xqy | 10 +- .../resources/modules/get-breakpoints.xqy | 12 ++ src/main/resources/modules/get-file.xqy | 30 +++-- src/main/resources/modules/get-files.xqy | 93 +++++++++++---- .../modules/get-marklogic-system-files.xqy | 45 ++++++++ src/main/resources/modules/get-servers.xqy | 23 ++-- src/main/resources/modules/get-stacktrace.xqy | 2 +- src/main/resources/modules/set-breakpoint.xqy | 22 +--- .../resources/modules/set-breakpoints.xqy | 9 +- src/main/resources/modules/step-in.xqy | 2 +- src/main/resources/modules/step-out.xqy | 2 +- src/main/resources/modules/step-over.xqy | 2 +- src/main/resources/modules/value.xqy | 23 ++-- src/main/ui/app/app.module.ts | 5 + src/main/ui/app/auth/auth.model.ts | 1 + src/main/ui/app/auth/auth.service.ts | 7 +- .../ui/app/codemirror/codemirror.component.ts | 60 +++++++++- src/main/ui/app/error/error.component.html | 16 +++ src/main/ui/app/error/error.component.scss | 38 ++++++ src/main/ui/app/error/error.component.ts | 32 ++++++ src/main/ui/app/error/index.ts | 1 + .../file-browser/file-browser.component.html | 2 +- src/main/ui/app/home/home.component.html | 20 +++- src/main/ui/app/home/home.component.scss | 108 +++++++++++++++++- src/main/ui/app/home/home.component.ts | 68 +++++++++-- src/main/ui/app/login/login.component.html | 3 + src/main/ui/app/login/login.component.ts | 2 +- .../ui/app/marklogic/marklogic.service.ts | 6 +- src/main/ui/index.html | 1 + 39 files changed, 622 insertions(+), 109 deletions(-) create mode 100644 src/main/resources/modules/get-breakpoints.xqy create mode 100644 src/main/resources/modules/get-marklogic-system-files.xqy create mode 100644 src/main/ui/app/error/error.component.html create mode 100644 src/main/ui/app/error/error.component.scss create mode 100644 src/main/ui/app/error/error.component.ts create mode 100644 src/main/ui/app/error/index.ts diff --git a/build.gradle b/build.gradle index 4b373ad..6cee8c3 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ targetCompatibility = 1.8 repositories { mavenLocal() jcenter() + maven { url 'https://developer.marklogic.com/maven2/' } } dependencies { @@ -38,6 +39,7 @@ dependencies { compile("org.springframework.boot:spring-boot-starter-web:${springBootVersion}") compile("org.springframework.boot:spring-boot-starter-security:${springBootVersion}") compile('com.marklogic:java-client-api:3.0.5') + compile('com.marklogic:marklogic-xcc:8.0.6') compile('commons-io:commons-io:2.4') // Optional Boot library - see https://docs.spring.io/spring-boot/docs/current/reference/html/using-boot-devtools.html @@ -47,10 +49,10 @@ dependencies { node { // Version of node to use. - version = '6.8.1' + version = '7.4.0' // Version of npm to use. - npmVersion = '3.10.8' + npmVersion = '4.0.5' download = true diff --git a/src/index.html b/src/index.html index 2dda011..53139a2 100644 --- a/src/index.html +++ b/src/index.html @@ -7,9 +7,10 @@ - + Loading... + diff --git a/src/main/java/com/marklogic/debugger/LoginInfo.java b/src/main/java/com/marklogic/debugger/LoginInfo.java index 550d656..57b8ece 100644 --- a/src/main/java/com/marklogic/debugger/LoginInfo.java +++ b/src/main/java/com/marklogic/debugger/LoginInfo.java @@ -4,9 +4,11 @@ public class LoginInfo { public String username; public String password; public String hostname; + public int port; public String toString() { return "{\"username\":\"" + username + "\"," + - "\"hostname\": \"" + hostname + "\"}"; + "\"hostname\": \"" + hostname + "\"," + + "\"port\": \"" + port + "\"}"; } } diff --git a/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationFilter.java b/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationFilter.java index a17b1d6..3f419cd 100644 --- a/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationFilter.java +++ b/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationFilter.java @@ -37,10 +37,12 @@ public class ConnectionAuthenticationFilter extends public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; public static final String SPRING_SECURITY_FORM_HOST_KEY = "hostname"; + public static final String SPRING_SECURITY_FORM_PORT_KEY = "port"; private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; private String hostnameParameter = SPRING_SECURITY_FORM_HOST_KEY; + private String hostportParameter = SPRING_SECURITY_FORM_PORT_KEY; private boolean postOnly = true; // ~ Constructors @@ -63,8 +65,10 @@ public Authentication attemptAuthentication(HttpServletRequest request, String username = obtainUsername(request); String password = obtainPassword(request); String hostname = obtainHostname(request); + Integer port = obtainPort(request); LoginInfo loginInfo = new LoginInfo(); loginInfo.hostname = hostname; + loginInfo.port = port; loginInfo.username = username; loginInfo.password = password; request.getSession().setAttribute("loginInfo", loginInfo); @@ -81,10 +85,14 @@ public Authentication attemptAuthentication(HttpServletRequest request, hostname = ""; } + if (port == null) { + port = 8000; + } + username = username.trim(); ConnectionAuthenticationToken authRequest = new ConnectionAuthenticationToken( - username, password, hostname); + username, password, hostname, port); // Allow subclasses to set the "details" property setDetails(request, authRequest); @@ -128,6 +136,10 @@ protected String obtainHostname(HttpServletRequest request) { return request.getParameter(hostnameParameter); } + protected Integer obtainPort(HttpServletRequest request) { + return Integer.parseInt(request.getParameter(hostportParameter)); + } + /** * Provided so that subclasses may configure what is put into the authentication * request's details property. diff --git a/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationToken.java b/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationToken.java index 93de296..4addeb1 100644 --- a/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationToken.java +++ b/src/main/java/com/marklogic/debugger/auth/ConnectionAuthenticationToken.java @@ -27,6 +27,7 @@ public class ConnectionAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private Object credentials; private Object hostname; + private Object port; // ~ Constructors // =================================================================================================== @@ -37,11 +38,12 @@ public class ConnectionAuthenticationToken extends AbstractAuthenticationToken { * will return false. * */ - public ConnectionAuthenticationToken(Object principal, Object credentials, Object hostname) { + public ConnectionAuthenticationToken(Object principal, Object credentials, Object hostname, Object port) { super(null); this.principal = principal; this.credentials = credentials; this.hostname = hostname; + this.port = port; setAuthenticated(false); } @@ -55,12 +57,13 @@ public ConnectionAuthenticationToken(Object principal, Object credentials, Objec * @param credentials * @param authorities */ - public ConnectionAuthenticationToken(Object principal, Object credentials, Object hostname, + public ConnectionAuthenticationToken(Object principal, Object credentials, Object hostname, Object port, Collection authorities) { super(authorities); this.principal = principal; this.credentials = credentials; this.hostname = hostname; + this.port = port; super.setAuthenticated(true); // must use super, as we override } @@ -79,6 +82,8 @@ public Object getHostname() { return this.hostname; } + public Object getPort() { return this.port; } + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( diff --git a/src/main/java/com/marklogic/debugger/auth/MarkLogicAuthenticationManager.java b/src/main/java/com/marklogic/debugger/auth/MarkLogicAuthenticationManager.java index 7d8b9f9..ee7ed8d 100644 --- a/src/main/java/com/marklogic/debugger/auth/MarkLogicAuthenticationManager.java +++ b/src/main/java/com/marklogic/debugger/auth/MarkLogicAuthenticationManager.java @@ -54,8 +54,9 @@ public Authentication authenticate(Authentication authentication) throws Authent String username = token.getPrincipal().toString(); String password = token.getCredentials().toString(); String hostname = token.getHostname().toString(); + int port = Integer.parseInt(token.getPort().toString()); - if (username == "" || password == "" || hostname == "") { + if (username == "" || password == "" || hostname == "" || port == 0) { throw new BadCredentialsException("Invalid credentials"); } /** @@ -63,6 +64,7 @@ public Authentication authenticate(Authentication authentication) throws Authent * authenticating users over and over. */ restConfig.setHost(hostname); + restConfig.setRestPort(port); RestClient client = new RestClient(restConfig, new SimpleCredentialsProvider(username, password)); URI uri = client.buildUri(pathToAuthenticateAgainst, null); try { @@ -78,7 +80,7 @@ public Authentication authenticate(Authentication authentication) throws Authent } return new ConnectionAuthenticationToken(token.getPrincipal(), token.getCredentials(), - token.getHostname(), token.getAuthorities()); + token.getHostname(), token.getPort(), token.getAuthorities()); } public void setPathToAuthenticateAgainst(String pathToAuthenticateAgainst) { diff --git a/src/main/java/com/marklogic/debugger/web/ApiController.java b/src/main/java/com/marklogic/debugger/web/ApiController.java index 92db4ca..9940fe8 100644 --- a/src/main/java/com/marklogic/debugger/web/ApiController.java +++ b/src/main/java/com/marklogic/debugger/web/ApiController.java @@ -4,11 +4,15 @@ import com.marklogic.client.DatabaseClientFactory; import com.marklogic.client.DatabaseClientFactory.Authentication; import com.marklogic.client.FailedRequestException; +import com.marklogic.client.ResourceNotFoundException; import com.marklogic.client.eval.EvalResult; import com.marklogic.client.eval.EvalResultIterator; import com.marklogic.client.eval.ServerEvaluationCall; import com.marklogic.debugger.auth.ConnectionAuthenticationToken; import com.marklogic.debugger.errors.InvalidRequestException; +import com.marklogic.xcc.*; +import com.marklogic.xcc.exceptions.RequestException; +import com.marklogic.xcc.types.ValueType; import org.apache.commons.io.IOUtils; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; @@ -91,6 +95,15 @@ public String getServerFiles(@PathVariable String serverId) throws InvalidReques } + @RequestMapping(value = "/marklogic/files", method = RequestMethod.GET) + @ResponseBody + public String getMarkLogicSystemFiles() throws InvalidRequestException { + ConnectionAuthenticationToken auth = (ConnectionAuthenticationToken)SecurityContextHolder.getContext().getAuthentication(); + HashMap hm = new HashMap<>(); + return evalQuery(auth, "get-marklogic-system-files.xqy", hm); + } + + @RequestMapping(value = "/servers/{serverId}/file", method = RequestMethod.GET) @ResponseBody public String getServerFile(@PathVariable String serverId, @RequestParam String uri) throws InvalidRequestException { @@ -169,6 +182,15 @@ public String setBreakpoints(@PathVariable String requestId, @RequestBody List hm = new HashMap<>(); + hm.put("requestId", requestId); + return evalQuery(auth, "set-breakpoints.xqy", hm); + } + @RequestMapping(value = "/requests/{requestId}/eval", method = RequestMethod.POST) @ResponseBody public String evalExpression(@PathVariable String requestId, @RequestBody String xquery) throws InvalidRequestException { @@ -207,7 +229,7 @@ private String evalQuery(ConnectionAuthenticationToken auth, String xquery, Hash String result = ""; if (auth != null) { try { - DatabaseClient client = DatabaseClientFactory.newClient((String)auth.getHostname(), 8000, (String)auth.getPrincipal(), (String)auth.getCredentials(), Authentication.DIGEST); + DatabaseClient client = DatabaseClientFactory.newClient((String)auth.getHostname(), (Integer)auth.getPort(), (String)auth.getPrincipal(), (String)auth.getCredentials(), Authentication.DIGEST); String q = getQuery(xquery); ServerEvaluationCall sec = client.newServerEval().xquery(q); for (String key : params.keySet()) { @@ -219,6 +241,20 @@ private String evalQuery(ConnectionAuthenticationToken auth, String xquery, Hash result += res.getString(); } } + catch(ResourceNotFoundException e) { + try { + ContentSource contentSource = ContentSourceFactory.newContentSource((String)auth.getHostname(), (Integer)auth.getPort(), (String)auth.getPrincipal(), (String)auth.getCredentials()); + Session session = contentSource.newSession(); + AdhocQuery adhocQuery = session.newAdhocQuery(getQuery(xquery)); + for (String key : params.keySet()) { + adhocQuery.setNewVariable(key, ValueType.XS_STRING, params.get(key)); + } + ResultSequence res = session.submitRequest(adhocQuery); + result += res.asString(); + } catch (RequestException e1) { + e1.printStackTrace(); + } + } catch(FailedRequestException e) { if (e.getFailedRequest().getMessageCode().equals("DBG-REQUESTRECORD")) { throw new InvalidRequestException(); diff --git a/src/main/resources/modules/continue.xqy b/src/main/resources/modules/continue.xqy index d98214f..a3ecd2b 100644 --- a/src/main/resources/modules/continue.xqy +++ b/src/main/resources/modules/continue.xqy @@ -1,3 +1,3 @@ declare variable $requestId external; -dbg:continue($requestId) +dbg:continue(xs:unsignedLong($requestId)) diff --git a/src/main/resources/modules/disable-server.xqy b/src/main/resources/modules/disable-server.xqy index 87f5fcd..16d36d1 100644 --- a/src/main/resources/modules/disable-server.xqy +++ b/src/main/resources/modules/disable-server.xqy @@ -1,3 +1,3 @@ declare variable $serverId external; -dbg:disconnect($serverId) \ No newline at end of file +dbg:disconnect(xs:unsignedLong($serverId)) diff --git a/src/main/resources/modules/enable-server.xqy b/src/main/resources/modules/enable-server.xqy index 4005ecd..1811555 100644 --- a/src/main/resources/modules/enable-server.xqy +++ b/src/main/resources/modules/enable-server.xqy @@ -1,3 +1,3 @@ declare variable $serverId external; -dbg:connect($serverId) \ No newline at end of file +dbg:connect(xs:unsignedLong($serverId)) diff --git a/src/main/resources/modules/get-attached.xqy b/src/main/resources/modules/get-attached.xqy index a85e591..8b5d8b8 100644 --- a/src/main/resources/modules/get-attached.xqy +++ b/src/main/resources/modules/get-attached.xqy @@ -5,13 +5,13 @@ declare variable $serverId external; let $a := json:array() let $_ := - for $attached in dbg:attached($serverId) - let $status := xdmp:request-status(xdmp:host(), $serverId, $attached) + for $attached in dbg:attached(xs:unsignedLong($serverId)) + let $status := xdmp:request-status(xdmp:host(), xs:unsignedLong($serverId), $attached) let $o := map:new(( map:entry("server", xdmp:server-name($status/*:server-id)), map:entry("host", xdmp:host-name($status/*:host-id)), - map:entry("modules", xdmp:database-name($status/*:modules)), - map:entry("database", xdmp:database-name($status/*:database)), + map:entry("modules", if ($status/*:modules = 0) then "FileSystem" else xdmp:database-name($status/*:modules)), + map:entry("database", if ($status/*:database = 0) then "FileSystem" else xdmp:database-name($status/*:database)), for $item in $status/*[fn:not(self::*:server-id or self::*:host-id or self::*:modules or self::*:database)] return map:entry(functx:words-to-camel-case(fn:replace(fn:local-name($item), "-", " ")), $item/fn:data()) @@ -19,4 +19,4 @@ let $_ := return json:array-push($a, $o) return - $a + xdmp:to-json($a) diff --git a/src/main/resources/modules/get-breakpoints.xqy b/src/main/resources/modules/get-breakpoints.xqy new file mode 100644 index 0000000..1e44f55 --- /dev/null +++ b/src/main/resources/modules/get-breakpoints.xqy @@ -0,0 +1,12 @@ +declare variable $requestId external; + +let $request-id := xs:unsignedLong($requestId) +let $expr := dbg:expr($request-id, dbg:breakpoints($request-id)) +let $o := json:object() +let $_ := ( + map:put($o, "uri", $expr/dbg:uri/fn:string()), + map:put($o, "line", $expr/dbg:line/fn:data()), + map:put($o, "statement", $expr/dbg:expr-source/fn:string()) +) +return + xdmp:to-json($o) diff --git a/src/main/resources/modules/get-file.xqy b/src/main/resources/modules/get-file.xqy index ae695c0..709c0b7 100644 --- a/src/main/resources/modules/get-file.xqy +++ b/src/main/resources/modules/get-file.xqy @@ -1,18 +1,30 @@ +import module namespace admin = "http://marklogic.com/xdmp/admin" + at "/MarkLogic/admin.xqy"; + declare variable $serverId external; declare variable $uri external; declare variable $ml-dir := xdmp:filesystem-filepath('.') || '/Modules'; -let $modules-db := xdmp:server-modules-database($serverId) +let $server-id := xs:unsignedLong($serverId) +let $config := admin:get-configuration() +let $modules-db := admin:appserver-get-modules-database($config, $server-id) +let $server-root := admin:appserver-get-root($config, $server-id) return - xdmp:invoke-function(function() { + if ($modules-db = 0) then if (fn:starts-with($uri, "/MarkLogic/")) then xdmp:document-get($ml-dir || $uri) else - fn:doc($uri) - }, - map:new(( - map:entry("isolation", "different-transaction"), - map:entry("database", $modules-db), - map:entry("transactionMode", "update-auto-commit") - ))) + xdmp:document-get($server-root || $uri) + else + xdmp:invoke-function(function() { + if (fn:starts-with($uri, "/MarkLogic/")) then + xdmp:document-get($ml-dir || $uri) + else + fn:doc($uri) + }, + + different-transaction + {$modules-db} + update-auto-commit + ) diff --git a/src/main/resources/modules/get-files.xqy b/src/main/resources/modules/get-files.xqy index be24e71..6301eab 100644 --- a/src/main/resources/modules/get-files.xqy +++ b/src/main/resources/modules/get-files.xqy @@ -1,8 +1,15 @@ +xquery version "1.0-ml"; + +import module namespace admin = "http://marklogic.com/xdmp/admin" + at "/MarkLogic/admin.xqy"; + declare option xdmp:mapping "false"; declare variable $serverId external; -declare function local:build-files($uris as xs:string+, $parent as xs:string, $a as json:array) +declare variable $ml-dir := xdmp:filesystem-filepath('.') || '/Modules'; + +declare function local:build-files($uris as xs:string*, $parent as xs:string, $a as json:array) { let $parent := if (fn:ends-with($parent, "/")) then $parent @@ -15,17 +22,17 @@ declare function local:build-files($uris as xs:string+, $parent as xs:string, $a return $file ) - (:let $_ := xdmp:log(("files:", $files)):) for $file in $files let $o := json:object() let $_ := map:put($o, "name", $file) let $_ := map:put($o, "type", "file") + let $_ := map:put($o, "collapsed", fn:true()) let $_ := map:put($o, "uri", $parent || $file) return json:array-push($a, $o) }; -declare function local:build-dirs($uris as xs:string+, $parent as xs:string) +declare function local:build-dirs($uris as xs:string*, $parent as xs:string) { let $parent := if (fn:ends-with($parent, "/")) then $parent @@ -54,24 +61,66 @@ declare function local:build-dirs($uris as xs:string+, $parent as xs:string) return $a }; -let $modules-db := xdmp:server-modules-database($serverId) -let $uris := - xdmp:invoke-function(function() { - for $x in cts:search(fn:doc(), cts:true-query(), "unfiltered") - let $uri := xdmp:node-uri($x) - where fn:not(fn:ends-with($uri, "/")) - order by $uri ascending +declare function local:get-system-files($dir as xs:string, $a as json:array) { + for $entry in xdmp:filesystem-directory($dir)/dir:entry[dir:type = "file"] + let $o := json:object() + let $_ := map:put($o, "name", fn:string($entry/dir:filename)) + let $_ := map:put($o, "type", "file") + let $_ := map:put($o, "collapsed", fn:true()) + let $_ := map:put($o, "uri", fn:replace($entry/dir:pathname, $ml-dir, "")) + return + json:array-push($a, $o) +}; + +declare function local:get-system-dirs($dir as xs:string, $a as json:array) { + for $entry in xdmp:filesystem-directory($dir)/dir:entry[dir:type = "directory"] + return + let $o := json:object() + let $children := json:array() + let $_ := local:get-system-dirs($entry/dir:pathname, $children) + let $_ := local:get-system-files($dir, $children) + let $_ := map:put($o, "name", fn:string($entry/dir:filename)) + let $_ := map:put($o, "type", "dir") + let $_ := map:put($o, "children", $children) + return + json:array-push($a, $o) +}; + +let $server-id := xs:unsignedLong($serverId) +let $config := admin:get-configuration() +let $modules-db := admin:appserver-get-modules-database($config, $server-id) +let $server-root := admin:appserver-get-root($config, $server-id) +let $obj := + if ($modules-db = 0) then + let $o := json:object() + let $_ := map:put($o, "name", "/") + let $_ := map:put($o, "type", "dir") + let $children := json:array() + let $_ := local:get-system-dirs($server-root, $children) + let $_ := map:put($o, "children", $children) + return + $o + else + let $uris := + xdmp:invoke-function(function() { + for $x in cts:search(fn:doc(), cts:and-query(()), "unfiltered") + let $uri := xdmp:node-uri($x) + where fn:not(fn:ends-with($uri, "/")) + order by $uri ascending + return + $uri + }, + map:new(( + map:entry("isolation", "different-transaction"), + map:entry("database", $modules-db), + map:entry("transactionMode", "update-auto-commit") + ))) + let $o := json:object() + let $_ := map:put($o, "name", "/") + let $_ := map:put($o, "type", "dir") + let $children := local:build-dirs($uris, "/") + let $_ := map:put($o, "children", $children) return - $uri - }, - map:new(( - map:entry("isolation", "different-transaction"), - map:entry("database", $modules-db), - map:entry("transactionMode", "update-auto-commit") - ))) -let $o := json:object() -let $_ := map:put($o, "name", "/") -let $_ := map:put($o, "type", "dir") -let $_ := map:put($o, "children", local:build-dirs($uris, "/")) + $o return - $o + xdmp:to-json($obj) diff --git a/src/main/resources/modules/get-marklogic-system-files.xqy b/src/main/resources/modules/get-marklogic-system-files.xqy new file mode 100644 index 0000000..163b27e --- /dev/null +++ b/src/main/resources/modules/get-marklogic-system-files.xqy @@ -0,0 +1,45 @@ +xquery version "1.0-ml"; + +declare option xdmp:mapping "false"; + +declare variable $ml-dir := xdmp:filesystem-filepath('.') || '/Modules'; +declare variable $start-dir := $ml-dir || '/MarkLogic'; + +declare function local:get-system-files($dir as xs:string, $a as json:array) { + for $entry in xdmp:filesystem-directory($dir)/dir:entry[dir:type = "file"][fn:not(dir:filename = '.DS_Store')] + let $o := json:object() + let $_ := map:put($o, "name", fn:string($entry/dir:filename)) + let $_ := map:put($o, "type", "file") + let $_ := map:put($o, "uri", fn:replace($entry/dir:pathname, $ml-dir, "")) + return + json:array-push($a, $o) +}; + +declare function local:get-system-dirs($dir as xs:string, $a as json:array) { + for $entry in xdmp:filesystem-directory($dir)/dir:entry[dir:type = "directory"] + return + let $o := json:object() + let $children := json:array() + let $_ := local:get-system-dirs($entry/dir:pathname, $children) + let $_ := local:get-system-files($entry/dir:pathname, $children) + let $_ := map:put($o, "name", fn:string($entry/dir:filename)) + let $_ := map:put($o, "type", "dir") + let $_ := map:put($o, "collapsed", fn:true()) + let $_ := map:put($o, "children", $children) + return + json:array-push($a, $o) +}; + +let $obj := + let $o := json:object() + let $_ := map:put($o, "name", "/MarkLogic") + let $_ := map:put($o, "type", "dir") + let $_ := map:put($o, "collapsed", fn:true()) + let $children := json:array() + let $_ := local:get-system-dirs($start-dir, $children) + let $_ := local:get-system-files($start-dir, $children) + let $_ := map:put($o, "children", $children) + return + $o +return + xdmp:to-json($obj) diff --git a/src/main/resources/modules/get-servers.xqy b/src/main/resources/modules/get-servers.xqy index 66e05a1..fbf03e4 100644 --- a/src/main/resources/modules/get-servers.xqy +++ b/src/main/resources/modules/get-servers.xqy @@ -1,10 +1,15 @@ -json:to-array( - let $connected := dbg:connected() - for $server in xdmp:servers() - return - object-node { - "id": fn:string($server), - "name": xdmp:server-name($server), - "connected": fn:exists($connected[. = $server]) - } +xdmp:to-json( + json:to-array( + let $connected := dbg:connected() + for $server in xdmp:servers() + return + let $o := json:object() + let $_ := ( + map:put($o, "id", fn:string($server)), + map:put($o, "name", xdmp:server-name($server)), + map:put($o, "connected", fn:exists($connected[. = $server])) + ) + return + $o + ) ) diff --git a/src/main/resources/modules/get-stacktrace.xqy b/src/main/resources/modules/get-stacktrace.xqy index e708a68..023c04d 100644 --- a/src/main/resources/modules/get-stacktrace.xqy +++ b/src/main/resources/modules/get-stacktrace.xqy @@ -14,7 +14,7 @@ declare function local:build-var-array($vars) { $array }; -let $stack := dbg:stack($requestId) +let $stack := dbg:stack(xs:unsignedLong($requestId)) let $e := json:array() let $_ := for $expr in $stack/*:expr diff --git a/src/main/resources/modules/set-breakpoint.xqy b/src/main/resources/modules/set-breakpoint.xqy index 2d09b4d..f94fb23 100644 --- a/src/main/resources/modules/set-breakpoint.xqy +++ b/src/main/resources/modules/set-breakpoint.xqy @@ -1,34 +1,24 @@ -(:declare variable $serverId external; -declare variable $uri external; -declare variable $line external; -:) - declare variable $serverId external; declare variable $uri external; declare variable $line external; -(:let $serverId := 12693404844329926599:) -(:let $uri := "/com.marklogic.hub/collectors/query.xqy" -let $line := 36:) -let $modules-db := xdmp:server-modules-database($serverId) +let $modules-db := xdmp:server-modules-database(xs:unsignedLong($serverId)) return let $results := dbg:eval(' declare variable $uri external; declare variable $line external; - xdmp:request(), - dbg:line(xdmp:request(), $uri, $line) + let $request := xdmp:request() + let $expressions := dbg:line($request, $uri, $line) ! dbg:expr($request, .) + return + (($expressions[dbg:line = $line])[1], $expressions[1])[1]/dbg:expr-id/fn:data() ', ( xs:QName("uri"), $uri, - xs:QName("line"), $line + xs:QName("line"), xs:unsignedInt($line) ), map:new(( map:entry("modules", $modules-db) ))) let $request := $results[1] - (:let $_ := dbg:continue($request):) return $results - - -(:dbg:break($request as xs:unsignedLong, $expression as xs:unsignedLong):) diff --git a/src/main/resources/modules/set-breakpoints.xqy b/src/main/resources/modules/set-breakpoints.xqy index 2a8467c..6657658 100644 --- a/src/main/resources/modules/set-breakpoints.xqy +++ b/src/main/resources/modules/set-breakpoints.xqy @@ -2,6 +2,11 @@ declare variable $requestId external; declare variable $uri external; declare variable $line external; -let $expr-id := dbg:line($requestId, $uri, $line) +let $expr-id := + let $requestId := xs:unsignedLong($requestId) + let $line := xs:unsignedInt($line) + 1 + let $expressions := dbg:line($requestId, $uri, $line) ! dbg:expr($requestId, .) + return + (($expressions[dbg:line = $line])[1], $expressions[1])[1]/dbg:expr-id/fn:data() return - dbg:break($requestId, $expr-id) + dbg:break(xs:unsignedLong($requestId), $expr-id) diff --git a/src/main/resources/modules/step-in.xqy b/src/main/resources/modules/step-in.xqy index 2ffb0a8..d37fd6e 100644 --- a/src/main/resources/modules/step-in.xqy +++ b/src/main/resources/modules/step-in.xqy @@ -1,3 +1,3 @@ declare variable $requestId external; -dbg:step($requestId) +dbg:step(xs:unsignedLong($requestId)) diff --git a/src/main/resources/modules/step-out.xqy b/src/main/resources/modules/step-out.xqy index a47b1ca..bd64e6f 100644 --- a/src/main/resources/modules/step-out.xqy +++ b/src/main/resources/modules/step-out.xqy @@ -1,3 +1,3 @@ declare variable $requestId external; -dbg:out($requestId) +dbg:out(xs:unsignedLong($requestId)) diff --git a/src/main/resources/modules/step-over.xqy b/src/main/resources/modules/step-over.xqy index 0aa23f0..5b236ee 100644 --- a/src/main/resources/modules/step-over.xqy +++ b/src/main/resources/modules/step-over.xqy @@ -1,4 +1,4 @@ declare variable $requestId external; -dbg:next($requestId) +dbg:next(xs:unsignedLong($requestId)) diff --git a/src/main/resources/modules/value.xqy b/src/main/resources/modules/value.xqy index 4ba965b..2274e2e 100644 --- a/src/main/resources/modules/value.xqy +++ b/src/main/resources/modules/value.xqy @@ -1,10 +1,19 @@ declare variable $requestId external; declare variable $xquery external; -try { - dbg:value($requestId, $xquery) -} -catch($e) { - xdmp:log($e), - xdmp:rethrow() -} +let $o := json:object() +let $_ := + try { + map:put($o, "resp", dbg:value(xs:unsignedLong($requestId), $xquery)), + map:put($o, "error", fn:false()) + } + catch($e) { + map:put($o, "resp", xdmp:quote($e, + yes + yes + yes + )), + map:put($o, "error", fn:true()) + } +return + xdmp:to-json($o) diff --git a/src/main/ui/app/app.module.ts b/src/main/ui/app/app.module.ts index cddd137..b53b249 100644 --- a/src/main/ui/app/app.module.ts +++ b/src/main/ui/app/app.module.ts @@ -14,6 +14,7 @@ import { FileBrowserComponent } from './file-browser'; import { HeaderComponent } from './header'; import { HomeComponent } from './home'; import { LoginComponent } from './login'; +import { ErrorComponent } from './error'; import { SubsectionComponent } from './subsection'; import { MarkLogicService } from './marklogic'; import { ROUTES } from './app.routes'; @@ -25,6 +26,7 @@ import { ROUTES } from './app.routes'; HeaderComponent, HomeComponent, LoginComponent, + ErrorComponent, SubsectionComponent, CodemirrorComponent ], @@ -37,6 +39,9 @@ import { ROUTES } from './app.routes'; MdlModule, GridManiaModule ], + entryComponents: [ + ErrorComponent + ], providers: [ AUTH_PROVIDERS, MarkLogicService diff --git a/src/main/ui/app/auth/auth.model.ts b/src/main/ui/app/auth/auth.model.ts index f03eb73..c10be48 100644 --- a/src/main/ui/app/auth/auth.model.ts +++ b/src/main/ui/app/auth/auth.model.ts @@ -1,6 +1,7 @@ export class AuthModel { constructor( public hostname: string, + public port: number, public username: string, public password: string ) { } diff --git a/src/main/ui/app/auth/auth.service.ts b/src/main/ui/app/auth/auth.service.ts index c5400d8..9499943 100644 --- a/src/main/ui/app/auth/auth.service.ts +++ b/src/main/ui/app/auth/auth.service.ts @@ -22,7 +22,12 @@ export class AuthService { login(authInfo: AuthModel) { // const params = `username=${authInfo.username}&password=${authInfo.password}&hostname=${authInfo.hostname}`; - const body = this.formData({ username: authInfo.username, password: authInfo.password, hostname: authInfo.hostname }); + const body = this.formData({ + username: authInfo.username, + password: authInfo.password, + hostname: authInfo.hostname, + port: authInfo.port + }); let headers = new Headers(); headers.set('Content-Type', 'application/x-www-form-urlencoded'); let options = new RequestOptions({ diff --git a/src/main/ui/app/codemirror/codemirror.component.ts b/src/main/ui/app/codemirror/codemirror.component.ts index a9b9943..afa813c 100644 --- a/src/main/ui/app/codemirror/codemirror.component.ts +++ b/src/main/ui/app/codemirror/codemirror.component.ts @@ -15,6 +15,7 @@ import { Breakpoint } from '../marklogic'; import * as CodeMirror from 'codemirror'; require('codemirror/mode/xquery/xquery'); require('codemirror/mode/javascript/javascript'); +require('codemirror/addon/selection/mark-selection'); /** * CodeMirror component @@ -45,6 +46,8 @@ export class CodemirrorComponent implements OnInit, OnChanges { private _line: number; private _expression: string; + private currentStatement: CodeMirror.TextMarker; + @Output() instance: CodeMirror.EditorFromTextArea = null; /** @@ -159,6 +162,11 @@ export class CodemirrorComponent implements OnInit, OnChanges { } highlightExpression() { + if (this.currentStatement) { + this.currentStatement.clear(); + this.currentStatement = null; + } + if (this._value === '' || !this._expression || !this._line) { return; } @@ -217,8 +225,23 @@ export class CodemirrorComponent implements OnInit, OnChanges { } eat(); break; + case 'comment': + if (peak() === ':') { + eat(); + if (peak() === ')') { + state = 'start'; + eat(); + } + continue; + } + eat(); + break; case 'start': - if (peak() === this._expression[pos]) { + if (peak() === this._expression[pos] || + ( + (peak() === '"' || peak() === '\'') && + (this._expression[pos] === '"' || this._expression[pos] === '\'') + )) { eatExpr(); if (pos > (this._expression.length - 1)) { state = 'done'; @@ -226,6 +249,38 @@ export class CodemirrorComponent implements OnInit, OnChanges { endChar = j; } } else if (peak() === '(' || peak() === ')') { + eat(); + if (peak() === ':') { + state = 'comment'; + eat(); + } + continue; + } else if (peak() === '/' && this._expression[pos] === 'd') { + if (this._expression.substring(pos).startsWith('descendant::')) { + pos += 'descendant::'.length; + } else { + reset(); + } + } else if (this._expression[pos] === 'f') { + if (this._expression.substring(pos).startsWith('fn:unordered(')) { + pos += 'fn:unordered('.length; + continue; + } else if (this._expression.substring(pos).startsWith('fn:')) { + pos += 'fn:'.length; + continue; + } else { + reset(); + } + } else if (this._expression[pos] === 'n') { + if (this._expression.substring(pos).startsWith('n:')) { + pos += 'n:f'.length; + continue; + } else { + reset(); + } + } else if (this._expression[pos] === ')') { + eatExpr(); + continue; } else { reset(); } @@ -235,7 +290,8 @@ export class CodemirrorComponent implements OnInit, OnChanges { } if (state === 'done') { - this.instance.getDoc().setSelection({line: startLine, ch: startChar}, {line: endLine, ch: endChar + 1}); + this.currentStatement = this.instance.getDoc().markText({line: startLine, ch: startChar}, {line: endLine, ch: endChar + 1}, {className: "current-statement"}); + // this.instance.getDoc().setSelection({line: startLine, ch: startChar}, {line: endLine, ch: endChar + 1}); } } diff --git a/src/main/ui/app/error/error.component.html b/src/main/ui/app/error/error.component.html new file mode 100644 index 0000000..7e6fdbd --- /dev/null +++ b/src/main/ui/app/error/error.component.html @@ -0,0 +1,16 @@ +
+
+
Server Error
+
+ + + +
+
+
{{error}}
+
+ +
+ diff --git a/src/main/ui/app/error/error.component.scss b/src/main/ui/app/error/error.component.scss new file mode 100644 index 0000000..c74e767 --- /dev/null +++ b/src/main/ui/app/error/error.component.scss @@ -0,0 +1,38 @@ +/deep/ .mdl-dialog { + width: 500px; +} + +/deep/ mdl-dialog-host-component { + padding: 0px; +} + +.error-dialog { + min-width: 500px; + width: 500px; +} + +pre { + width: 100%; + overflow: auto; +} + +.mdl-dialog__title { + + font-size: 1.5rem; + + .mdl-button--fab.mdl-button--mini-fab { + height: 30px; + min-width: 30px; + width: 30px; + } + + display: flex; + flex-direction: row; + align-items: center; + + padding: 12px 24px 12px; + + background-color: #222; + + color: white; +} diff --git a/src/main/ui/app/error/error.component.ts b/src/main/ui/app/error/error.component.ts new file mode 100644 index 0000000..1069591 --- /dev/null +++ b/src/main/ui/app/error/error.component.ts @@ -0,0 +1,32 @@ +import { Component, HostListener, Inject } from '@angular/core'; + +import { MdlDialogReference } from 'angular2-mdl'; + +@Component({ + selector: 'app-error', + templateUrl: './error.component.html', + styleUrls: ['./error.component.scss'] +}) +export class ErrorComponent { + error: string; + + constructor( + private dialog: MdlDialogReference, + @Inject('error') error: string + ) { + this.error = error; + } + + hide() { + this.dialog.hide(); + } + + @HostListener('keydown.esc') + public onEsc(): void { + this.cancel(); + } + + cancel() { + this.hide(); + } +} diff --git a/src/main/ui/app/error/index.ts b/src/main/ui/app/error/index.ts new file mode 100644 index 0000000..d34541e --- /dev/null +++ b/src/main/ui/app/error/index.ts @@ -0,0 +1 @@ +export * from './error.component'; diff --git a/src/main/ui/app/file-browser/file-browser.component.html b/src/main/ui/app/file-browser/file-browser.component.html index d462278..350b2cb 100644 --- a/src/main/ui/app/file-browser/file-browser.component.html +++ b/src/main/ui/app/file-browser/file-browser.component.html @@ -14,7 +14,7 @@ -
    +
    • diff --git a/src/main/ui/app/home/home.component.html b/src/main/ui/app/home/home.component.html index 5b2817f..47745f4 100644 --- a/src/main/ui/app/home/home.component.html +++ b/src/main/ui/app/home/home.component.html @@ -24,6 +24,9 @@ +
      @@ -47,6 +50,9 @@
      +
      + DEBUG A PROCESS TO ENABLE +
      pause play_arrow @@ -61,18 +67,22 @@
    -
      +
      • {{variable.name}}: {{variable.value}}
      +

       

      - {{output.type === 'i' ? '>' : '<-' }} {{output.txt}} + Server Error (show) + + {{output.type === 'i' ? '>' : '<-' }} {{output.txt}} +
      - > + >
      @@ -82,11 +92,10 @@

      No breakpoints

      -
      {{uri}}
      • - {{breakpoint.uri}}:{{breakpoint.line}} + {{breakpoint.uri}}:{{breakpoint.line + 1}}
      @@ -100,6 +109,7 @@

      No Processes stopped

      • + {{attachment.requestRewrittenText || attachment.requestText}}
      diff --git a/src/main/ui/app/home/home.component.scss b/src/main/ui/app/home/home.component.scss index a7da455..97c511f 100644 --- a/src/main/ui/app/home/home.component.scss +++ b/src/main/ui/app/home/home.component.scss @@ -3,10 +3,27 @@ .current-line { background-color: rgba(128,128,128,0.1); } + + .current-statement { + background-color: rgba(255,0,0,0.2); + } +} + +// /deep/ .mdl-switch.is-checked .mdl-switch__track { +// // background: rgba(0,0,0, 0.26); +// } + +/deep/ .mdl-layout__header { + background-color: #222; + border-color: #080808; } /deep/ .breakpoints { width: .8em; + + .section-body { + padding: 10px; + } } /deep/ .breakpoint { @@ -20,9 +37,35 @@ height: 100%; } +.breakpoints { + ul { + margin: 0; + padding: 10px; + list-style: none; + + &:hover { + background-color: rgba(128,128,128,0.25); + } + } +} + .attached { - li { - cursor: pointer; + ul { + margin: 0; + padding: 10px; + list-style: none; + + &:hover { + background-color: rgba(128,128,128,0.25); + } + + li { + cursor: pointer; + } + } + + .section-body { + padding: 10px; } } @@ -61,7 +104,33 @@ } .debug-area { - min-height: 100px; + min-height: 150px; + + .debugger { + position: relative; + + .disabled { + position: absolute; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + background: rgba(255,255,255, 0.75); + z-index: 10000; + + span { + display: inline-block; + width: 100%; + font-weight: bold; + margin: auto; + text-align: center; + top: 50%; + transform: translate(0, -50%); + position: relative; + background: rgba(255,255,255, 0.75); + } + } + } .clickable { color: #666; @@ -135,17 +204,48 @@ ul.frames { margin-left: 5px; } +.no-request { + padding: 20px; + color: #666; +} + .console-input { border: none; background: transparent; + width: 60%; + &:focus { outline: 0; } + + .prompt { + color: #333; + } } .console-output { + color: #666; + .prompt { - color: #333; + color: #666; + } + + .error { + background-color: rgba(255, 0, 0, 0.5); + padding: 5px; + border-radius: 5px; + color: black; + width: 100%; + display: inline-block; + + a { + cursor: pointer; + } + + pre { + width: 100%; + white-space: pre-wrap; + } } } diff --git a/src/main/ui/app/home/home.component.ts b/src/main/ui/app/home/home.component.ts index 8ca6b45..b805713 100644 --- a/src/main/ui/app/home/home.component.ts +++ b/src/main/ui/app/home/home.component.ts @@ -5,6 +5,8 @@ import { MarkLogicService } from '../marklogic'; import { Breakpoint } from '../marklogic'; import { Router, ActivatedRoute } from '@angular/router'; import { AuthService } from '../auth'; +import { ErrorComponent } from '../error'; +import { MdlDialogService, MdlDialogReference } from 'angular2-mdl'; import * as _ from 'lodash'; @Component({ @@ -17,6 +19,7 @@ export class HomeComponent implements OnInit, OnDestroy { appservers: Array; selectedServer: any; serverFiles: any; + systemFiles: any; attached: any; currentUri: string; currentLine: number; @@ -30,6 +33,8 @@ export class HomeComponent implements OnInit, OnDestroy { stack: any; consoleInput: string; consoleOutput: Array = []; + commandHistory: Array = []; + commandHistoryIndex: number = -1; breakpointsSet: boolean = false; @@ -51,6 +56,7 @@ export class HomeComponent implements OnInit, OnDestroy { private authService: AuthService, private router: Router, private route: ActivatedRoute, + private dialogService: MdlDialogService, private marklogic: MarkLogicService) { } @@ -59,6 +65,10 @@ export class HomeComponent implements OnInit, OnDestroy { this.appserverName = params['appserverName']; this.requestId = params['requestId']; + if (!this.appserverName) { + this.router.navigate(['login']); + } + this.marklogic.getServers().subscribe((servers: any) => { this.appservers = servers; if (this.appserverName) { @@ -111,6 +121,14 @@ export class HomeComponent implements OnInit, OnDestroy { }); } + hasVariables() { + return this.stack && + this.stack.frames && + this.stack.frames[0] && + this.stack.frames[0].variables && + this.stack.frames[0].variables.length > 0; + } + debugRequest(requestId) { this.router.navigate(['server', this.appserverName, requestId]); } @@ -215,7 +233,10 @@ export class HomeComponent implements OnInit, OnDestroy { showFiles() { this.marklogic.getFiles(this.selectedServer.id).subscribe((files: any) => { this.serverFiles = files; - // this.$mdSidenav('left').toggle(); + }); + + this.marklogic.getSystemFiles().subscribe((files: any) => { + this.systemFiles = files; }); } @@ -225,6 +246,7 @@ export class HomeComponent implements OnInit, OnDestroy { this.marklogic.getFile(this.selectedServer.id, entry.uri).subscribe((txt: any) => { this.currentUri = entry.uri; this.fileText = txt; + this.getBreakpoints(); }); } @@ -234,7 +256,7 @@ export class HomeComponent implements OnInit, OnDestroy { this.currentUri = uri; this.fileText = txt; this.currentLine = line; - + this.getBreakpoints(); if (this.stack.expressions && this.stack.expressions.length > 0) { this.currentExpression = this.stack.expressions[0].expressionSource; } @@ -242,21 +264,53 @@ export class HomeComponent implements OnInit, OnDestroy { } consoleKeyPressed($event: KeyboardEvent) { + if (!this.requestId) { + return; + } if ($event.keyCode === 13) { this.consoleOutput.push({ txt: this.consoleInput, type: 'i' }); - this.marklogic.valueExpression(this.requestId, this.consoleInput).subscribe((output) => { - this.consoleOutput.push({ - txt: output, - type: 'o' - }); + this.commandHistory.push(this.consoleInput); + this.marklogic.valueExpression(this.requestId, this.consoleInput).subscribe((output: any) => { + if (!output.error) { + this.consoleOutput.push({ + txt: output.resp, + type: 'o' + }); + } else { + this.consoleOutput.push({ + txt: output.resp, + type: 'e' + }); + } }); this.consoleInput = null; + this.commandHistoryIndex = -1; + } else if ($event.keyCode === 38) { + if (this.commandHistoryIndex < (this.commandHistory.length - 1)) { + this.commandHistoryIndex++; + this.consoleInput = this.commandHistory[this.commandHistory.length - 1 - this.commandHistoryIndex]; + } + } else if ($event.keyCode === 40) { + if (this.commandHistoryIndex > 0) { + this.commandHistoryIndex--; + this.consoleInput = this.commandHistory[this.commandHistory.length - 1 - this.commandHistoryIndex]; + } } } + showError(errorText: string) { + this.dialogService.showCustomDialog({ + component: ErrorComponent, + providers: [ + { provide: 'error', useValue: errorText } + ], + isModal: true + }); + } + clearConsole() { this.consoleOutput = []; } diff --git a/src/main/ui/app/login/login.component.html b/src/main/ui/app/login/login.component.html index 738c2e1..7550d6e 100644 --- a/src/main/ui/app/login/login.component.html +++ b/src/main/ui/app/login/login.component.html @@ -7,6 +7,9 @@
      +
      + +
      diff --git a/src/main/ui/app/login/login.component.ts b/src/main/ui/app/login/login.component.ts index 32721b9..f7a54cc 100644 --- a/src/main/ui/app/login/login.component.ts +++ b/src/main/ui/app/login/login.component.ts @@ -9,7 +9,7 @@ import { MarkLogicService } from '../marklogic'; styleUrls: ['./login.component.scss'] }) export class LoginComponent { - authInfo: AuthModel = new AuthModel('localhost', 'admin', 'admin'); + authInfo: AuthModel = new AuthModel('localhost', 8000, 'admin', 'admin'); appServers: Array; currentServer: any; diff --git a/src/main/ui/app/marklogic/marklogic.service.ts b/src/main/ui/app/marklogic/marklogic.service.ts index 3e48c76..c23287c 100644 --- a/src/main/ui/app/marklogic/marklogic.service.ts +++ b/src/main/ui/app/marklogic/marklogic.service.ts @@ -25,6 +25,10 @@ export class MarkLogicService { return this.get('/api/servers/' + serverId + '/files'); } + getSystemFiles() { + return this.get('/api/marklogic/files'); + } + getFile(serverId, uri) { let options: RequestOptionsArgs = { headers: new Headers({'Accept': 'text/plain'}) @@ -110,7 +114,7 @@ export class MarkLogicService { valueExpression(requestId: any, expression: string) { return this.http.post(`/api/requests/${requestId}/value`, expression).map((resp: Response) => { - return resp.text(); + return resp.json(); }); } diff --git a/src/main/ui/index.html b/src/main/ui/index.html index cf20698..bc5592c 100644 --- a/src/main/ui/index.html +++ b/src/main/ui/index.html @@ -10,5 +10,6 @@ Loading... +