commit all the files
This commit is contained in:
parent
986a7af5fd
commit
2b21f69005
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@ -0,0 +1,33 @@
|
||||
FROM debian:bullseye-slim
|
||||
WORKDIR /opt/program
|
||||
# Set some environment variables. PYTHONUNBUFFERED keeps Python from buffering our standard
|
||||
# output stream, which means that logs can be delivered to the user quickly. PYTHONDONTWRITEBYTECODE
|
||||
# keeps Python from writing the .pyc files which are unnecessary in this case. We also update
|
||||
# PATH so that the serve program is found when the container is invoked.
|
||||
ENV PYTHONUNBUFFERED=TRUE
|
||||
ENV PYTHONDONTWRITEBYTECODE=TRUE
|
||||
ENV PATH="/opt/program:${PATH}"
|
||||
|
||||
# Get basic dependencies
|
||||
RUN apt-get -y update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
nginx \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# overwrite the link to system python and upgrade pip
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& pip3 install --upgrade pip
|
||||
|
||||
COPY requirements.txt /tmp/
|
||||
RUN pip3 install -r /tmp/requirements.txt && \
|
||||
rm -rf /root/.cache
|
||||
|
||||
|
||||
# Set up the program in the image
|
||||
COPY ./ /opt/program
|
||||
RUN chmod +x /opt/program/serve.py
|
||||
EXPOSE 9992
|
||||
CMD ./serve.py
|
19
README.md
19
README.md
@ -1,3 +1,20 @@
|
||||
# mc-status-page
|
||||
|
||||
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus.
|
||||
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus.
|
||||
|
||||
## Usage
|
||||
|
||||
Create `.env` with these keys:
|
||||
```
|
||||
DB_HOST=
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
DB_PROJ=
|
||||
DB_TABLE=
|
||||
MINE_IP=
|
||||
```
|
||||
|
||||
where `MINE_IP` is the IP Address of your Minecraft server (including port), and the rest correspond to your PostgreSQL database.
|
||||
|
||||
Then `docker-compose up -d` will run the site on port 9992.
|
||||
|
95
app.py
Normal file
95
app.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Flask app to display a table with the result of a postgreSQL query"""
|
||||
|
||||
from flask import Flask, render_template
|
||||
import pandas as pd
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import psycopg2
|
||||
import logging
|
||||
import sys
|
||||
import datetime
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
def connect_to_database(args):
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=args.dbhost,
|
||||
database=args.dbname,
|
||||
user=args.dbuser,
|
||||
password=args.dbpass,
|
||||
sslmode='require',
|
||||
options=f'project={args.project}'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
except Exception as e:
|
||||
logging.error('Failed to connect to database: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
table_name = vars(args).get('table', 'minecraft')
|
||||
# if args.clean doesn't exist, it will be False
|
||||
if vars(args).get('clean', False):
|
||||
# remove table if it exists
|
||||
try:
|
||||
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logging.error('Failed to drop table: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
# Create table if it doesn't exist
|
||||
try:
|
||||
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logging.error('Failed to create table: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# make a fake args object that acts the same way as argparse.Namespace
|
||||
class Args:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
args = Args(
|
||||
dbhost=os.environ.get("DB_HOST", "localhost"),
|
||||
dbname=os.environ.get("DB_NAME", "minecraft"),
|
||||
dbuser=os.environ.get("DB_USER", "postgres"),
|
||||
dbpass=os.environ.get("DB_PASS", ""),
|
||||
project=os.environ.get("DB_PROJ", "default"),
|
||||
table=os.environ.get("DB_TABLE", "minecraft"),
|
||||
)
|
||||
|
||||
conn = connect_to_database(args)
|
||||
table_name = vars(args).get("table", "minecraft")
|
||||
df = pd.read_sql(
|
||||
f"SELECT * FROM {table_name} ORDER BY timestamp DESC LIMIT 100;", conn
|
||||
)
|
||||
online = df["players"].tolist()[0]
|
||||
df = df[['timestamp', 'players', 'latency']]
|
||||
# format timestamp
|
||||
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
# if the timestamp is more than 5 minutes old, the server is offline
|
||||
latest_timestamp = datetime.datetime.strptime(df['timestamp'].tolist()[0], '%Y-%m-%d %H:%M:%S')
|
||||
if (datetime.datetime.now() - latest_timestamp) > datetime.timedelta(minutes=5):
|
||||
status = "The server is currently offline. Please try again later."
|
||||
else:
|
||||
if online > 0:
|
||||
status = f"There are currently <b>{ online }</b> players online."
|
||||
else:
|
||||
status = "There are currently no players online."
|
||||
return render_template("index.html", data=df.to_html(justify="center"), status=status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host="0.0.0.0", port=9993)
|
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
||||
# docker-compose file for serving nginx without SSL
|
||||
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
build: .
|
||||
container_name: mcstatus-server
|
||||
restart: always
|
||||
ports:
|
||||
- 9992:9992
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./html:/usr/share/nginx/html
|
||||
- ./logs:/var/log/nginx
|
||||
environment:
|
||||
- TZ=America/Denver
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
status:
|
||||
build: .
|
||||
container_name: mcstatus
|
||||
restart: always
|
||||
environment:
|
||||
- TZ=America/Denver
|
||||
env_file:
|
||||
- ../.env
|
||||
command: python mc.py
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: mcstatus
|
137
mc.py
Normal file
137
mc.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""
|
||||
Application to ping Minecraft server and write all data to postgresql database.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import datetime
|
||||
import logging
|
||||
import argparse
|
||||
|
||||
from mcstatus import JavaServer as MinecraftServer
|
||||
import psycopg2
|
||||
from typing import Tuple, Dict
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# read environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
|
||||
|
||||
# get DB_HOST, DB_PROJ, DB_USER, DB_PASS, and DB_NAME from environment variables
|
||||
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||||
DB_NAME = os.environ.get('DB_NAME', 'main')
|
||||
DB_TABLE = os.environ.get('DB_TABLE', 'minecraft')
|
||||
DB_PROJ = os.environ.get('DB_PROJ', 'default')
|
||||
DB_USER = os.environ.get('DB_USER', 'user')
|
||||
DB_PASS = os.environ.get('DB_PASS', 'password')
|
||||
IP_ADD = os.environ.get('MINE_IP', 'localhost')
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
# Set up command line arguments
|
||||
parser = argparse.ArgumentParser(description='Monitor Minecraft server activity and write it to postgresql database.')
|
||||
parser.add_argument('--dbhost', default=DB_HOST, help='Postgresql database host')
|
||||
parser.add_argument('--dbname', default=DB_NAME, help='Postgresql database name')
|
||||
parser.add_argument('--dbuser', default=DB_USER, help='Postgresql database user')
|
||||
parser.add_argument('--dbpass', default=DB_PASS, help='Postgresql database password')
|
||||
parser.add_argument('--project', default=DB_PROJ, help='Project name')
|
||||
parser.add_argument('--table', default=DB_TABLE, help='Table name')
|
||||
parser.add_argument('--ip', default=IP_ADD, help='IP Address of Minecraft Server')
|
||||
parser.add_argument('--interval', default=60, type=int, help='Interval in seconds between measurements')
|
||||
parser.add_argument('--clean', action='store_true', help='Clean database before starting (default: False)')
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
# Connect to database
|
||||
def connect_to_database(args):
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=args.dbhost,
|
||||
database=args.dbname,
|
||||
user=args.dbuser,
|
||||
password=args.dbpass,
|
||||
sslmode='require',
|
||||
options=f'project={args.project}'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
except Exception as e:
|
||||
logging.error('Failed to connect to database: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
table_name = vars(args).get('table', DB_TABLE)
|
||||
# if args.clean doesn't exist, it will be False
|
||||
if vars(args).get('clean', False):
|
||||
# remove table if it exists
|
||||
try:
|
||||
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logging.error('Failed to drop table: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
# Create table if it doesn't exist
|
||||
try:
|
||||
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logging.error('Failed to create table: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def get_minecraft_server_status(host: str) -> Dict[str, str]:
|
||||
# Get Minecraft server status
|
||||
try:
|
||||
server = MinecraftServer.lookup(host)
|
||||
status = server.status()
|
||||
logging.info('Successfully got Minecraft server status')
|
||||
except Exception as e:
|
||||
logging.error('Failed to get Minecraft server status: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
# extract status as dictionary
|
||||
status = status.__dict__
|
||||
return status
|
||||
|
||||
|
||||
def main(args, conn=None):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
# Get current time
|
||||
timestamp = datetime.datetime.now()
|
||||
|
||||
# Get Minecraft server status
|
||||
ip_add = vars(args).get('ip', 'localhost:25571')
|
||||
status = get_minecraft_server_status(ip_add)
|
||||
table_name = vars(args).get('table', DB_TABLE)
|
||||
if status:
|
||||
# Write to postgres the status
|
||||
cur.execute(f'INSERT INTO {table_name} (timestamp, server_name, version, protocol, players, max_players, latency) VALUES (%s, %s, %s, %s, %s, %s, %s)', (timestamp, status['raw']['description'], status['raw']['version']['name'], status['raw']['version']['protocol'], status['raw']['players']['online'], status['raw']['players']['max'], status['latency']))
|
||||
conn.commit()
|
||||
# Sleep for interval
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
logging.info('Exiting...')
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logging.error('Failed to write to database: %s', e)
|
||||
# sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Main loop
|
||||
args = parse_args()
|
||||
conn = connect_to_database(args)
|
||||
while True:
|
||||
# if connection to server is lost, reconnect
|
||||
if not conn:
|
||||
logging.info('Reconnecting to database...')
|
||||
conn = connect_to_database(args)
|
||||
|
||||
main(args, conn=conn)
|
73
nginx.conf
Normal file
73
nginx.conf
Normal file
@ -0,0 +1,73 @@
|
||||
worker_processes 1;
|
||||
daemon off; # Prevent forking
|
||||
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
events {
|
||||
# defaults
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /var/log/nginx/access.log combined;
|
||||
|
||||
upstream gunicorn {
|
||||
server unix:/tmp/gunicorn.sock;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9992 deferred;
|
||||
client_max_body_size 5m;
|
||||
|
||||
keepalive_timeout 5;
|
||||
proxy_read_timeout 1200s;
|
||||
|
||||
location ~ ^/ {
|
||||
#
|
||||
# Wide-open CORS config for nginx
|
||||
#
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
#
|
||||
# Om nom nom cookies
|
||||
#
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
#
|
||||
# Custom headers and headers various browsers *should* be OK with but aren't
|
||||
#
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||
#
|
||||
# Tell client that this pre-flight info is valid for 20 days
|
||||
#
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
if ($request_method = 'POST') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||
}
|
||||
if ($request_method = 'GET') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||
}
|
||||
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://gunicorn;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404 "{}";
|
||||
}
|
||||
}
|
||||
}
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
gunicorn
|
||||
gevent
|
||||
flask
|
||||
mcstatus
|
||||
psycopg2-binary
|
||||
pandas
|
||||
python-dotenv
|
96
serve.py
Executable file
96
serve.py
Executable file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# This file implements the API shell.
|
||||
# You don't necessarily need to modify it for various algorithms.
|
||||
# It starts nginx and gunicorn with the correct configurations and
|
||||
# then waits until gunicorn exits.
|
||||
#
|
||||
# The flask server is specified to be the app object in wsgi.py
|
||||
#
|
||||
# We set the following parameters:
|
||||
#
|
||||
# Parameter Environment Variable Default Value
|
||||
# --------- -------------------- -------------
|
||||
# number of workers MODEL_SERVER_WORKERS the number of CPU cores
|
||||
# timeout MODEL_SERVER_TIMEOUT 60 seconds
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
cpu_count = multiprocessing.cpu_count()
|
||||
|
||||
model_server_timeout = os.environ.get("MODEL_SERVER_TIMEOUT", 60)
|
||||
model_server_workers = int(os.environ.get("MODEL_SERVER_WORKERS", cpu_count))
|
||||
|
||||
|
||||
def sigterm_handler(nginx_pid, gunicorn_pid):
|
||||
try:
|
||||
os.kill(nginx_pid, signal.SIGQUIT)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.kill(gunicorn_pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def start_server():
|
||||
print(f"Starting server with {model_server_workers} workers.")
|
||||
|
||||
# link the log streams to stdout/err so that
|
||||
# they will be logged to the container logs
|
||||
subprocess.check_call(["ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"])
|
||||
subprocess.check_call(["ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"])
|
||||
|
||||
nginx = subprocess.Popen(["nginx", "-c", "/opt/program/nginx.conf"])
|
||||
gunicorn = subprocess.Popen(
|
||||
[
|
||||
"gunicorn",
|
||||
"--timeout",
|
||||
str(model_server_timeout),
|
||||
"-k",
|
||||
"gevent",
|
||||
"-b",
|
||||
"unix:/tmp/gunicorn.sock",
|
||||
"-w",
|
||||
str(model_server_workers),
|
||||
"wsgi:app",
|
||||
]
|
||||
)
|
||||
|
||||
# NOTE: https://docs.python.org/3/library/signal.html#signal.signal
|
||||
# This sets a handler (second argument) for the signal SIGTERM (first argument)
|
||||
# in the function `signal.signal` which we want to call (unclear why...)
|
||||
|
||||
# The handler should be a callable which takes two arguments (hence `a`, `b`),
|
||||
# and it is called with two arguments: the signal number and current stack frame.
|
||||
# Neither of these are relevant to us, so we define a dummy handler which kills
|
||||
# the two processes we have started (nginx and gunicorn) regardless of `a`/`b`.
|
||||
|
||||
# Basically, we are hard-coding "kill these two processes if you get a SIGTERM"
|
||||
def handler(a, b, c):
|
||||
return sigterm_handler(nginx.pid, gunicorn.pid)
|
||||
|
||||
signal.signal(signal.SIGTERM, handler)
|
||||
|
||||
# If either subprocess exits, so do we.
|
||||
pids = set([nginx.pid, gunicorn.pid])
|
||||
while True:
|
||||
pid, _ = os.wait()
|
||||
if pid in pids:
|
||||
break
|
||||
|
||||
sigterm_handler(nginx.pid, gunicorn.pid)
|
||||
print("API server exiting")
|
||||
|
||||
|
||||
# The main routine just invokes the start function.
|
||||
if __name__ == "__main__":
|
||||
start_server()
|
36
templates/index.html
Normal file
36
templates/index.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!-- Flask template for displaying the results of a SQL query as a basic website -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<title>Minecraft Server Status</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<h1>Minecraft Server Status</h1>
|
||||
<div align="center">
|
||||
<p style="font-size: 150%;">
|
||||
{{ status|safe }}
|
||||
</p>
|
||||
</div>
|
||||
<br>
|
||||
<!-- inline style to center table elements and create spacing in each column-->
|
||||
<style>
|
||||
table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
font-size: 125%;
|
||||
}
|
||||
</style>
|
||||
<div align="center">
|
||||
{{ data|safe }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user