Compare commits

...

2 Commits

Author SHA1 Message Date
0b3c61e1a6 Webmine 2025-07-31 11:56:44 +02:00
f6406d0dec ui now showing "not connected" 2025-07-31 11:39:58 +02:00
58 changed files with 456 additions and 104 deletions

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
<circle style="fill:#D75A4A;" cx="25" cy="25" r="25"/>
<polyline style="fill:none;stroke:#FFFFFF;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" points="16,34 25,25 34,16"/>
<polyline style="fill:none;stroke:#FFFFFF;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" points="16,16 25,25 34,34"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

View File

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

View File

@ -10,13 +10,21 @@ import {RxStompState} from '@stomp/rx-stomp';
}) })
export class ApiService { export class ApiService {
private _connected: boolean = false;
get connected(): boolean {
return this._connected;
}
constructor( constructor(
readonly http: HttpClient, readonly http: HttpClient,
readonly stomp: RxStompService, readonly stomp: RxStompService,
) { ) {
this.onConnect(() => this._connected = true);
this.onDisconnect(() => this._connected = false);
} }
getNone<T>(path: any[], next?: Next<void>): void { getNone(path: any[], next?: Next<void>): void {
this.http.get<void>(url('http', path)).subscribe(next); this.http.get<void>(url('http', path)).subscribe(next);
} }
@ -28,7 +36,7 @@ export class ApiService {
this.http.get<any[]>(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next); 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 { postNone(path: any[], data: any, next?: Next<void>): void {
this.http.post<void>(url('http', path), data).subscribe(next); this.http.post<void>(url('http', path), data).subscribe(next);
} }

View File

@ -2,39 +2,61 @@ export type FromJson<T> = (json: any) => T;
export type Next<T> = (t: T) => any; 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 { export function orNull<T, R>(t: T | null, map: (t: T) => R): R | null {
if (t === null) { if (t === null || t === undefined) {
return null; return null;
} }
return map(t); return map(t);
} }
export function orElse<T, R>(t: T | null, map: (t: T) => R, orElse: R): R { export function orElse<T, R>(t: T | null | undefined, map: (t: T) => R, orElse: R): R {
if (t === null) { if (t === null || t === undefined) {
return orElse; return orElse;
} }
return map(t); return map(t);
} }
export function validateBoolean(json: any) { export function validateBoolean(json: any) {
if (typeof json === 'boolean') { if (typeof json !== 'boolean') {
return json; throw new Error(`Not a Boolean: type=${typeof json}, value=${JSON.stringify(json)}`);
} }
throw new Error('Not a boolean: ' + JSON.stringify(json)); return json;
} }
export function validateNumber(json: any) { export function validateNumber(json: any) {
if (typeof json === 'number') { if (typeof json !== 'number') {
return json; throw new Error(`Not a Number: type=${typeof json}, value=${JSON.stringify(json)}`);
} }
throw new Error('Not a number: ' + JSON.stringify(json)); return json;
}
export function validateString(json: any) {
if (typeof json !== 'string') {
throw new Error(`Not a String: type=${typeof json}, value=${JSON.stringify(json)}`);
}
return json;
}
export function validateList<T>(json: any, fromJson: FromJson<T>): T[] {
if (!Array.isArray(json)) {
throw new Error(`Not a list: type=${typeof json}, value=${JSON.stringify(json)}`);
}
return json.map(fromJson);
}
export function validateMap<K, V>(json: any, key: FromJson<K>, value: FromJson<V>): Map<K, V> {
if (typeof json !== 'object') {
throw new Error(`Not a Map: type=${typeof json}, value=${JSON.stringify(json)}`);
}
const result = new Map<K, V>();
for (const [rawKey, rawValue] of Object.entries(json)) {
const parsedKey = key(rawKey);
const parsedValue = value(rawValue);
result.set(parsedKey, parsedValue);
}
return result;
} }
export function url(protocol: string, path: any[]): string { export function url(protocol: string, path: any[]): string {

View File

@ -1,28 +0,0 @@
import {Mode} from "./Mode";
import {validateBoolean, validateNumber, validateString} from "../crud/CrudHelpers";
export class Server {
constructor(
readonly name: string,
readonly motd: string,
readonly mode: Mode,
readonly port: number,
readonly running: boolean,
readonly icon: boolean,
) {
//
}
static fromJson(json: any): Server {
return new Server(
validateString(json.name),
validateString(json.motd),
validateString(json.mode) as Mode,
validateNumber(json.port),
validateBoolean(json.running),
validateBoolean(json.icon),
);
}
}

View File

@ -1,34 +0,0 @@
<div class="heading">
<img src="minecraft.svg" alt="Minecraft">
</div>
<div class="list">
<div *ngFor="let server of servers()" class="server">
<div class="icon">
<img src="{{server.mode.toLowerCase()}}.png" alt="{{server.mode}}" *ngIf="!server.icon">
<img [src]="url('http',['Server', server.name, 'icon'])" alt="{{server.mode}}" *ngIf="server.icon">
</div>
<div class="name">
{{ server.motd }}
<div class="address">
10.255.0.1:{{ server.port }}
</div>
</div>
<div class="map" *ngIf="server.name === 'survival24'">
<a href="https://mc.ph87.de/" target="_blank">
<img src="map.svg" alt="Karte">
</a>
</div>
<div class="command start" [class.startInactive]="!server.running" [class.startActive]="server.running" (click)="start(server)">
&nbsp;An&nbsp;
</div>
<div class="command stop" [class.stopInactive]="server.running" [class.stopActive]="!server.running" (click)="stop(server)">
Aus
</div>
</div>
</div>
<div class="hint">
<img src="info.svg" alt="(i)">
Beim Starten eines Servers werden alle anderen gestoppt.
</div>

View File

@ -0,0 +1,73 @@
import {Mode} from "./Mode";
import {orElse, validateBoolean, validateList, validateNumber, validateString} from "../crud/CrudHelpers";
export enum Channel {
red = 'red',
blue = 'blue',
green = 'green',
yellow = 'yellow',
magenta = 'magenta',
cyan = 'cyan',
}
export function validateChannel(json: any): Channel {
return validateString(json) as Channel;
}
export class ChannelInstance {
constructor(
readonly channel: Channel,
readonly signal: number,
) {
//
}
static fromJson(json: any): ChannelInstance {
return new ChannelInstance(
validateChannel(json.channel),
validateNumber(json.signal),
);
}
}
export class Server {
constructor(
readonly name: string,
readonly motd: string,
readonly mode: Mode,
readonly port: number,
readonly running: boolean,
readonly webmine: boolean,
readonly icon: boolean,
readonly activators: ChannelInstance[],
readonly observers: ChannelInstance[],
) {
//
}
static fromJson(json: any): Server {
return new Server(
validateString(json.name),
validateString(json.motd),
validateString(json.mode) as Mode,
validateNumber(json.port),
validateBoolean(json.running),
validateBoolean(json.webmine),
validateBoolean(json.icon),
validateList(json['activators'], ChannelInstance.fromJson),
validateList(json['observers'], ChannelInstance.fromJson),
);
}
isActivatorPowered(channel: Channel): boolean | null {
return orElse(this.activators.filter(c => c.channel === channel).map(c => c.signal > 0)[0], v => v, null);
}
isObserverPowered(channel: Channel): boolean | null {
return orElse(this.observers.filter(c => c.channel === channel).map(c => c.signal > 0)[0], v => v, null);
}
}

View File

@ -0,0 +1,52 @@
<div class="heading">
<img src="minecraft.svg" alt="Minecraft">
</div>
<div class="list">
<div *ngFor="let server of servers()" class="server">
<div class="icon">
<img src="{{server.mode.toLowerCase()}}.png" alt="{{server.mode}}" *ngIf="!server.icon">
<img [src]="url('http',['Server', server.name, 'icon'])" alt="{{server.mode}}" *ngIf="server.icon">
</div>
<div class="name">
{{ server.motd }}
<div class="address">
10.255.0.1:{{ server.port }}
</div>
</div>
<div class="map" *ngIf="server.name === 'survival24'">
<a href="https://mc.ph87.de/" target="_blank">
<img src="map.svg" alt="Karte">
</a>
</div>
<div class="command start" [class.startInactive]="!server.running" [class.startActive]="server.running" (click)="start(server)">
&nbsp;An&nbsp;
</div>
<div class="command stop" [class.stopInactive]="server.running" [class.stopActive]="!server.running" (click)="stop(server)">
Aus
</div>
<ng-container *ngIf="server.running && server.webmine">
<div class="channelInstanceList">
<div class="channelInstance" *ngFor="let channel of channels()">
<img src="webmine/block/activator/{{channel}}_null.png" [class.hidden]="server.isActivatorPowered(channel) !== null" alt="{{channel}}" (click)="crudService.webmineActivatorToggle(server, channel)">
<img src="webmine/block/activator/{{channel}}_false.png" [class.hidden]="server.isActivatorPowered(channel) !== false" alt="{{channel}}" (click)="crudService.webmineActivatorToggle(server, channel)">
<img src="webmine/block/activator/{{channel}}_true.png" [class.hidden]="server.isActivatorPowered(channel) !== true" alt="{{channel}}" (click)="crudService.webmineActivatorToggle(server, channel)">
</div>
</div>
<div class="channelInstanceList">
<div class="channelInstance" *ngFor="let channel of channels()">
<img src="webmine/block/observer/{{channel}}_null.png" [class.hidden]="server.isObserverPowered(channel) !== null" alt="{{channel}}">
<img src="webmine/block/observer/{{channel}}_false.png" [class.hidden]="server.isObserverPowered(channel) !== false" alt="{{channel}}">
<img src="webmine/block/observer/{{channel}}_true.png" [class.hidden]="server.isObserverPowered(channel) !== true" alt="{{channel}}">
</div>
</div>
</ng-container>
</div>
</div>
<div class="message messageError" *ngIf="!crudService.api.connected">
<img src="error.svg" alt="(x)">
Nicht verbunden!
</div>

View File

@ -3,16 +3,22 @@
.server { .server {
display: flex; display: flex;
flex-wrap: wrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
border-bottom: 1px solid #464d55;
> div { > div {
padding: 0.25em; padding: 0.25em;
} }
.icon { .icon {
img {
height: 2em; height: 2em;
width: 2em;
text-align: center;
img {
height: 100%;
vertical-align: middle; vertical-align: middle;
} }
} }
@ -25,6 +31,7 @@
.map { .map {
font-weight: bold; font-weight: bold;
img { img {
height: 2em; height: 2em;
vertical-align: middle; vertical-align: middle;
@ -61,6 +68,23 @@
color: white; color: white;
} }
.channelInstanceList {
width: 100%;
display: flex;
flex-direction: row;
.channelInstance {
display: block;
width: calc(100% / 6);
padding-left: 0.25em;
padding-right: 0.25em;
img {
width: 100%;
}
}
}
} }
} }

View File

@ -1,9 +1,9 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {NgForOf, NgIf} from '@angular/common'; import {NgForOf, NgIf} from '@angular/common';
import {Server} from './Server'; import {Channel, Server} from '../Server';
import {CrudListComponent} from '../crud/CrudListComponent'; import {CrudListComponent} from '../../crud/CrudListComponent';
import {ServerService} from './server.service'; import {ServerService} from '../server.service';
import {url} from '../crud/CrudHelpers'; import {url} from '../../crud/CrudHelpers';
@Component({ @Component({
selector: 'app-server-list', selector: 'app-server-list',
@ -36,4 +36,8 @@ export class ServerListComponent extends CrudListComponent<Server, ServerService
return this.list.sort((a, b) => a.port - b.port); return this.list.sort((a, b) => a.port - b.port);
} }
channels(): Channel[] {
return Object.keys(Channel).filter(key => isNaN(Number(key))).map(key => key as Channel);
}
} }

View File

@ -1,6 +1,6 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {CrudService} from '../crud/CrudService'; import {CrudService} from '../crud/CrudService';
import {Server} from './Server'; import {Channel, Server} from './Server';
import {ApiService} from '../crud/ApiService'; import {ApiService} from '../crud/ApiService';
import {Next} from '../crud/CrudHelpers'; import {Next} from '../crud/CrudHelpers';
@ -23,4 +23,8 @@ export class ServerService extends CrudService<Server> {
this.getSingle([server.name, 'stop'], next); this.getSingle([server.name, 'stop'], next);
} }
webmineActivatorToggle(server: Server, channel: Channel) {
this.getNone(['Activator', server.name, channel, server.isActivatorPowered(channel) === true ? 0 : 15])
}
} }

View File

@ -12,9 +12,7 @@ body {
text-decoration: underline; text-decoration: underline;
} }
.hint { .message {
background-color: lightyellow;
border: 0.1em solid yellow;
margin: 0.5em; margin: 0.5em;
padding: 0.25em; padding: 0.25em;
font-size: 60%; font-size: 60%;
@ -25,3 +23,17 @@ body {
vertical-align: bottom; vertical-align: bottom;
} }
} }
.messageInfo {
background-color: lightyellow;
border: 0.1em solid yellow;
}
.messageError {
background-color: #fdaaaa;
border: 0.1em solid red;
}
.hidden {
display: none;
}

View File

@ -0,0 +1,22 @@
package de.ph87.mc.server;
import de.ph87.mc.webmine.Channel;
import lombok.Data;
import lombok.NonNull;
import java.util.Map;
@Data
public class ChannelInstanceDto {
@NonNull
public final Channel channel;
public final int signal;
public ChannelInstanceDto(@NonNull final Map.Entry<Channel, Integer> entry) {
this.channel = entry.getKey();
this.signal = entry.getValue();
}
}

View File

@ -0,0 +1,8 @@
package de.ph87.mc.server;
@FunctionalInterface
public interface EConsumer<T, E extends Exception> {
void accept(final T t) throws E;
}

View File

@ -0,0 +1,12 @@
package de.ph87.mc.server;
import de.ph87.mc.webmine.Channel;
import lombok.NonNull;
public class InvalidChannelSignal extends Exception {
public InvalidChannelSignal(@NonNull final Server server, @NonNull final Channel channel, final int signal) {
super("Invalid signal received: server=%s, channel=%s, signal=%s".formatted(server.properties.name, channel, signal));
}
}

View File

@ -31,7 +31,7 @@ public class Properties {
final java.util.Properties properties = new java.util.Properties(); final java.util.Properties properties = new java.util.Properties();
try (final FileReader reader = new FileReader(file)) { try (final FileReader reader = new FileReader(file)) {
properties.load(reader); properties.load(reader);
name = properties.getProperty("level-name"); name = directory.getName();
motd = properties.getProperty("motd"); motd = properties.getProperty("motd");
mode = Mode.valueOf(properties.getProperty("gamemode").toUpperCase(Locale.ROOT)); mode = Mode.valueOf(properties.getProperty("gamemode").toUpperCase(Locale.ROOT));
port = Integer.parseInt(properties.getProperty("server-port")); port = Integer.parseInt(properties.getProperty("server-port"));

View File

@ -1,11 +1,17 @@
package de.ph87.mc.server; package de.ph87.mc.server;
import de.ph87.mc.webmine.Channel;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.Data; import lombok.Data;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Map;
@Data @Data
@Slf4j @Slf4j
@ -30,6 +36,12 @@ public class Server {
public boolean shutdown = false; public boolean shutdown = false;
public boolean webmine = false;
private final Map<Channel, Integer> activators = new HashMap<>();
private final Map<Channel, Integer> observers = new HashMap<>();
public Server(@NonNull final File directory) throws NoMinecraftServer { public Server(@NonNull final File directory) throws NoMinecraftServer {
this.directory = directory; this.directory = directory;
this.pidFile = new File(directory, "pid"); this.pidFile = new File(directory, "pid");
@ -37,19 +49,47 @@ public class Server {
this.properties = new Properties(directory); this.properties = new Properties(directory);
} }
public boolean isRunning() {
synchronized (lock) {
return process != null && process.isAlive();
}
}
@Override @Override
public String toString() { public String toString() {
return "Server(%s, \"%s\", %s)".formatted(properties.mode, properties.motd, isRunning() ? "RUNNING" : "stopped"); return "Server(%s, \"%s\", %s)".formatted(properties.mode, properties.motd, process != null ? "RUNNING" : "stopped");
} }
public boolean eq(@NonNull final Server other) { public boolean eq(@NonNull final Server other) {
return properties.name.equals(other.properties.name); return properties.name.equals(other.properties.name);
} }
public void setActivator(final Channel channel, final int signal) throws InvalidChannelSignal {
if (signal < 0 || signal > 15) {
throw new InvalidChannelSignal(this, channel, signal);
}
synchronized (activators) {
activators.put(channel, signal);
publish(channel, signal);
log.info("Activator changed: name={}, channel={}, signal={}", properties.name, channel, signal);
}
}
public void setObserver(final Channel channel, final int signal) throws InvalidChannelSignal {
if (signal < 0 || signal > 15) {
throw new InvalidChannelSignal(this, channel, signal);
}
synchronized (observers) {
observers.put(channel, signal);
log.info("Observer changed: name={}: {}={}", properties.name, channel, signal);
}
}
private void publish(@NonNull final Channel channel, final int signal) {
new Thread(() -> {
try {
final URLConnection con = URI.create("http://localhost:8123/set?channel=%s&signal=%d".formatted(channel, signal)).toURL().openConnection();
con.setConnectTimeout(500);
con.setReadTimeout(500);
con.connect();
} catch (IOException e) {
log.error("Failed to publish Activator to Server: name={}, channel={}, signal={}, error={}", properties.name, channel, signal, e.getMessage());
}
}).start();
}
} }

View File

@ -4,28 +4,44 @@ import de.ph87.mc.websocket.IWebsocketMessage;
import lombok.Data; import lombok.Data;
import lombok.NonNull; import lombok.NonNull;
import java.util.List;
@Data @Data
public class ServerDto implements IWebsocketMessage { public class ServerDto implements IWebsocketMessage {
@NonNull
public final String name; public final String name;
@NonNull
public final String motd; public final String motd;
@NonNull
public final Mode mode; public final Mode mode;
public final int port; public final int port;
public final boolean running; public final boolean running;
public final boolean webmine;
public boolean icon; public boolean icon;
public ServerDto(final @NonNull Server server) { @NonNull
public final List<ChannelInstanceDto> activators;
@NonNull
public final List<ChannelInstanceDto> observers;
public ServerDto(@NonNull final Server server) {
this.name = server.properties.name; this.name = server.properties.name;
this.motd = server.properties.motd; this.motd = server.properties.motd;
this.mode = server.properties.mode; this.mode = server.properties.mode;
this.port = server.properties.port; this.port = server.properties.port;
this.running = server.isRunning(); this.running = server.process != null;
this.webmine = server.webmine;
this.icon = server.iconFile.isFile(); this.icon = server.iconFile.isFile();
this.activators = server.getActivators().entrySet().stream().map(ChannelInstanceDto::new).toList();
this.observers = server.getObservers().entrySet().stream().map(ChannelInstanceDto::new).toList();
} }
} }

View File

@ -1,10 +1,13 @@
package de.ph87.mc.server; package de.ph87.mc.server;
import de.ph87.mc.webmine.Channel;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
@ -14,10 +17,12 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
@Slf4j @Slf4j
@Service @Service
@EnableScheduling
@RequiredArgsConstructor @RequiredArgsConstructor
public class ServerService { public class ServerService {
@ -37,6 +42,21 @@ public class ServerService {
} }
} }
@Scheduled(fixedDelay = 2, timeUnit = TimeUnit.SECONDS)
public void schedule() {
synchronized (serversLock) {
servers.forEach(this::refresh);
}
}
private void refresh(@NonNull final Server server) {
if (server.process != null && !server.process.isAlive()) {
log.error("Server crash detected: name={}", server.properties.name);
server.process = null;
publish(server);
}
}
@NonNull @NonNull
private Optional<Server> _tryLoadingFromDir(@NonNull final File directory) { private Optional<Server> _tryLoadingFromDir(@NonNull final File directory) {
try { try {
@ -61,13 +81,14 @@ public class ServerService {
private void start(@NonNull final Server server) { private void start(@NonNull final Server server) {
synchronized (server.lock) { synchronized (server.lock) {
if (server.isRunning()) { if (server.process != null) {
log.warn("Server is already running: name={}", server.properties.name); log.warn("Server is already running: name={}", server.properties.name);
return; return;
} }
stopAll(); stopAll();
waitForAllToBeStopped();
log.info("Starting server: name={}", server.properties.name); log.info("Starting server: name={}", server.properties.name);
final ProcessBuilder builder = new ProcessBuilder("java", "-jar", "server.jar"); final ProcessBuilder builder = new ProcessBuilder("java", "-Xms2G", "-Xmx2G", "-jar", "server.jar", "nogui");
builder.directory(server.directory); builder.directory(server.directory);
try { try {
server.process = builder.start(); server.process = builder.start();
@ -78,9 +99,21 @@ public class ServerService {
} }
} }
private void waitForAllToBeStopped() {
synchronized (serversLock) {
try {
while (servers.stream().anyMatch(server -> server.process != null)) {
serversLock.wait(1000);
}
} catch (InterruptedException e) {
log.error("Interrupted while waiting for ALL servers to stop");
}
}
}
private void stop(@NonNull final Server server) { private void stop(@NonNull final Server server) {
synchronized (server.lock) { synchronized (server.lock) {
if (!server.isRunning()) { if (server.process == null) {
log.warn("Server is not running: name={}", server.properties.name); log.warn("Server is not running: name={}", server.properties.name);
return; return;
} }
@ -121,6 +154,14 @@ public class ServerService {
return publish(server); return publish(server);
} }
@NonNull
@SuppressWarnings("UnusedReturnValue")
private <E extends Exception> ServerDto setE(final @NonNull String name, @NonNull final EConsumer<Server, E> modifier) throws E {
final Server server = getByName(name);
modifier.accept(server);
return publish(server);
}
@NonNull @NonNull
private ServerDto publish(@NonNull final Server server) { private ServerDto publish(@NonNull final Server server) {
final ServerDto dto = new ServerDto(server); final ServerDto dto = new ServerDto(server);
@ -131,7 +172,7 @@ public class ServerService {
@NonNull @NonNull
private Server getByName(@NonNull final String name) { private Server getByName(@NonNull final String name) {
synchronized (serversLock) { synchronized (serversLock) {
return servers.stream().filter(server -> server.properties.name.equals(name)).findFirst().orElseThrow(); return servers.stream().filter(server -> server.properties.name.equals(name)).peek(this::refresh).findFirst().orElseThrow();
} }
} }
@ -156,4 +197,12 @@ public class ServerService {
} }
} }
public void setActivator(final @NonNull String name, final Channel channel, final int signal) throws InvalidChannelSignal {
setE(name, server -> server.setActivator(channel, signal));
}
public void setObserver(final @NonNull String name, final Channel channel, final int signal) throws InvalidChannelSignal {
setE(name, server -> server.setObserver(channel, signal));
}
} }

View File

@ -0,0 +1,5 @@
package de.ph87.mc.webmine;
public enum Channel {
red, blue, green, yellow, magenta, cyan,
}

View File

@ -0,0 +1,57 @@
package de.ph87.mc.webmine;
import de.ph87.mc.server.InvalidChannelSignal;
import de.ph87.mc.server.ServerService;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
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 org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
@Slf4j
@CrossOrigin
@RestController
@RequestMapping("Server")
public class WebmineController {
private final ServerService serverService;
public WebmineController(final ServerService serverService) {
this.serverService = serverService;
}
@GetMapping("Activator/{serverName}/{channelName}/{signal}")
public void activator(@NonNull @PathVariable final String serverName, @NonNull @PathVariable String channelName, @PathVariable int signal) {
final Channel channel = Arrays.stream(Channel.values()).filter(c -> c.name().equals(channelName)).findFirst().orElseThrow(() -> {
final String message = "No such channel: %s".formatted(channelName);
log.error(message);
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
});
try {
serverService.setActivator(serverName, channel, signal);
} catch (InvalidChannelSignal e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
@GetMapping("Observer/{serverName}/{channelName}/{signal}")
public void observer(@NonNull @PathVariable final String serverName, @NonNull @PathVariable String channelName, @PathVariable int signal) {
final Channel channel = Arrays.stream(Channel.values()).filter(c -> c.name().equals(channelName)).findFirst().orElseThrow(() -> {
final String message = "No such channel: %s".formatted(channelName);
log.error(message);
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
});
try {
serverService.setObserver(serverName, channel, signal);
} catch (InvalidChannelSignal e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
}