properties, starting, checking pid/ps, stopping

This commit is contained in:
Patrick Haßel 2025-07-30 12:50:27 +02:00
commit 242d056aeb
46 changed files with 16197 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/worlds/

1
application.properties Normal file
View File

@ -0,0 +1 @@
server.port=8083

49
pom.xml Normal file
View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ph87</groupId>
<artifactId>McManager</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.6</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<finalName>McManager</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,18 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
# noinspection EditorConfigKeyCorrectness
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
src/main/angular/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,59 @@
# Angular
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.12.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -0,0 +1,124 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "less",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/angular",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "angular:build:production"
},
"development": {
"buildTarget": "angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
}
}
}
}
}
}

14882
src/main/angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"name": "angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build && rsync --archive --delete -e 'ssh -p 2222' ./dist/angular/browser/ mc@mc.ph87.de:/srv/McManager/www/",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0",
"@stomp/rx-stomp": "^2.0.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 1024 1024" class="icon" xmlns="http://www.w3.org/2000/svg">
<path d="M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z" fill="#2196F3"/>
<path d="M469.333333 469.333333h85.333334v234.666667h-85.333334z" fill="#FFFFFF"/>
<path d="M512 352m-53.333333 0a53.333333 53.333333 0 1 0 106.666666 0 53.333333 53.333333 0 1 0-106.666666 0Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<router-outlet />

View File

@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.less'
})
export class AppComponent {
title = 'angular';
}

View File

@ -0,0 +1,15 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';
import {RxStompService, rxStompServiceFactory} from './crud/rx-stomp.service';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes),
provideHttpClient(),
{provide: RxStompService, useFactory: rxStompServiceFactory},
]
};

View File

@ -0,0 +1,6 @@
import {Routes} from '@angular/router';
import {ServerListComponent} from './server-list/server-list.component';
export const routes: Routes = [
{path: '**', component: ServerListComponent},
];

View File

@ -0,0 +1,55 @@
import {HttpClient} from "@angular/common/http";
import {FromJson, Next, url} from "./CrudHelpers";
import {filter, map, Subscription} from "rxjs";
import {Injectable} from '@angular/core';
import {RxStompService} from './rx-stomp.service';
import {RxStompState} from '@stomp/rx-stomp';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(
readonly http: HttpClient,
readonly stomp: RxStompService,
) {
}
getNone<T>(path: any[], next?: Next<void>): void {
this.http.get<void>(url('http', path)).subscribe(next);
}
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): void {
this.http.get<any>(url('http', path)).pipe(map(fromJson)).subscribe(next);
}
getList<T>(path: any[], fromJson: FromJson<T>, next?: Next<T[]>): void {
this.http.get<any[]>(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
}
postNone<T>(path: any[], data: any, next?: Next<void>): void {
this.http.post<void>(url('http', path), data).subscribe(next);
}
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): void {
this.http.post<any>(url('http', path), data).pipe(map(fromJson)).subscribe(next);
}
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T[]>): void {
this.http.post<any[]>(url('http', path), data).pipe(map(list => list.map(fromJson))).subscribe(next);
}
onConnect(next: Next<void>): Subscription {
return this.stomp.connectionState$.pipe(filter(state => state === RxStompState.OPEN)).subscribe(() => next());
}
onDisconnect(next: Next<void>): Subscription {
return this.stomp.connectionState$.pipe(filter(state => state === RxStompState.CLOSED)).subscribe(() => next());
}
subscribe<T>(path: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
return this.stomp.watch([...path].join('/')).pipe(map(m => fromJson(JSON.parse(m.body)))).subscribe(next);
}
}

View File

@ -0,0 +1,43 @@
export type FromJson<T> = (json: any) => T;
export type Next<T> = (t: T) => any;
export function validateString(json: any) {
if (typeof json === 'string') {
return json;
}
throw new Error('Not a string: ' + JSON.stringify(json));
}
export function orNull<T, R>(t: T | null, map: (t: T) => R): R | null {
if (t === null) {
return null;
}
return map(t);
}
export function orElse<T, R>(t: T | null, map: (t: T) => R, orElse: R): R {
if (t === null) {
return orElse;
}
return map(t);
}
export function validateBoolean(json: any) {
if (typeof json === 'boolean') {
return json;
}
throw new Error('Not a boolean: ' + JSON.stringify(json));
}
export function validateNumber(json: any) {
if (typeof json === 'number') {
return json;
}
throw new Error('Not a number: ' + JSON.stringify(json));
}
export function url(protocol: string, path: any[]): string {
const secure = location.protocol === 'https:' ? 's' : '';
return `${protocol}${secure}://${location.hostname}:8083/${path.join('/')}`;
}

View File

@ -0,0 +1,38 @@
import {Injectable, OnDestroy, OnInit} from "@angular/core";
import {Subscription} from "rxjs";
import {CrudService} from "./CrudService";
@Injectable()
export abstract class CrudListComponent<TYPE, SERVICE extends CrudService<TYPE>> implements OnInit, OnDestroy {
protected readonly subs: Subscription[] = [];
protected list: TYPE[] = [];
protected constructor(
readonly crudService: SERVICE,
readonly equals: (a: TYPE, b: TYPE) => boolean,
) {
//
}
ngOnInit(): void {
this.subs.push(this.crudService.subscribe(item => this.update(item)));
this.subs.push(this.crudService.api.onConnect(() => this.crudService.findAll(list => this.list = list)));
this.subs.push(this.crudService.api.onDisconnect(() => this.list = []));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
protected update(item: TYPE) {
const index = this.list.findIndex(i => this.equals(i, item));
if (index >= 0) {
this.list.splice(index, 1, item);
} else {
this.list.push(item);
}
}
}

View File

@ -0,0 +1,48 @@
import {FromJson, Next} from './CrudHelpers';
import {ApiService} from './ApiService';
import {Subscription} from 'rxjs';
export abstract class CrudService<T> {
protected constructor(
readonly api: ApiService,
readonly path: any[],
readonly fromJson: FromJson<T>,
) {
//
}
findAll(next?: Next<T[]>): void {
this.getList(['findAll'], next);
}
protected getNone(path: any[], next?: Next<void>) {
this.api.getNone([...this.path, ...path], next);
}
protected getSingle(path: any[], next?: Next<T>) {
this.api.getSingle([...this.path, ...path], this.fromJson, next);
}
protected getList(path: any[], next?: Next<T[]>) {
this.api.getList([...this.path, ...path], this.fromJson, next);
}
protected postNone(path: any[], data: any, next?: Next<void>) {
this.api.postNone([...this.path, ...path], data, next);
}
protected postSingle(path: any[], data: any, next?: Next<T>) {
this.api.postSingle([...this.path, ...path], data, this.fromJson, next);
}
protected postList(path: any[], data: any, next?: Next<T[]>) {
this.api.postList([...this.path, ...path], data, this.fromJson, next);
}
subscribe(next: Next<T>): Subscription {
const subscription = this.api.subscribe([this.path], this.fromJson, order => next(order));
return new Subscription(() => subscription.unsubscribe());
}
}

View File

@ -0,0 +1,28 @@
import {Injectable} from '@angular/core';
import {RxStomp, RxStompConfig} from "@stomp/rx-stomp";
import {url} from './CrudHelpers';
@Injectable({
providedIn: 'root'
})
export class RxStompService extends RxStomp {
constructor() {
super();
}
}
const config: RxStompConfig = {
brokerURL: url("ws", ["ws"]),
heartbeatIncoming: 2000,
heartbeatOutgoing: 2000,
reconnectDelay: 500,
};
export function rxStompServiceFactory() {
const rxStomp = new RxStompService();
rxStomp.configure(config);
rxStomp.activate();
return rxStomp;
}

View File

@ -0,0 +1,5 @@
export enum Mode {
CREATIVE = 'CREATIVE',
SURVIVAL = 'SURVIVAL',
ADVENTURE = 'ADVENTURE',
}

View File

@ -0,0 +1,40 @@
import {Mode} from "./Mode";
import {orNull, validateBoolean, validateNumber, validateString} from "../crud/CrudHelpers";
export class Server {
constructor(
readonly directory: string,
readonly name: string,
readonly motd: string,
readonly mode: Mode,
readonly serverPort: number,
readonly rconPort: number,
readonly rconPassword: string,
readonly queryPort: number,
readonly pid: number | null,
readonly running: boolean,
readonly propertyFile: string,
readonly pidFile: string,
) {
//
}
static fromJson(json: any): Server {
return new Server(
validateString(json.directory),
validateString(json.name),
validateString(json.motd),
validateString(json.mode) as Mode,
validateNumber(json.serverPort),
validateNumber(json.rconPort),
validateString(json.rconPassword),
validateNumber(json.queryPort),
orNull(json.pid, validateNumber),
validateBoolean(json.running),
validateString(json.propertyFile),
validateString(json.pidFile),
);
}
}

View File

@ -0,0 +1,25 @@
<div class="heading">
Serverliste
</div>
<div class="hint">
<img src="info.svg" alt="(i)">
Beim Starten eines Servers werden alle anderen gestoppt.
</div>
<div class="list">
<div *ngFor="let server of list" class="server">
<div class="icon">
<img src="{{server.mode.toLowerCase()}}.png" alt="{{server.mode}}">
</div>
<div class="name">
{{ server.motd }}
</div>
<div class="command start" [class.startInactive]="!server.running" [class.startActive]="server.running" (click)="start(server)">
Start
</div>
<div class="command stop" [class.stopInactive]="server.running" [class.stopActive]="!server.running" (click)="stop(server)">
Stopp
</div>
</div>
</div>

View File

@ -0,0 +1,52 @@
.list {
.server {
display: flex;
flex-direction: row;
align-items: center;
> div {
padding: 0.25em;
}
.icon {
img {
height: 2em;
vertical-align: middle;
}
}
.name {
flex-grow: 1;
font-weight: bold;
}
.command {
margin-right: 0.25em;
border-radius: 0.25em;
}
.start {
background-color: green;
}
.startActive {
background-color: limegreen;
box-shadow: 0 0 0.3em 0.1em limegreen, 0 0 0.6em 0.2em rgba(50, 205, 50, 0.5);
color: white;
}
.stop {
background-color: indianred;
}
.stopActive {
background-color: red;
box-shadow: 0 0 0.3em 0.1em red, 0 0 0.6em 0.2em rgba(255, 0, 0, 0.5);
color: white;
}
}
}

View File

@ -0,0 +1,31 @@
import {Component} from '@angular/core';
import {NgForOf} from '@angular/common';
import {Server} from './Server';
import {CrudListComponent} from '../crud/CrudListComponent';
import {ServerService} from './server.service';
@Component({
selector: 'app-server-list',
imports: [
NgForOf
],
templateUrl: './server-list.component.html',
styleUrl: './server-list.component.less'
})
export class ServerListComponent extends CrudListComponent<Server, ServerService> {
constructor(
crudService: ServerService,
) {
super(crudService, (a, b) => a.name === b.name);
}
start(server: Server) {
this.crudService.start(server);
}
stop(server: Server) {
this.crudService.stop(server);
}
}

View File

@ -0,0 +1,26 @@
import {Injectable} from '@angular/core';
import {CrudService} from '../crud/CrudService';
import {Server} from './Server';
import {ApiService} from '../crud/ApiService';
import {Next} from '../crud/CrudHelpers';
@Injectable({
providedIn: 'root'
})
export class ServerService extends CrudService<Server> {
constructor(
api: ApiService,
) {
super(api, ['Server'], Server.fromJson);
}
start(server: Server, next?: Next<Server>) {
this.getSingle([server.name, 'start'], next);
}
stop(server: Server, next?: Next<Server>) {
this.getSingle([server.name, 'stop'], next);
}
}

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>McManager</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="survival.png">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,24 @@
body {
font-family: sans-serif;
margin: 0;
font-size: 5vw;
}
.heading {
padding: 0.25em;
font-weight: bold;
}
.hint {
background-color: lightyellow;
border: 0.1em solid yellow;
margin: 0.25em;
padding: 0.25em;
font-size: 60%;
border-radius: 0.25em;
img {
height: 1.7em;
vertical-align: middle;
}
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,13 @@
package de.ph87.mc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Backend {
public static void main(String[] args) {
SpringApplication.run(Backend.class, args);
}
}

View File

@ -0,0 +1,7 @@
package de.ph87.mc.server;
public enum Mode {
SURVIVAL,
CREATIVE,
ADVENTURE,
}

View File

@ -0,0 +1,65 @@
package de.ph87.mc.server;
import de.ph87.mc.websocket.IWebsocketMessage;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import java.io.File;
@Data
public class Server implements IWebsocketMessage {
@NonNull
public final File directory;
@NonNull
public final File propertyFile;
@NonNull
public final File pidFile;
@NonNull
public final String name;
@NonNull
public final String motd;
@NonNull
public final Mode mode;
public final int serverPort;
public final int rconPort;
@NonNull
public final String rconPassword;
public final int queryPort;
@Nullable
private Long pid;
public Server(@NonNull final File directory, @NonNull final String name, @NonNull final String motd, @NonNull final Mode mode, final int serverPort, final int rconPort, final String rconPassword, final int queryPort) {
this.directory = directory;
this.propertyFile = new File(directory, "server.properties");
this.pidFile = new File(directory, "pid");
this.name = name;
this.motd = motd;
this.mode = mode;
this.serverPort = serverPort;
this.rconPort = rconPort;
this.rconPassword = rconPassword;
this.queryPort = queryPort;
}
@Override
public String toString() {
return "Server(%s, \"%s\", %s)".formatted(mode, motd, isRunning() ? "RUNNING" : "stopped");
}
public boolean isRunning() {
return pid != null;
}
}

View File

@ -0,0 +1,14 @@
package de.ph87.mc.server;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "de.ph87.mc.server")
public class ServerConfig {
private String path = "./worlds/";
}

View File

@ -0,0 +1,38 @@
package de.ph87.mc.server;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Server")
public class ServerController {
private final ServerService serverService;
private final ServerRepository serverRepository;
@GetMapping("findAll")
public List<Server> findAll() {
return serverRepository.findAll();
}
@GetMapping("{name}/start")
public Server start(@NonNull @PathVariable final String name) {
return serverService.start(name);
}
@GetMapping("{name}/stop")
public Server stop(@NonNull @PathVariable final String name) {
return serverService.stop(name);
}
}

View File

@ -0,0 +1,128 @@
package de.ph87.mc.server;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Slf4j
@Service
@RequiredArgsConstructor
public class ServerProcessHelper {
public static final String[] CMDLINE = {"java", "-jar", "server.jar"};
public static final String CMDLINE_STR = String.join(" ", CMDLINE);
private final ApplicationEventPublisher applicationEventPublisher;
public void updatePid(@NonNull final Server server) {
server.setPid(_readAndVerifyPid(server));
if (server.getPid() == null) {
deletePidFile(server);
}
}
@Nullable
private Long _readAndVerifyPid(@NonNull final Server server) {
if (!server.pidFile.exists()) {
return null;
}
final long pid;
try (final FileInputStream stream = new FileInputStream(server.pidFile)) {
pid = Long.parseLong(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException | NumberFormatException e) {
log.error("Failed to read pid-file: file={}, error={}", server.pidFile, e.getMessage());
return null;
}
if (!validateProcFile(server, pid)) {
return null;
}
return pid;
}
private static boolean validateProcFile(@NonNull final Server server, final long pid) {
final File procFile = new File("/proc/%d/cmdline".formatted(pid));
if (!procFile.exists()) {
log.warn("Server not running: {}", server.name);
return false;
}
try (final FileInputStream stream = new FileInputStream(procFile)) {
final String cmdline = new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace((char) 0, ' ').trim();
if (!CMDLINE_STR.equals(cmdline)) {
log.error("cmdline of running Server does not match: pid={}, running={}, expected={}", pid, cmdline, CMDLINE_STR);
return false;
}
} catch (IOException | NumberFormatException e) {
log.error("Failed to read proc-file: file={}, error={}", procFile, e.getMessage());
return false;
}
return true;
}
private void deletePidFile(@NonNull final Server server) {
server.setPid(null);
if (server.pidFile.delete()) {
log.info("PID-file removed: {}", server.pidFile);
applicationEventPublisher.publishEvent(server);
}
}
public void startProcess(@NonNull final Server server) {
if (server.isRunning()) {
return;
}
log.info("Starting Server: {}", server.name);
final ProcessBuilder builder = new ProcessBuilder(CMDLINE);
builder.directory(server.directory);
try {
final Process process = builder.start();
server.setPid(process.pid());
writePid(server, process.pid());
} catch (IOException e) {
log.error("Failed to start server: error={}, name={}", e.getMessage(), server.name);
}
}
public void stopProcess(@NonNull final Server server) {
if (!server.isRunning()) {
return;
}
new Thread(() -> {
try {
log.info("Stopping Server: {}", server.name);
new ProcessBuilder("kill", "-15", server.getPid() + "").start();
while (server.getPid() != null && validateProcFile(server, server.getPid())) {
//noinspection BusyWait
Thread.sleep(1000);
}
deletePidFile(server);
} catch (IOException | InterruptedException e) {
log.error("Failed to stop server: error={}, name={}", e.getMessage(), server.name);
}
}).start();
}
private void writePid(@NonNull final Server server, final long pid) throws IOException {
final File file = server.pidFile;
try (final FileOutputStream stream = new FileOutputStream(file)) {
stream.write("%d".formatted(pid).getBytes(StandardCharsets.UTF_8));
}
log.info("PID-file written: file={} = {}", server.pidFile, pid);
applicationEventPublisher.publishEvent(server);
}
}

View File

@ -0,0 +1,70 @@
package de.ph87.mc.server;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
@Slf4j
@Service
@RequiredArgsConstructor
public class ServerRepository {
private final ApplicationEventPublisher applicationEventPublisher;
private final ServerProcessHelper serverProcessHelper;
private final ServerConfig serverConfig;
@NonNull
public List<Server> findAll() {
final File ROOT = new File(serverConfig.getPath());
return Arrays.stream(Objects.requireNonNull(ROOT.listFiles())).map(this::_tryLoadingFromDir).filter(Optional::isPresent).map(Optional::get).toList();
}
@NonNull
private Optional<Server> _tryLoadingFromDir(@NonNull final File dir) {
final File file = new File(dir, "server.properties");
if (!file.isFile()) {
log.warn("Server directory without server.properties file: {}", dir);
return Optional.empty();
}
final Properties properties = new Properties();
try (final FileReader reader = new FileReader(file)) {
properties.load(reader);
final String name = properties.getProperty("level-name");
final String motd = properties.getProperty("motd");
final Mode gamemode = Mode.valueOf(properties.getProperty("gamemode").toUpperCase(Locale.ROOT));
final int serverPort = Integer.parseInt(properties.getProperty("server-port"));
final int rconPort = Integer.parseInt(properties.getProperty("rcon.port"));
final String rconPassword = properties.getProperty("rcon.password");
final int queryPort = Integer.parseInt(properties.getProperty("query.port"));
final Server server = new Server(dir, name, motd, gamemode, serverPort, rconPort, rconPassword, queryPort);
serverProcessHelper.updatePid(server);
applicationEventPublisher.publishEvent(server);
return Optional.of(server);
} catch (IOException e) {
log.error("Failed to read server.properties: file={}, error={}", file, e.getMessage());
return Optional.empty();
}
}
@NonNull
public Server getByName(@NonNull final String name) {
return findAll().stream().filter(server -> server.name.equals(name)).findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}

View File

@ -0,0 +1,38 @@
package de.ph87.mc.server;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class ServerService {
private final ServerRepository repository;
private final ServerProcessHelper serverProcessHelper;
@NonNull
public Server start(@NonNull final String name) {
final Server server = repository.getByName(name);
if (server.isRunning()) {
return server;
}
repository.findAll().forEach(serverProcessHelper::stopProcess);
serverProcessHelper.startProcess(server);
return server;
}
@NonNull
public Server stop(@NonNull final String name) {
final Server server = repository.getByName(name);
if (!server.isRunning()) {
return server;
}
serverProcessHelper.stopProcess(server);
return server;
}
}

View File

@ -0,0 +1,13 @@
package de.ph87.mc.websocket;
public interface IWebsocketMessage {
default String getWebsocketDestination() {
String name = getClass().getSimpleName();
if (name.endsWith("Dto")) {
return name.substring(0, name.length() - 4);
}
return name;
}
}

View File

@ -0,0 +1,40 @@
package de.ph87.mc.websocket;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@CrossOrigin
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public static final String DESTINATION = "";
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler());
}
@Bean
public ThreadPoolTaskScheduler heartBeatScheduler() {
final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("wss-heartbeat-");
scheduler.setPoolSize(1);
scheduler.setRemoveOnCancelPolicy(true);
scheduler.setAwaitTerminationSeconds(5);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
return scheduler;
}
}

View File

@ -0,0 +1,22 @@
package de.ph87.mc.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketService {
private final SimpMessageSendingOperations simpMessageSendingOperations;
@EventListener
public void send(@NonNull final IWebsocketMessage message) {
simpMessageSendingOperations.convertAndSend(message.getWebsocketDestination(), message);
}
}

View File

@ -0,0 +1,4 @@
logging.level.root=WARN
logging.level.de.ph87=INFO
#-
spring.main.banner-mode=off