Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*~
|
||||
static/list.json
|
||||
static/servers.js
|
||||
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
Minetest server list
|
||||
====================
|
||||
|
||||
Setting up the webpage
|
||||
----------------------
|
||||
|
||||
You will have to install node.js, doT.js and their dependencies to compile
|
||||
the serverlist webpage template.
|
||||
|
||||
First install node.js, e.g.:
|
||||
|
||||
# apt-get install nodejs
|
||||
# # OR:
|
||||
# pacman -S nodejs
|
||||
# # OR:
|
||||
# emerge nodejs
|
||||
|
||||
Then install doT.js and its dependencies:
|
||||
|
||||
$ cd ~
|
||||
$ npm install dot commander mkdirp
|
||||
|
||||
And finally compile the template:
|
||||
|
||||
$ cd util/master/static
|
||||
$ ~/node_modules/dot/bin/dot-packer -s . -d .
|
||||
|
||||
You can now serve the webpage by copying the files in static/ to your web root, or by [starting the master server](#setting-up-the-server).
|
||||
|
||||
|
||||
Embedding the server list in a page
|
||||
-----------------------------------
|
||||
|
||||
<head>
|
||||
...
|
||||
<script>
|
||||
var master = {
|
||||
root: 'http://servers.minetest.net/',
|
||||
limit: 10,
|
||||
clients_min: 1,
|
||||
no_flags: 1,
|
||||
no_ping: 1,
|
||||
no_uptime: 1
|
||||
};
|
||||
</script>
|
||||
...
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
<div id="server_list"></div>
|
||||
...
|
||||
</body>
|
||||
<script src="list.js"></script>
|
||||
|
||||
|
||||
Setting up the server
|
||||
---------------------
|
||||
|
||||
1. Install Python 3 and pip:
|
||||
|
||||
# pacman -S python python-pip
|
||||
# # OR:
|
||||
# apt-get install python3 python3-pip
|
||||
|
||||
2. Install Flask, APSchedule, and (if using in production) uwsgi:
|
||||
|
||||
# # You might have to use pip3 if your system defaults to Python 2
|
||||
# pip install APSchedule flask uwsgi
|
||||
|
||||
3. Configure the server by changing options in config.py, which is a Flask
|
||||
configuration file.
|
||||
|
||||
4. Start the server:
|
||||
|
||||
$ ./server.py
|
||||
$ # Or for production:
|
||||
$ uwsgi -s /tmp/serverlist.sock -w server:app
|
||||
$ # Then configure according to http://flask.pocoo.org/docs/deploying/uwsgi/
|
||||
|
||||
26
config.py
Normal file
26
config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
# Enables detailed tracebacks and an interactive Python console on errors.
|
||||
# Never use in production!
|
||||
#DEBUG = True
|
||||
|
||||
# Makes the server more performant at sending static files when the
|
||||
# server is being proxied by a server that supports X-Sendfile.
|
||||
#USE_X_SENDFILE = True
|
||||
|
||||
# Address to listen for clients on
|
||||
HOST = "0.0.0.0"
|
||||
|
||||
# Port to listen on
|
||||
PORT = 8000
|
||||
|
||||
# File to store the JSON server list data in.
|
||||
FILENAME = "list.json"
|
||||
|
||||
# Ammount of time, is seconds, after which servers are removed from the list
|
||||
# if they haven't updated their listings. Note: By default Minetest servers
|
||||
# only announce once every 5 minutes, so this should be more than 300.
|
||||
PURGE_TIME = 350
|
||||
|
||||
# List of banned IP addresses.
|
||||
BANLIST = []
|
||||
|
||||
319
server.py
Executable file
319
server.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
import os, sys, json, time, socket
|
||||
from threading import Thread, RLock
|
||||
from operator import itemgetter
|
||||
|
||||
from apscheduler.scheduler import Scheduler
|
||||
from flask import Flask, request, send_from_directory
|
||||
|
||||
serverList = []
|
||||
maxServers = 0
|
||||
maxClients = 0
|
||||
listLock = RLock()
|
||||
|
||||
sched = Scheduler()
|
||||
sched.start()
|
||||
|
||||
app = Flask(__name__, static_url_path = "")
|
||||
app.config.from_pyfile("config.py")
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return app.send_static_file("index.html")
|
||||
|
||||
|
||||
@app.route("/list")
|
||||
def list():
|
||||
# We have to make sure that the list isn't cached,
|
||||
# since the list isn't really static.
|
||||
return send_from_directory(app.static_folder, app.config["FILENAME"],
|
||||
cache_timeout=0)
|
||||
|
||||
|
||||
@app.route("/announce", methods=["GET", "POST"])
|
||||
def announce():
|
||||
ip = request.remote_addr
|
||||
if ip.startswith("::ffff:"):
|
||||
ip = ip[7:]
|
||||
|
||||
if ip in app.config["BANLIST"]:
|
||||
return "Banned.", 403
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.form["json"]
|
||||
else:
|
||||
data = request.args["json"]
|
||||
|
||||
if len(data) > 5000:
|
||||
return "JSON data is too big.", 413
|
||||
|
||||
try:
|
||||
server = json.loads(data)
|
||||
except:
|
||||
return "Unable to process JSON data.", 400
|
||||
|
||||
if not "action" in server:
|
||||
return "Missing action field.", 400
|
||||
|
||||
if server["action"] == "start":
|
||||
server["uptime"] = 0
|
||||
|
||||
if server["action"] != "delete" and not checkRequest(server):
|
||||
return "Invalid JSON data.", 400
|
||||
|
||||
server["ip"] = ip
|
||||
|
||||
if not "port" in server:
|
||||
server["port"] = 30000
|
||||
|
||||
old = getServer(server["ip"], server["port"])
|
||||
|
||||
if server["action"] == "delete":
|
||||
if not old:
|
||||
return "Server not found.", 500
|
||||
removeServer(old)
|
||||
saveList()
|
||||
return "Removed from server list."
|
||||
|
||||
if server["action"] != "start" and not old:
|
||||
# Server to update not found, continue as a new server
|
||||
server["action"] = "start"
|
||||
|
||||
server["update_time"] = time.time()
|
||||
|
||||
if server["action"] == "start":
|
||||
server["start"] = time.time()
|
||||
else:
|
||||
server["start"] = old["start"]
|
||||
|
||||
if "clients_list" in server:
|
||||
server["clients"] = len(server["clients_list"])
|
||||
|
||||
if old:
|
||||
server["clients_top"] = max(server["clients"], old["clients_top"])
|
||||
else:
|
||||
server["clients_top"] = server["clients"]
|
||||
|
||||
# Make sure that startup options don't change
|
||||
if server["action"] != "start":
|
||||
if "mods" in old:
|
||||
server["mods"] = old["mods"]
|
||||
|
||||
# Popularity
|
||||
if old:
|
||||
server["updates"] = old["updates"] + 1
|
||||
# This is actally a count of all the client numbers we've received,
|
||||
# it includes clients that were on in the previous update.
|
||||
server["total_clients"] = old["total_clients"] + server["clients"]
|
||||
else:
|
||||
server["updates"] = 1
|
||||
server["total_clients"] = server["clients"]
|
||||
server["pop_v"] = server["total_clients"] / server["updates"]
|
||||
|
||||
finishRequestAsync(server)
|
||||
|
||||
return "Thanks, your request has been filed.", 202
|
||||
|
||||
|
||||
# Returns ping time in seconds (up), False (down), or None (error).
|
||||
def serverUp(address, port):
|
||||
try:
|
||||
start = time.time()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(3)
|
||||
buf = b"\x4f\x45\x74\x03\x00\x00\x00\x01"
|
||||
sock.sendto(buf, (address, port))
|
||||
data, addr = sock.recvfrom(1000)
|
||||
if not data:
|
||||
return False
|
||||
peer_id = data[12:14]
|
||||
buf = b"\x4f\x45\x74\x03" + peer_id + b"\x00\x00\x03"
|
||||
sock.sendto(buf, (address, port))
|
||||
sock.close()
|
||||
end = time.time()
|
||||
return end - start
|
||||
except socket.timeout:
|
||||
return False
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def getServerAndIndex(ip, port):
|
||||
with listLock:
|
||||
for i, server in enumerate(serverList):
|
||||
if server["ip"] == ip and server["port"] == port:
|
||||
return (i, server)
|
||||
|
||||
|
||||
def getServer(ip, port):
|
||||
server = getServerAndIndex(ip, port)
|
||||
return server and server[1]
|
||||
|
||||
|
||||
def removeServer(server):
|
||||
with listLock:
|
||||
try:
|
||||
serverList.remove(server)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def sortList():
|
||||
with listLock:
|
||||
serverList.sort(key=itemgetter("clients", "start"), reverse=True)
|
||||
|
||||
@sched.interval_schedule(minutes=1, coalesce=True, max_instances=1)
|
||||
def purgeOld():
|
||||
with listLock:
|
||||
for server in serverList:
|
||||
if server["update_time"] < time.time() - app.config["PURGE_TIME"]:
|
||||
serverList.remove(server)
|
||||
saveList()
|
||||
|
||||
|
||||
def loadList():
|
||||
global serverList, maxServers, maxClients
|
||||
try:
|
||||
with open(os.path.join("static", app.config["FILENAME"]), "r") as fd:
|
||||
data = json.load(fd)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
with listLock:
|
||||
serverList = data["list"]
|
||||
maxServers = data["total_max"]["servers"]
|
||||
maxClients = data["total_max"]["clients"]
|
||||
|
||||
|
||||
def saveList():
|
||||
global maxServers, maxClients
|
||||
with listLock:
|
||||
servers = len(serverList)
|
||||
clients = 0
|
||||
for server in serverList:
|
||||
clients += server["clients"]
|
||||
|
||||
maxServers = max(servers, maxServers)
|
||||
maxClients = max(clients, maxClients)
|
||||
|
||||
with open(os.path.join("static", app.config["FILENAME"]), "w") as fd:
|
||||
json.dump({
|
||||
"total": {"servers": servers, "clients": clients},
|
||||
"total_max": {"servers": maxServers, "clients": maxClients},
|
||||
"list": serverList
|
||||
},
|
||||
fd,
|
||||
indent = "\t" if app.config["DEBUG"] else None)
|
||||
|
||||
|
||||
# fieldName: (Required, Type, SubType)
|
||||
fields = {
|
||||
"action": (True, "str"),
|
||||
|
||||
"address": (False, "str"),
|
||||
"port": (False, "int"),
|
||||
|
||||
"clients": (True, "int"),
|
||||
"clients_max": (True, "int"),
|
||||
"uptime": (True, "int"),
|
||||
"game_time": (True, "int"),
|
||||
"lag": (False, "float"),
|
||||
|
||||
"clients_list": (False, "list", "str"),
|
||||
"mods": (False, "list", "str"),
|
||||
|
||||
"version": (True, "str"),
|
||||
"gameid": (True, "str"),
|
||||
"mapgen": (False, "str"),
|
||||
"url": (False, "str"),
|
||||
"privs": (False, "str"),
|
||||
"name": (True, "str"),
|
||||
"description": (True, "str"),
|
||||
|
||||
# Flags
|
||||
"creative": (False, "bool"),
|
||||
"dedicated": (False, "bool"),
|
||||
"damage": (False, "bool"),
|
||||
"liquid_finite": (False, "bool"),
|
||||
"pvp": (False, "bool"),
|
||||
"password": (False, "bool"),
|
||||
"rollback": (False, "bool"),
|
||||
"can_see_far_names": (False, "bool"),
|
||||
}
|
||||
def checkRequest(server):
|
||||
for name, data in fields.items():
|
||||
if not name in server:
|
||||
if data[0]: return False
|
||||
else: continue
|
||||
#### Compatibility code ####
|
||||
# Accept anything in boolean fields but convert it to a
|
||||
# boolean, because old servers send some booleans as strings.
|
||||
if data[1] == "bool":
|
||||
server[name] = True if server[name] else False
|
||||
continue
|
||||
# clients_max and port were sent as strings instead of integers
|
||||
if (name == "clients_max" or name == "port") and\
|
||||
type(server[name]).__name__ == "str":
|
||||
server[name] = int(server[name])
|
||||
continue
|
||||
#### End compatibility code ####
|
||||
if type(server[name]).__name__ != data[1]:
|
||||
return False
|
||||
if len(data) >= 3:
|
||||
for item in server[name]:
|
||||
if type(item).__name__ != data[2]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def finishRequestAsync(server):
|
||||
th = Thread(name = "ServerListThread",
|
||||
target = asyncFinishThread,
|
||||
args = (server,))
|
||||
th.start()
|
||||
|
||||
|
||||
def asyncFinishThread(server):
|
||||
if "address" in server and server["address"] != "":
|
||||
try:
|
||||
info = socket.getaddrinfo(server["address"], server["port"])
|
||||
except:
|
||||
app.logger.warning("Unable to get address info for %s." % (server["address"],))
|
||||
return
|
||||
addresses = set(data[4][0] for data in info)
|
||||
found = False
|
||||
for addr in addresses:
|
||||
if server["ip"] == addr:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
app.logger.warning("Invalid IP %s for address %s (address valid for %s)."
|
||||
% (server["ip"], server["address"], addresses))
|
||||
return
|
||||
else:
|
||||
server["address"] = server["ip"]
|
||||
|
||||
server["ping"] = serverUp(server["address"], server["port"])
|
||||
if not server["ping"]:
|
||||
return
|
||||
|
||||
del server["action"]
|
||||
|
||||
with listLock:
|
||||
old = getServerAndIndex(server["ip"], server["port"])
|
||||
if old:
|
||||
serverList[old[0]] = server
|
||||
else:
|
||||
serverList.append(server)
|
||||
|
||||
sortList()
|
||||
saveList()
|
||||
|
||||
|
||||
loadList()
|
||||
purgeOld()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host = app.config["HOST"], port = app.config["PORT"])
|
||||
|
||||
11
static/index.html
Normal file
11
static/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Minetest server list</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="server_list"></div>
|
||||
</body>
|
||||
</html>
|
||||
<script src="list.js"></script>
|
||||
101
static/list.js
Normal file
101
static/list.js
Normal file
@@ -0,0 +1,101 @@
|
||||
var master;
|
||||
if (!master) master = {};
|
||||
if (typeof(master.root) == 'undefined') master.root = window.location.href;
|
||||
if (!master.output) master.output = '#server_list';
|
||||
if (!master.list) master.list = "list";
|
||||
if (!master.list_root) master.list_root = master.root;
|
||||
if (!master.list_url) master.list_url = master.list_root + master.list;
|
||||
|
||||
function humanTime(seconds) {
|
||||
if (!seconds) return '?';
|
||||
var conv = {
|
||||
y: 31536000,
|
||||
d: 86400,
|
||||
h: 3600,
|
||||
m: 60
|
||||
}
|
||||
for (var i in conv) {
|
||||
if (seconds >= conv[i]) {
|
||||
return (seconds / conv[i]).toFixed(1) + i;
|
||||
}
|
||||
}
|
||||
return seconds + 's';
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
if(!str) return str;
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function addressString(server) {
|
||||
var isIPv6 = server.address.indexOf(":") != -1;
|
||||
var addrStr = (isIPv6 ? '[' : '') +
|
||||
escapeHTML(server.address) +
|
||||
(isIPv6 ? ']' : '');
|
||||
var shortStr = addrStr;
|
||||
addrStr += ':' + server.port;
|
||||
var str = '<span'
|
||||
if (shortStr.length > 25) {
|
||||
shortStr = shortStr.substr(0, 23) + "…";
|
||||
str += ' class="mts_tooltip" title="' + addrStr + '"'
|
||||
}
|
||||
if (server.port != 30000)
|
||||
shortStr += ':' + server.port;
|
||||
return str + '>' + shortStr + '</span>';
|
||||
}
|
||||
|
||||
function tooltipString(str, maxLen) {
|
||||
str = escapeHTML(str);
|
||||
var shortStr = str;
|
||||
var ret = '<span';
|
||||
if (shortStr.length > maxLen) {
|
||||
shortStr = shortStr.substr(0, maxLen - 2) + "…";
|
||||
ret += ' class="mts_tooltip" title="' + str + '"';
|
||||
}
|
||||
return ret + '>' + shortStr + '</span>';
|
||||
}
|
||||
|
||||
function hoverList(name, list) {
|
||||
if (!list || list.length == 0) return '';
|
||||
var str = '<div class="mts_hover_list">'
|
||||
str += name + '(' + list.length + ')<br />';
|
||||
for (var i in list) {
|
||||
str += escapeHTML(list[i]) + '<br />';
|
||||
}
|
||||
return str + '</div>';
|
||||
}
|
||||
|
||||
function hoverString(name, string) {
|
||||
if (!string) return '';
|
||||
return '<div class="mts_hover_list">'
|
||||
+ name + ':<br />'
|
||||
+ escapeHTML(string) + '<br />'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function draw(json) {
|
||||
html = window.render.servers(json);
|
||||
jQuery(master.output).html(html);
|
||||
}
|
||||
|
||||
function get() {
|
||||
jQuery.getJSON(master.list_url, draw);
|
||||
}
|
||||
|
||||
function loaded(){
|
||||
if (!master.no_refresh) {
|
||||
setInterval(get, 60 * 1000);
|
||||
}
|
||||
get();
|
||||
}
|
||||
|
||||
// https://github.com/pyrsmk/toast
|
||||
this.toast=function(){var e=document,t=e.getElementsByTagName("head")[0],n=this.setTimeout,r="createElement",i="appendChild",s="addEventListener",o="onreadystatechange",u="styleSheet",a=10,f=0,l=function(){--f},c,h=function(e,r,i,s){if(!t)n(function(){h(e)},a);else if(e.length){c=-1;while(i=e[++c]){if((s=typeof i)=="function"){r=function(){return i(),!0};break}if(s=="string")p(i);else if(i.pop){p(i[0]),r=i[1];break}}d(r,Array.prototype.slice.call(e,c+1))}},p=function(n,s){++f,/\.css$/.test(n)?(s=e[r]("link"),s.rel=u,s.href=n,t[i](s),v(s)):(s=e[r]("script"),s.src=n,t[i](s),s[o]===null?s[o]=m:s.onload=l)},d=function(e,t){if(!f)if(!e||e()){h(t);return}n(function(){d(e,t)},a)},v=function(e){if(e.sheet||e[u]){l();return}n(function(){v(e)},a)},m=function(){/ded|co/.test(this.readyState)&&l()};h(arguments)};
|
||||
|
||||
toast(master.root + 'style.css', master.root + 'servers.js', function() {
|
||||
if (typeof(jQuery) != 'undefined')
|
||||
return loaded();
|
||||
else
|
||||
toast('//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js', loaded);
|
||||
});
|
||||
|
||||
74
static/servers.jst
Normal file
74
static/servers.jst
Normal file
@@ -0,0 +1,74 @@
|
||||
{{? !master.no_total}}
|
||||
<div class="total">
|
||||
Players: {{=it.total.clients}}/{{=it.total_max.clients}}
|
||||
Servers: {{=it.total.servers}}/{{=it.total_max.servers}}
|
||||
</div>
|
||||
{{?}}
|
||||
<table>
|
||||
<tr>
|
||||
{{? !master.no_address}}<th>IP[:Port]</th>{{?}}
|
||||
{{? !master.no_clients}}<th>Players/Max{{? !master.no_avgtop}}<br/>Avg/Top{{?}}</th>{{?}}
|
||||
{{? !master.no_version}}<th>Version, Gameid, MapGen</th>{{?}}
|
||||
{{? !master.no_name}}<th>Name</th>{{?}}
|
||||
{{? !master.no_description}}<th>Description</th>{{?}}
|
||||
{{? !master.no_flags}}<th>Flags</th>{{?}}
|
||||
{{? !master.no_uptime}}<th>Uptime, Age</th>{{?}}
|
||||
{{? !master.no_ping}}<th>Ping, Lag</th>{{?}}
|
||||
</tr>
|
||||
{{~it.list :server:index}}
|
||||
{{ if (master.limit && index + 1 > master.limit) break;}}
|
||||
{{ if (master.min_clients && server.clients < master.min_clients) continue;}}
|
||||
<tr>
|
||||
{{? !master.no_address}}
|
||||
<td class ="address">
|
||||
{{=addressString(server)}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_clients}}
|
||||
<td class="clients{{? server.clients_list && server.clients_list.length > 0}} mts_hover_list_text{{?}}">
|
||||
{{=server.clients}}/{{=server.clients_max}}{{? !master.no_avgtop}} {{=Math.floor(server.pop_v)}}/{{=server.clients_top}}{{?}}
|
||||
{{=hoverList("Clients", server.clients_list)}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_version}}
|
||||
<td class="version{{? server.mods && server.mods.length > 0}} mts_hover_list_text{{?}}">
|
||||
{{=escapeHTML(server.version)}}, {{=escapeHTML(server.gameid)}},
|
||||
{{=escapeHTML(server.mapgen || '?')}}
|
||||
{{=hoverList("Mods", server.mods)}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_name}}
|
||||
<td class="name">
|
||||
{{? server.url}}
|
||||
<a href="{{=escapeHTML(server.url)}}">{{=tooltipString(server.name, 25)}}</a>
|
||||
{{??}}
|
||||
{{=tooltipString(server.name, 25)}}
|
||||
{{?}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_description}}
|
||||
<td class="description">
|
||||
{{=tooltipString(server.description, 50)}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_flags}}
|
||||
<td class="flags {{? server.privs}} mts_hover_list_text{{?}}">
|
||||
{{=hoverString("Privs", server.privs)}}
|
||||
{{=server.creative ? 'Cre ' : ''}}
|
||||
{{=server.dedicated ? 'Ded ' : ''}}
|
||||
{{=server.damage ? 'Dmg ' : ''}}
|
||||
{{=server.liquid_finite ? 'Liq ' : ''}}
|
||||
{{=server.pvp ? 'PvP ' : ''}}
|
||||
{{=server.password ? 'Pwd ' : ''}}
|
||||
{{=server.rollback ? 'Rol ' : ''}}
|
||||
{{=server.can_see_far_names ? 'Far ' : ''}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_uptime}}
|
||||
<td class="uptime">
|
||||
{{=humanTime(server.uptime)}}, {{=humanTime(server.game_time)}}
|
||||
</td>{{?}}
|
||||
{{? !master.no_ping}}
|
||||
<td class="ping">
|
||||
{{=Math.floor(server.ping * 1000)}}{{? server.lag}}, {{= Math.floor(server.lag * 1000)}}{{?}}
|
||||
</td>{{?}}
|
||||
</tr>
|
||||
{{~}}
|
||||
</table>
|
||||
{{? master.min_clients || master.limit}}
|
||||
<a class="clickable" onclick="delete master.min_clients; delete master.limit; get();">More...</a>
|
||||
{{?}}
|
||||
35
static/style.css
Normal file
35
static/style.css
Normal file
@@ -0,0 +1,35 @@
|
||||
#server_list table {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
#server_list td, #server_list th {
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.mts_hover_list{
|
||||
visibility: hidden;
|
||||
border: gray solid 1px;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background-color: white;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
td:hover .mts_hover_list {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mts_hover_list_text, .mts_tooltip {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dashed;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
Reference in New Issue
Block a user