This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
||||
build:
|
||||
runs-on: site-build
|
||||
env:
|
||||
APP_HOME: /home/zaine/master-folder/projects/java_projects/org_backend
|
||||
APP_HOME: /home/zaine/master-folder/org-platform/org_backend
|
||||
SERVICE_NAME: org-backend.service
|
||||
SUDO_PASSWORD: ${{ secrets.SUDO_PASSWORD }}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ service, and restarts it.
|
||||
The service runs the stable build artifact at:
|
||||
|
||||
```text
|
||||
/home/zaine/master-folder/projects/java_projects/org_backend/target/org-backend.jar
|
||||
/home/zaine/master-folder/org-platform/org_backend/target/org-backend.jar
|
||||
```
|
||||
|
||||
Runtime configuration is loaded by systemd from:
|
||||
|
||||
23
backups/systemd-20260604-121946/org-backend.service.permanent-copy
Executable file
23
backups/systemd-20260604-121946/org-backend.service.permanent-copy
Executable file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Org Backend Spring Boot App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=zaine
|
||||
Group=zaine
|
||||
Environment=SPRING_PROFILES_ACTIVE=prod
|
||||
EnvironmentFile=/etc/org-backend/org-backend.env
|
||||
|
||||
WorkingDirectory=/home/zaine/master-folder/projects/java_projects/org_backend
|
||||
|
||||
ExecStart=/usr/bin/java -jar /home/zaine/master-folder/projects/java_projects/org_backend/target/org-backend.jar
|
||||
|
||||
SuccessExitStatus=143
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Optional: limit memory
|
||||
# Environment="JAVA_OPTS=-Xms256m -Xmx1g"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
1
backups/systemd-20260604-121946/org-backend.service.symlink-target
Executable file
1
backups/systemd-20260604-121946/org-backend.service.symlink-target
Executable file
@@ -0,0 +1 @@
|
||||
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||
1
backups/systemd-20260604-121946/org-backend.service.wants-symlink-target
Executable file
1
backups/systemd-20260604-121946/org-backend.service.wants-symlink-target
Executable file
@@ -0,0 +1 @@
|
||||
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||
6
backups/systemd-20260604-121946/summary.txt
Executable file
6
backups/systemd-20260604-121946/summary.txt
Executable file
@@ -0,0 +1,6 @@
|
||||
timestamp=20260604-121946
|
||||
unit=/etc/systemd/system/org-backend.service
|
||||
unit_target=/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||
wants=/etc/systemd/system/multi-user.target.wants/org-backend.service
|
||||
wants_target=/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||
permanent=/home/zaine/master-folder/projects/java_projects/org_backend/misc/org-backend.service
|
||||
31
misc/org-backend.env.example
Executable file
31
misc/org-backend.env.example
Executable file
@@ -0,0 +1,31 @@
|
||||
# Copy to /etc/org-backend/org-backend.env (systemd EnvironmentFile)
|
||||
# Paths must use org-platform/ after Phase 1 reorg — not org_files/
|
||||
|
||||
SERVER_PORT=9010
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SPRING_DATASOURCE_URL=jdbc:postgresql://127.0.0.1:5432/org_web
|
||||
SPRING_DATASOURCE_USERNAME=zaine
|
||||
SPRING_DATASOURCE_PASSWORD=change-me
|
||||
SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.postgresql.Driver
|
||||
SPRING_JPA_SHOW_SQL=false
|
||||
|
||||
CALIBRE_DB_PATH=/home/zaine/master-folder/projects/calibre/library/metadata.db
|
||||
JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
|
||||
JWT_EXPIRATION_MS=86400000
|
||||
|
||||
ZONE_BUILD_DIR=/home/zaine/master-folder/org-platform/org_web
|
||||
ZONE_BUILD_LOG=/home/zaine/master-folder/org-platform/org_web/org-web-build.log
|
||||
ZONE_MANIFEST_PATH=/home/zaine/master-folder/org-platform/zone/data/manifest.json
|
||||
ZONE_AUTHORING_URL=http://127.0.0.1:8765
|
||||
ZONE_WATCHER_UNIT=watcher.service
|
||||
ZONE_SCRIPTS_LOG_DIR=/home/zaine/logs
|
||||
|
||||
EMACS_RUN_DIR=/home/zaine
|
||||
EMACS_RUN_LOG=/home/zaine/logs/emacs.log
|
||||
COMBINED_RUN_LOG=/home/zaine/logs/combined.log
|
||||
|
||||
PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves
|
||||
|
||||
# Leave unset on private zone — timesheet API read/write without credentials
|
||||
# ORG_BACKEND_API_KEY=
|
||||
TIMESHEET_AUTH_REQUIRED=false
|
||||
@@ -8,9 +8,9 @@ Group=zaine
|
||||
Environment=SPRING_PROFILES_ACTIVE=prod
|
||||
EnvironmentFile=/etc/org-backend/org-backend.env
|
||||
|
||||
WorkingDirectory=/home/zaine/master-folder/projects/java_projects/org_backend
|
||||
WorkingDirectory=/home/zaine/master-folder/org-platform/org_backend
|
||||
|
||||
ExecStart=/usr/bin/java -jar /home/zaine/master-folder/projects/java_projects/org_backend/target/org-backend.jar
|
||||
ExecStart=/usr/bin/java -jar /home/zaine/master-folder/org-platform/org_backend/target/org-backend.jar
|
||||
|
||||
SuccessExitStatus=143
|
||||
Restart=always
|
||||
|
||||
6
pom.xml
6
pom.xml
@@ -44,6 +44,12 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Schema migrations (runs at startup) -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_HOME="${APP_HOME:-/home/zaine/master-folder/projects/java_projects/org_backend}"
|
||||
APP_HOME="${APP_HOME:-/home/zaine/master-folder/org-platform/org_backend}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-org-backend.service}"
|
||||
SYSTEMCTL_TIMEOUT="${SYSTEMCTL_TIMEOUT:-120}"
|
||||
ENV_FILE="${ENV_FILE:-/etc/org-backend/org-backend.env}"
|
||||
|
||||
14
src/main/java/org/zaine/app/config/RestClientConfig.java
Executable file
14
src/main/java/org/zaine/app/config/RestClientConfig.java
Executable file
@@ -0,0 +1,14 @@
|
||||
package org.zaine.app.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RestClientConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.zaine.app.service.BuildRunStateService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -58,28 +59,15 @@ public class BuildController {
|
||||
Map.entry("37", "ansi-white")
|
||||
);
|
||||
|
||||
/* =========================================================
|
||||
BUILD STATE
|
||||
========================================================= */
|
||||
|
||||
private final AtomicBoolean webBuildRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean roamBuildRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean emacsRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean combinedRunning = new AtomicBoolean(false);
|
||||
private final BuildRunStateService buildRunState;
|
||||
|
||||
private final AtomicReference<Process> webProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> roamProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> emacsProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> combinedProcess = new AtomicReference<>();
|
||||
|
||||
private volatile Instant emacsLastRun;
|
||||
private volatile Integer emacsLastExitCode;
|
||||
private volatile Instant webLastRun;
|
||||
private volatile Integer webLastExitCode;
|
||||
private volatile Instant roamLastRun;
|
||||
private volatile Integer roamLastExitCode;
|
||||
private volatile Instant combinedLastRun;
|
||||
private volatile Integer combinedLastExitCode;
|
||||
@Autowired
|
||||
public BuildController(BuildRunStateService buildRunState) {
|
||||
this.buildRunState = buildRunState;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
CONFIG
|
||||
@@ -87,8 +75,6 @@ public class BuildController {
|
||||
|
||||
@Value("${zone.build.dir}") private String webBuildDirectory;
|
||||
@Value("${zone.build.log}") private String webBuildLogFile;
|
||||
@Value("${orgroam.build.dir}") private String roamBuildDirectory;
|
||||
@Value("${orgroam.build.log}") private String roamBuildLogFile;
|
||||
@Value("${emacs.run.dir}") private String emacsRunDirectory;
|
||||
@Value("${emacs.run.log}") private String emacsRunLogFile;
|
||||
@Value("${combined.run.log}") private String combinedRunLogFile;
|
||||
@@ -143,7 +129,7 @@ public class BuildController {
|
||||
})
|
||||
@PostMapping("/build-web")
|
||||
public ResponseEntity<String> triggerWebBuild() {
|
||||
return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true);
|
||||
return startBuild(webBuildDirectory, webBuildLogFile, true, webProcess);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -156,7 +142,7 @@ public class BuildController {
|
||||
})
|
||||
@DeleteMapping("/build-web")
|
||||
public ResponseEntity<String> cancelWebBuild() {
|
||||
return killProcess(webProcess, webBuildRunning, "web build");
|
||||
return killProcess(webProcess, true, "web build");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -169,7 +155,8 @@ public class BuildController {
|
||||
})
|
||||
@GetMapping("/build-web/status")
|
||||
public ResponseEntity<BuildStatus> getWebBuildStatus() {
|
||||
return ResponseEntity.ok(new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode));
|
||||
var s = buildRunState.webStatus();
|
||||
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -185,62 +172,6 @@ public class BuildController {
|
||||
return streamLogs(webBuildLogFile);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
ORG ROAM BUILD
|
||||
========================================================= */
|
||||
|
||||
@Operation(
|
||||
summary = "Trigger org-roam build",
|
||||
description = "Triggers a build of the org-roam application"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Build triggered successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@PostMapping("/build-roam")
|
||||
public ResponseEntity<String> triggerRoamBuild() {
|
||||
return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Cancel org-roam build",
|
||||
description = "Cancels a running org-roam build"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Build cancelled successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@DeleteMapping("/build-roam")
|
||||
public ResponseEntity<String> cancelRoamBuild() {
|
||||
return killProcess(roamProcess, roamBuildRunning, "roam build");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get org-roam build status",
|
||||
description = "Returns the status of a running org-roam build"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Build status retrieved successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@GetMapping("/build-roam/status")
|
||||
public ResponseEntity<BuildStatus> getRoamBuildStatus() {
|
||||
return ResponseEntity.ok(new BuildStatus(roamBuildRunning.get(), roamLastRun, roamLastExitCode));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Stream org-roam build logs",
|
||||
description = "Streams the logs of a running org-roam build"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Logs streamed successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@GetMapping("/build-roam/logs")
|
||||
public SseEmitter streamRoamLogs() {
|
||||
return streamLogs(roamBuildLogFile);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
EMACS RERUN
|
||||
========================================================= */
|
||||
@@ -256,7 +187,7 @@ public class BuildController {
|
||||
@PostMapping("/rerun-emacs")
|
||||
public ResponseEntity<String> triggerEmacs() {
|
||||
log.info("Triggering Emacs command");
|
||||
return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess);
|
||||
return startCommand(emacsRunDirectory, emacsRunLogFile, emacsProcess);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -269,7 +200,7 @@ public class BuildController {
|
||||
})
|
||||
@DeleteMapping("/rerun-emacs")
|
||||
public ResponseEntity<String> cancelEmacs() {
|
||||
return killProcess(emacsProcess, emacsRunning, "emacs");
|
||||
return killProcess(emacsProcess, false, "emacs");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -282,7 +213,8 @@ public class BuildController {
|
||||
})
|
||||
@GetMapping("/rerun-emacs/status")
|
||||
public ResponseEntity<BuildStatus> getEmacsStatus() {
|
||||
return ResponseEntity.ok(new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode));
|
||||
var s = buildRunState.emacsStatus();
|
||||
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -298,127 +230,6 @@ public class BuildController {
|
||||
return streamLogs(emacsRunLogFile);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
COMBINED: EMACS + ROAM (sequential)
|
||||
========================================================= */
|
||||
|
||||
@Operation(
|
||||
summary = "Trigger combined run",
|
||||
description = "Triggers a combined run of Emacs and org-roam"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Combined run triggered successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@PostMapping("/run-combined")
|
||||
public ResponseEntity<String> triggerCombined() {
|
||||
if (!combinedRunning.compareAndSet(false, true)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body("Combined run already in progress");
|
||||
}
|
||||
if (emacsRunning.get() || roamBuildRunning.get()) {
|
||||
combinedRunning.set(false);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body("Emacs or Roam already running independently");
|
||||
}
|
||||
|
||||
try {
|
||||
File logFile = prepareLogFile(combinedRunLogFile);
|
||||
combinedLastRun = Instant.now();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
appendToLog(logFile, "=== [1/2] Starting Emacs ===\n");
|
||||
|
||||
ProcessBuilder emacsPb = new ProcessBuilder(
|
||||
"bash", "-c",
|
||||
"TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null"
|
||||
);
|
||||
emacsPb.directory(Path.of(emacsRunDirectory).toFile());
|
||||
emacsPb.redirectErrorStream(true);
|
||||
emacsPb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
|
||||
|
||||
Process ep = emacsPb.start();
|
||||
combinedProcess.set(ep);
|
||||
int emacsExit = ep.waitFor();
|
||||
emacsLastExitCode = emacsExit;
|
||||
|
||||
appendToLog(logFile, "\n=== Emacs exited (code " + emacsExit + ") ===\n");
|
||||
appendToLog(logFile, "=== [2/2] Starting Roam build ===\n");
|
||||
|
||||
ProcessBuilder roamPb = new ProcessBuilder("make");
|
||||
roamPb.directory(Path.of(roamBuildDirectory).toFile());
|
||||
roamPb.redirectErrorStream(true);
|
||||
roamPb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
|
||||
|
||||
Process rp = roamPb.start();
|
||||
combinedProcess.set(rp);
|
||||
int roamExit = rp.waitFor();
|
||||
roamLastExitCode = roamExit;
|
||||
roamLastRun = Instant.now();
|
||||
|
||||
int finalCode = (emacsExit == 0 || emacsExit == 124) && roamExit == 0 ? 0 : 1;
|
||||
combinedLastExitCode = finalCode;
|
||||
|
||||
appendToLog(logFile, "\n=== Roam build exited (code " + roamExit + ") ===\n");
|
||||
appendToLog(logFile, "=== Combined run complete (exit " + finalCode + ") ===\n");
|
||||
|
||||
} catch (Exception e) {
|
||||
combinedLastExitCode = 1;
|
||||
log.error("Combined run failed", e);
|
||||
} finally {
|
||||
combinedRunning.set(false);
|
||||
combinedProcess.set(null);
|
||||
}
|
||||
}).start();
|
||||
|
||||
return ResponseEntity.ok("Combined run started");
|
||||
|
||||
} catch (IOException e) {
|
||||
combinedRunning.set(false);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to start: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Cancel combined run",
|
||||
description = "Cancels a combined run of Emacs and org-roam"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Combined run cancelled successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@DeleteMapping("/run-combined")
|
||||
public ResponseEntity<String> cancelCombined() {
|
||||
return killProcess(combinedProcess, combinedRunning, "combined run");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get combined run status",
|
||||
description = "Returns the status of a running combined run"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Combined run status retrieved successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@GetMapping("/run-combined/status")
|
||||
public ResponseEntity<BuildStatus> getCombinedStatus() {
|
||||
return ResponseEntity.ok(new BuildStatus(combinedRunning.get(), combinedLastRun, combinedLastExitCode));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get combined run logs",
|
||||
description = "Returns the logs of a running combined run"
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Combined run logs retrieved successfully"),
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
})
|
||||
@GetMapping("/run-combined/logs")
|
||||
public SseEmitter streamCombinedLogs() {
|
||||
return streamLogs(combinedRunLogFile);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
LAST RUN TIMESTAMPS
|
||||
========================================================= */
|
||||
@@ -433,12 +244,7 @@ public class BuildController {
|
||||
})
|
||||
@GetMapping("/last-runs")
|
||||
public ResponseEntity<Map<String, Object>> getLastRuns() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)),
|
||||
"roam", Map.of("lastRun", nullSafe(roamLastRun), "exitCode", nullSafe(roamLastExitCode)),
|
||||
"emacs", Map.of("lastRun", nullSafe(emacsLastRun), "exitCode", nullSafe(emacsLastExitCode)),
|
||||
"combined", Map.of("lastRun", nullSafe(combinedLastRun), "exitCode", nullSafe(combinedLastExitCode))
|
||||
));
|
||||
return ResponseEntity.ok(buildRunState.lastRunsSnapshot());
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
@@ -447,10 +253,10 @@ public class BuildController {
|
||||
|
||||
private ResponseEntity<String> startBuild(
|
||||
String buildDir, String logPath,
|
||||
AtomicBoolean runningFlag, AtomicReference<Process> processRef,
|
||||
boolean isWeb) {
|
||||
boolean webBuild, AtomicReference<Process> processRef) {
|
||||
|
||||
if (!runningFlag.compareAndSet(false, true)) {
|
||||
boolean started = webBuild ? buildRunState.tryStartWeb() : buildRunState.tryStartEmacs();
|
||||
if (!started) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body("Build already running");
|
||||
}
|
||||
|
||||
@@ -464,21 +270,30 @@ public class BuildController {
|
||||
log.info("Starting build in {}", buildDir);
|
||||
Process process = pb.start();
|
||||
processRef.set(process);
|
||||
|
||||
if (isWeb) webLastRun = Instant.now();
|
||||
else roamLastRun = Instant.now();
|
||||
if (webBuild) {
|
||||
buildRunState.markWebStarted();
|
||||
} else {
|
||||
buildRunState.markEmacsStarted();
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
int exit = process.waitFor();
|
||||
if (isWeb) webLastExitCode = exit;
|
||||
else roamLastExitCode = exit;
|
||||
log.info("Build finished with exit code {}", exit);
|
||||
if (webBuild) {
|
||||
buildRunState.finishWeb(exit);
|
||||
} else {
|
||||
buildRunState.finishEmacs(exit);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Build thread interrupted", e);
|
||||
if (webBuild) {
|
||||
buildRunState.clearWebRunning();
|
||||
} else {
|
||||
buildRunState.clearEmacsRunning();
|
||||
}
|
||||
} finally {
|
||||
runningFlag.set(false);
|
||||
processRef.set(null);
|
||||
}
|
||||
}).start();
|
||||
@@ -486,7 +301,11 @@ public class BuildController {
|
||||
return ResponseEntity.ok("Build started");
|
||||
|
||||
} catch (IOException e) {
|
||||
runningFlag.set(false);
|
||||
if (webBuild) {
|
||||
buildRunState.clearWebRunning();
|
||||
} else {
|
||||
buildRunState.clearEmacsRunning();
|
||||
}
|
||||
log.error("Build failed to start", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to start build: " + e.getMessage());
|
||||
@@ -495,9 +314,9 @@ public class BuildController {
|
||||
|
||||
private ResponseEntity<String> startCommand(
|
||||
String runDir, String logPath,
|
||||
AtomicBoolean runningFlag, AtomicReference<Process> processRef) {
|
||||
AtomicReference<Process> processRef) {
|
||||
|
||||
if (!runningFlag.compareAndSet(false, true)) {
|
||||
if (!buildRunState.tryStartEmacs()) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body("Command already running");
|
||||
}
|
||||
|
||||
@@ -513,16 +332,16 @@ public class BuildController {
|
||||
|
||||
Process process = pb.start();
|
||||
processRef.set(process);
|
||||
emacsLastRun = Instant.now();
|
||||
buildRunState.markEmacsStarted();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
int exit = process.waitFor();
|
||||
emacsLastExitCode = exit;
|
||||
buildRunState.finishEmacs(exit);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
buildRunState.clearEmacsRunning();
|
||||
} finally {
|
||||
runningFlag.set(false);
|
||||
processRef.set(null);
|
||||
}
|
||||
}).start();
|
||||
@@ -530,7 +349,7 @@ public class BuildController {
|
||||
return ResponseEntity.ok("Command started");
|
||||
|
||||
} catch (IOException e) {
|
||||
runningFlag.set(false);
|
||||
buildRunState.clearEmacsRunning();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to start command: " + e.getMessage());
|
||||
}
|
||||
@@ -538,7 +357,7 @@ public class BuildController {
|
||||
|
||||
private ResponseEntity<String> killProcess(
|
||||
AtomicReference<Process> processRef,
|
||||
AtomicBoolean runningFlag,
|
||||
boolean webBuild,
|
||||
String label) {
|
||||
|
||||
Process p = processRef.get();
|
||||
@@ -546,7 +365,11 @@ public class BuildController {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No running " + label);
|
||||
}
|
||||
p.destroyForcibly();
|
||||
runningFlag.set(false);
|
||||
if (webBuild) {
|
||||
buildRunState.clearWebRunning();
|
||||
} else {
|
||||
buildRunState.clearEmacsRunning();
|
||||
}
|
||||
processRef.set(null);
|
||||
log.info("Killed {}", label);
|
||||
return ResponseEntity.ok("Killed " + label);
|
||||
|
||||
77
src/main/java/org/zaine/app/controller/zone/TimesheetController.java
Executable file
77
src/main/java/org/zaine/app/controller/zone/TimesheetController.java
Executable file
@@ -0,0 +1,77 @@
|
||||
package org.zaine.app.controller.zone;
|
||||
|
||||
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import org.zaine.app.service.TimesheetService;
|
||||
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
|
||||
|
||||
@RestController
|
||||
|
||||
@RequestMapping("/api/timesheet")
|
||||
|
||||
@Tag(name = "Timesheet", description = "Timesheet year storage")
|
||||
|
||||
public class TimesheetController {
|
||||
|
||||
|
||||
|
||||
private static final String API_KEY_HEADER = "X-Org-Api-Key";
|
||||
|
||||
|
||||
|
||||
private final TimesheetService timesheetService;
|
||||
|
||||
|
||||
|
||||
public TimesheetController(TimesheetService timesheetService) {
|
||||
|
||||
this.timesheetService = timesheetService;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Operation(summary = "Get timesheet year")
|
||||
|
||||
@GetMapping("/{year}")
|
||||
|
||||
public JsonNode getYear(@PathVariable int year) {
|
||||
|
||||
return timesheetService.getYear(year);
|
||||
|
||||
}
|
||||
|
||||
|
||||
46
src/main/java/org/zaine/app/controller/zone/ZoneController.java
Executable file
46
src/main/java/org/zaine/app/controller/zone/ZoneController.java
Executable file
@@ -0,0 +1,46 @@
|
||||
package org.zaine.app.controller.zone;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.zaine.app.service.ZoneManifestService;
|
||||
import org.zaine.app.service.ZoneStatusService;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/zone")
|
||||
@Tag(name = "Zone", description = "Zone dashboard manifest and integration status")
|
||||
public class ZoneController {
|
||||
|
||||
private final ZoneManifestService manifestService;
|
||||
private final ZoneStatusService statusService;
|
||||
|
||||
public ZoneController(ZoneManifestService manifestService, ZoneStatusService statusService) {
|
||||
this.manifestService = manifestService;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
@Operation(summary = "Get zone manifest", description = "Returns enriched manifest JSON for the dashboard")
|
||||
@GetMapping("/manifest")
|
||||
public Map<String, Object> manifest() {
|
||||
try {
|
||||
return manifestService.loadEnrichedManifest();
|
||||
} catch (IOException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "Get zone integration status", description = "Aggregated status for authoring, watcher, logs, timesheet")
|
||||
@GetMapping("/status")
|
||||
public Map<String, Object> status() {
|
||||
return statusService.collectStatus();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,48 @@
|
||||
package org.zaine.app.exception;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
// 🔹 Handles "no route found" (true 404 URLs)
|
||||
@ExceptionHandler(org.springframework.web.servlet.NoHandlerFoundException.class)
|
||||
public ResponseEntity<Map<String, String>> handleNotFound(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
@ExceptionHandler(NoHandlerFoundException.class)
|
||||
public ResponseEntity<Map<String, String>> handleNotFound(HttpServletRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
Map.of(
|
||||
"message", "Could not find resource with URL: " + request.getRequestURI()
|
||||
)
|
||||
Map.of("message", "Could not find resource with URL: " + request.getRequestURI())
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 Handles your own "not found in DB"
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<Map<String, String>> handleRuntime(
|
||||
RuntimeException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, String>> handleResponseStatus(
|
||||
ResponseStatusException ex,
|
||||
HttpServletRequest request) {
|
||||
HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value());
|
||||
if (status == null) {
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase();
|
||||
return ResponseEntity.status(status).body(
|
||||
Map.of("message", reason, "path", request.getRequestURI())
|
||||
);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataAccessException.class)
|
||||
public ResponseEntity<Map<String, String>> handleDataAccess(
|
||||
DataAccessException ex,
|
||||
HttpServletRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
|
||||
Map.of(
|
||||
"message", "Could not find resource with URL: " + request.getRequestURI()
|
||||
"message", "Database error",
|
||||
"path", request.getRequestURI()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
51
src/main/java/org/zaine/app/model/TimesheetYear.java
Executable file
51
src/main/java/org/zaine/app/model/TimesheetYear.java
Executable file
@@ -0,0 +1,51 @@
|
||||
package org.zaine.app.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "timesheet_year")
|
||||
public class TimesheetYear {
|
||||
|
||||
@Id
|
||||
@Column(name = "year")
|
||||
private Integer year;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
|
||||
private String payload;
|
||||
|
||||
@Column(name = "saved_at", nullable = false)
|
||||
private Instant savedAt = Instant.now();
|
||||
|
||||
public Integer getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(Integer year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public String getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(String payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public Instant getSavedAt() {
|
||||
return savedAt;
|
||||
}
|
||||
|
||||
public void setSavedAt(Instant savedAt) {
|
||||
this.savedAt = savedAt;
|
||||
}
|
||||
}
|
||||
7
src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java
Executable file
7
src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java
Executable file
@@ -0,0 +1,7 @@
|
||||
package org.zaine.app.repositories;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.zaine.app.model.TimesheetYear;
|
||||
|
||||
public interface TimesheetYearRepository extends JpaRepository<TimesheetYear, Integer> {
|
||||
}
|
||||
93
src/main/java/org/zaine/app/service/BuildRunStateService.java
Executable file
93
src/main/java/org/zaine/app/service/BuildRunStateService.java
Executable file
@@ -0,0 +1,93 @@
|
||||
package org.zaine.app.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class BuildRunStateService {
|
||||
|
||||
private final AtomicBoolean webBuildRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean emacsRunning = new AtomicBoolean(false);
|
||||
|
||||
private volatile Instant emacsLastRun;
|
||||
private volatile Integer emacsLastExitCode;
|
||||
private volatile Instant webLastRun;
|
||||
private volatile Integer webLastExitCode;
|
||||
|
||||
public boolean tryStartWeb() {
|
||||
return webBuildRunning.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
public void markWebStarted() {
|
||||
webLastRun = Instant.now();
|
||||
}
|
||||
|
||||
public void finishWeb(int exitCode) {
|
||||
webLastRun = Instant.now();
|
||||
webLastExitCode = exitCode;
|
||||
webBuildRunning.set(false);
|
||||
}
|
||||
|
||||
public void clearWebRunning() {
|
||||
webBuildRunning.set(false);
|
||||
}
|
||||
|
||||
public boolean isWebRunning() {
|
||||
return webBuildRunning.get();
|
||||
}
|
||||
|
||||
public boolean tryStartEmacs() {
|
||||
return emacsRunning.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
public void markEmacsStarted() {
|
||||
emacsLastRun = Instant.now();
|
||||
}
|
||||
|
||||
public void finishEmacs(int exitCode) {
|
||||
emacsLastRun = Instant.now();
|
||||
emacsLastExitCode = exitCode;
|
||||
emacsRunning.set(false);
|
||||
}
|
||||
|
||||
public void clearEmacsRunning() {
|
||||
emacsRunning.set(false);
|
||||
}
|
||||
|
||||
public boolean isEmacsRunning() {
|
||||
return emacsRunning.get();
|
||||
}
|
||||
|
||||
public Map<String, Object> lastRunsSnapshot() {
|
||||
Map<String, Object> runs = new LinkedHashMap<>();
|
||||
runs.put("org_web", Map.of(
|
||||
"lastRun", nullSafe(webLastRun),
|
||||
"exitCode", nullSafe(webLastExitCode),
|
||||
"running", webBuildRunning.get()
|
||||
));
|
||||
runs.put("emacs", Map.of(
|
||||
"lastRun", nullSafe(emacsLastRun),
|
||||
"exitCode", nullSafe(emacsLastExitCode),
|
||||
"running", emacsRunning.get()
|
||||
));
|
||||
return runs;
|
||||
}
|
||||
|
||||
public BuildStatus webStatus() {
|
||||
return new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode);
|
||||
}
|
||||
|
||||
public BuildStatus emacsStatus() {
|
||||
return new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode);
|
||||
}
|
||||
|
||||
private static Object nullSafe(Object value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
public record BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
@Service
|
||||
public class RpgSaveService {
|
||||
private static final String DEFAULT_SAVE_DIRECTORY =
|
||||
"/home/zaine/master-folder/projects/java_projects/org_backend/data/rpg-saves";
|
||||
"/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path saveDirectory;
|
||||
|
||||
265
src/main/java/org/zaine/app/service/TimesheetService.java
Executable file
265
src/main/java/org/zaine/app/service/TimesheetService.java
Executable file
@@ -0,0 +1,265 @@
|
||||
package org.zaine.app.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.zaine.app.model.TimesheetYear;
|
||||
import org.zaine.app.repositories.TimesheetYearRepository;
|
||||
import org.zaine.app.security.JwtUtil;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
@Service
|
||||
public class TimesheetService {
|
||||
|
||||
private static final Map<String, Double> CONTRACTED = Map.of(
|
||||
"full", 7.5,
|
||||
"half", 3.75,
|
||||
"off", 0.0
|
||||
);
|
||||
|
||||
private final TimesheetYearRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final String apiKey;
|
||||
private final String jwtCookieName;
|
||||
private final int maxPayloadBytes;
|
||||
private final boolean authRequired;
|
||||
|
||||
public TimesheetService(
|
||||
TimesheetYearRepository repository,
|
||||
ObjectMapper objectMapper,
|
||||
JwtUtil jwtUtil,
|
||||
@Value("${org.auth.api-key:}") String apiKey,
|
||||
@Value("${auth.cookie.name:orgWebJwt}") String jwtCookieName,
|
||||
@Value("${timesheet.max-payload-bytes:5242880}") int maxPayloadBytes,
|
||||
@Value("${timesheet.auth.required:true}") boolean authRequired) {
|
||||
this.repository = repository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.apiKey = apiKey;
|
||||
this.jwtCookieName = jwtCookieName;
|
||||
this.maxPayloadBytes = maxPayloadBytes;
|
||||
this.authRequired = authRequired;
|
||||
}
|
||||
|
||||
public void requireWriteAccess(String providedKey, String authorizationHeader, String cookieHeader) {
|
||||
if (!authRequired || apiKey == null || apiKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (providedKey != null && apiKey.equals(providedKey)) {
|
||||
return;
|
||||
}
|
||||
String token = extractBearerToken(authorizationHeader, cookieHeader);
|
||||
if (token != null && jwtUtil.isTokenValid(token)) {
|
||||
return;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing credentials");
|
||||
}
|
||||
|
||||
public void enforcePayloadSize(JsonNode body) {
|
||||
try {
|
||||
int size = objectMapper.writeValueAsBytes(body).length;
|
||||
if (size > maxPayloadBytes) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"Timesheet payload exceeds " + maxPayloadBytes + " bytes"
|
||||
);
|
||||
}
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private String extractBearerToken(String authorizationHeader, String cookieHeader) {
|
||||
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||
return authorizationHeader.substring(7).trim();
|
||||
}
|
||||
if (cookieHeader == null || cookieHeader.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String prefix = jwtCookieName + "=";
|
||||
for (String part : cookieHeader.split(";")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.substring(prefix.length()).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public JsonNode getYear(int year) {
|
||||
return repository.findById(year)
|
||||
.map(row -> parsePayload(row.getPayload()))
|
||||
.orElseGet(() -> emptyYear(year));
|
||||
}
|
||||
|
||||
private ObjectNode emptyYear(int year) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("year", year);
|
||||
node.set("entries", objectMapper.createObjectNode());
|
||||
return node;
|
||||
}
|
||||
|
||||
public boolean yearExists(int year) {
|
||||
return repository.existsById(year);
|
||||
}
|
||||
|
||||
public JsonNode putYear(int year, JsonNode body, String apiKeyHeader, String authorization, String cookie) {
|
||||
requireWriteAccess(apiKeyHeader, authorization, cookie);
|
||||
enforcePayloadSize(body);
|
||||
ObjectNode normalized = body.isObject() ? (ObjectNode) body.deepCopy() : objectMapper.createObjectNode();
|
||||
normalized.put("year", year);
|
||||
if (!normalized.has("entries")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload must include entries object");
|
||||
}
|
||||
validateYearPayload(year, normalized);
|
||||
saveRow(year, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public JsonNode mergeYear(int year, JsonNode body, String apiKeyHeader, String authorization, String cookie) {
|
||||
requireWriteAccess(apiKeyHeader, authorization, cookie);
|
||||
enforcePayloadSize(body);
|
||||
JsonNode incoming = body.has("entries") ? body.get("entries") : body;
|
||||
if (!incoming.isObject()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Expected entries object");
|
||||
}
|
||||
|
||||
ObjectNode result;
|
||||
Optional<TimesheetYear> existing = repository.findById(year);
|
||||
if (existing.isPresent()) {
|
||||
result = (ObjectNode) parsePayload(existing.get().getPayload()).deepCopy();
|
||||
} else {
|
||||
result = objectMapper.createObjectNode();
|
||||
result.put("year", year);
|
||||
result.set("entries", objectMapper.createObjectNode());
|
||||
}
|
||||
|
||||
ObjectNode entries = result.withObject("entries");
|
||||
int replaced = 0;
|
||||
int imported = 0;
|
||||
var fields = incoming.fields();
|
||||
while (fields.hasNext()) {
|
||||
var field = fields.next();
|
||||
imported++;
|
||||
String date = field.getKey();
|
||||
if (entries.has(date)) {
|
||||
replaced++;
|
||||
}
|
||||
ObjectNode target = entries.has(date) && entries.get(date).isObject()
|
||||
? (ObjectNode) entries.get(date).deepCopy()
|
||||
: objectMapper.createObjectNode();
|
||||
target.put("date", date);
|
||||
if (field.getValue().isObject()) {
|
||||
field.getValue().fields().forEachRemaining(f -> target.set(f.getKey(), f.getValue()));
|
||||
}
|
||||
entries.set(date, target);
|
||||
}
|
||||
|
||||
result.put("year", year);
|
||||
saveRow(year, result);
|
||||
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("year", year);
|
||||
response.set("entries", entries);
|
||||
response.put("replaced", replaced);
|
||||
response.put("imported", imported);
|
||||
return response;
|
||||
}
|
||||
|
||||
public Map<String, Object> weekSummary(int year) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("year", year);
|
||||
Optional<TimesheetYear> row = repository.findById(year);
|
||||
if (row.isEmpty()) {
|
||||
summary.put("available", false);
|
||||
summary.put("message", "No timesheet data");
|
||||
return summary;
|
||||
}
|
||||
|
||||
JsonNode root = parsePayload(row.get().getPayload());
|
||||
JsonNode entries = root.get("entries");
|
||||
if (entries == null || !entries.isObject()) {
|
||||
summary.put("available", false);
|
||||
return summary;
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
if (today.getYear() != year) {
|
||||
today = LocalDate.of(year, 12, 31);
|
||||
}
|
||||
LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||
|
||||
double hoursWorked = 0;
|
||||
double contracted = 0;
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate d = weekStart.plusDays(i);
|
||||
String iso = d.toString();
|
||||
JsonNode entry = entries.get(iso);
|
||||
if (entry == null || !entry.isObject()) {
|
||||
continue;
|
||||
}
|
||||
hoursWorked += entryHours(entry);
|
||||
String schedule = entry.path("schedule").asText("off");
|
||||
contracted += CONTRACTED.getOrDefault(schedule, 0.0);
|
||||
}
|
||||
|
||||
summary.put("available", true);
|
||||
summary.put("weekStart", weekStart.toString());
|
||||
summary.put("hoursWorked", round(hoursWorked));
|
||||
summary.put("contracted", round(contracted));
|
||||
summary.put("delta", round(hoursWorked - contracted));
|
||||
return summary;
|
||||
}
|
||||
|
||||
private double entryHours(JsonNode entry) {
|
||||
if (entry.has("hoursWorked") && !entry.get("hoursWorked").isNull()) {
|
||||
return entry.get("hoursWorked").asDouble(0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static double round(double v) {
|
||||
return Math.round(v * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
private void saveRow(int year, JsonNode body) {
|
||||
try {
|
||||
TimesheetYear row = repository.findById(year).orElse(new TimesheetYear());
|
||||
row.setYear(year);
|
||||
row.setPayload(objectMapper.writeValueAsString(body));
|
||||
row.setSavedAt(java.time.Instant.now());
|
||||
repository.save(row);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateYearPayload(int year, JsonNode body) {
|
||||
if (!body.has("entries") || !body.get("entries").isObject()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload must include entries object");
|
||||
}
|
||||
if (body.has("year") && body.get("year").asInt() != year) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Year mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode parsePayload(String payload) {
|
||||
try {
|
||||
return objectMapper.readTree(payload);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Corrupt timesheet payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/main/java/org/zaine/app/service/ZoneManifestService.java
Executable file
85
src/main/java/org/zaine/app/service/ZoneManifestService.java
Executable file
@@ -0,0 +1,85 @@
|
||||
package org.zaine.app.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@Service
|
||||
public class ZoneManifestService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path manifestPath;
|
||||
|
||||
public ZoneManifestService(
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${zone.manifest.path}") String manifestPath) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.manifestPath = Path.of(manifestPath);
|
||||
}
|
||||
|
||||
public Map<String, Object> loadEnrichedManifest() throws IOException {
|
||||
if (!Files.isRegularFile(manifestPath)) {
|
||||
throw new IOException("Manifest not found: " + manifestPath);
|
||||
}
|
||||
String raw = Files.readString(manifestPath);
|
||||
Map<String, Object> manifest = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
|
||||
if (!manifest.containsKey("version") || ((Number) manifest.get("version")).intValue() < 2) {
|
||||
manifest.put("version", 2);
|
||||
}
|
||||
manifest.putIfAbsent("integrations", defaultIntegrations());
|
||||
manifest.putIfAbsent("widgets", defaultWidgets());
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static Map<String, Object> defaultIntegrations() {
|
||||
Map<String, Object> integrations = new LinkedHashMap<>();
|
||||
integrations.put("authoring", Map.of(
|
||||
"pollMs", 5000,
|
||||
"path", "/api/zone/status"
|
||||
));
|
||||
integrations.put("watcher", Map.of(
|
||||
"pollMs", 10000,
|
||||
"path", "/api/zone/status"
|
||||
));
|
||||
integrations.put("timesheet", Map.of(
|
||||
"pollMs", 60000,
|
||||
"path", "/api/zone/status"
|
||||
));
|
||||
return integrations;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> defaultWidgets() {
|
||||
return List.of(
|
||||
Map.of(
|
||||
"id", "authoring-queue",
|
||||
"type", "queue",
|
||||
"title", "Authoring builds",
|
||||
"integration", "authoring"
|
||||
),
|
||||
Map.of(
|
||||
"id", "watcher",
|
||||
"type", "badge",
|
||||
"title", "Lima watcher",
|
||||
"integration", "watcher"
|
||||
),
|
||||
Map.of(
|
||||
"id", "timesheet-summary",
|
||||
"type", "timesheet",
|
||||
"title", "Hours this week",
|
||||
"integration", "timesheet"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
526
src/main/java/org/zaine/app/service/ZoneStatusService.java
Executable file
526
src/main/java/org/zaine/app/service/ZoneStatusService.java
Executable file
@@ -0,0 +1,526 @@
|
||||
package org.zaine.app.service;
|
||||
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import java.io.RandomAccessFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import java.nio.file.Files;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
|
||||
|
||||
@Service
|
||||
|
||||
public class ZoneStatusService {
|
||||
|
||||
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ZoneStatusService.class);
|
||||
|
||||
private static final long SCRIPT_STALE_HOURS = 48;
|
||||
|
||||
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
private final String authoringUrl;
|
||||
|
||||
private final String watcherUnit;
|
||||
|
||||
private final Path buildLogPath;
|
||||
|
||||
private final Path scriptsLogDir;
|
||||
|
||||
private final TimesheetService timesheetService;
|
||||
|
||||
private final BuildRunStateService buildRunState;
|
||||
|
||||
|
||||
|
||||
public ZoneStatusService(
|
||||
|
||||
RestTemplate restTemplate,
|
||||
|
||||
@Value("${zone.authoring.url}") String authoringUrl,
|
||||
|
||||
@Value("${zone.watcher.unit}") String watcherUnit,
|
||||
|
||||
@Value("${zone.build.log}") String buildLogPath,
|
||||
|
||||
@Value("${zone.scripts.log.dir}") String scriptsLogDir,
|
||||
|
||||
TimesheetService timesheetService,
|
||||
|
||||
BuildRunStateService buildRunState) {
|
||||
|
||||
this.restTemplate = restTemplate;
|
||||
|
||||
this.authoringUrl = authoringUrl;
|
||||
|
||||
this.watcherUnit = watcherUnit;
|
||||
|
||||
this.buildLogPath = Path.of(buildLogPath);
|
||||
|
||||
this.scriptsLogDir = Path.of(scriptsLogDir);
|
||||
|
||||
this.timesheetService = timesheetService;
|
||||
|
||||
this.buildRunState = buildRunState;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Map<String, Object> collectStatus() {
|
||||
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
|
||||
status.put("authoring", fetchAuthoringDetail());
|
||||
|
||||
status.put("watcher", fetchWatcher());
|
||||
|
||||
status.put("orgWebLog", tailLog(buildLogPath, 30));
|
||||
|
||||
status.put("scripts", fetchScriptLogs());
|
||||
|
||||
status.put("lastRuns", buildRunState.lastRunsSnapshot());
|
||||
|
||||
status.put("timesheet", fetchTimesheetSummary());
|
||||
|
||||
return status;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
private Map<String, Object> fetchAuthoringDetail() {
|
||||
|
||||
try {
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.getForEntity(
|
||||
|
||||
authoringUrl + "/api/build", Map.class);
|
||||
|
||||
if (response.getBody() != null) {
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>(response.getBody());
|
||||
|
||||
enrichAuthoringDetail(body);
|
||||
|
||||
return body;
|
||||
|
||||
}
|
||||
|
||||
} catch (RestClientException ex) {
|
||||
|
||||
log.debug("Authoring status unavailable: {}", ex.getMessage());
|
||||
|
||||
}
|
||||
|
||||
return Map.of(
|
||||
|
||||
"running", false,
|
||||
|
||||
"queued", 0,
|
||||
|
||||
"message", "Authoring service unreachable",
|
||||
|
||||
"error", true
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
private void enrichAuthoringDetail(Map<String, Object> authoring) {
|
||||
|
||||
Object pendingObj = authoring.get("pending");
|
||||
|
||||
int pendingCount = 0;
|
||||
|
||||
if (pendingObj instanceof List<?> pending) {
|
||||
|
||||
pendingCount = pending.size();
|
||||
|
||||
}
|
||||
|
||||
authoring.put("pendingCount", pendingCount);
|
||||
|
||||
|
||||
|
||||
Object failedObj = authoring.get("failed");
|
||||
|
||||
String lastFailedTitle = null;
|
||||
|
||||
if (failedObj instanceof List<?> failed && !failed.isEmpty()) {
|
||||
|
||||
Object last = failed.get(failed.size() - 1);
|
||||
|
||||
if (last instanceof Map<?, ?> job) {
|
||||
|
||||
Object title = job.get("title");
|
||||
|
||||
if (title != null) {
|
||||
|
||||
lastFailedTitle = title.toString();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (lastFailedTitle != null) {
|
||||
|
||||
authoring.put("lastFailedTitle", lastFailedTitle);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> fetchTimesheetSummary() {
|
||||
try {
|
||||
return timesheetService.weekSummary(java.time.Year.now().getValue());
|
||||
} catch (Exception ex) {
|
||||
log.debug("Timesheet summary unavailable: {}", ex.getMessage());
|
||||
Map<String, Object> fallback = new LinkedHashMap<>();
|
||||
fallback.put("available", false);
|
||||
fallback.put("message", "Timesheet unavailable");
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> fetchWatcher() {
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
result.put("unit", watcherUnit);
|
||||
|
||||
try {
|
||||
|
||||
Process process = new ProcessBuilder(
|
||||
|
||||
"systemctl", "show", watcherUnit,
|
||||
|
||||
"--property=ActiveState,SubState,MainPID"
|
||||
|
||||
).redirectErrorStream(true).start();
|
||||
|
||||
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
|
||||
|
||||
if (!finished) {
|
||||
|
||||
process.destroyForcibly();
|
||||
|
||||
result.put("active", "unknown");
|
||||
|
||||
result.put("message", "systemctl timed out");
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
for (String line : output.split("\n")) {
|
||||
|
||||
int eq = line.indexOf('=');
|
||||
|
||||
if (eq > 0) {
|
||||
|
||||
result.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
|
||||
log.debug("Watcher status failed: {}", ex.getMessage());
|
||||
|
||||
result.put("active", "unknown");
|
||||
|
||||
result.put("message", ex.getMessage());
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> tailLog(Path logFile, int maxLines) {
|
||||
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
|
||||
meta.put("path", logFile.toString());
|
||||
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
|
||||
meta.put("tail", List.of());
|
||||
|
||||
meta.put("message", "Log file not found");
|
||||
|
||||
return meta;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
meta.put("mtime", Files.getLastModifiedTime(logFile).toInstant().toString());
|
||||
|
||||
meta.put("size", Files.size(logFile));
|
||||
|
||||
meta.put("tail", readLastLines(logFile, maxLines));
|
||||
|
||||
} catch (IOException ex) {
|
||||
|
||||
meta.put("tail", List.of());
|
||||
|
||||
meta.put("message", ex.getMessage());
|
||||
|
||||
}
|
||||
|
||||
return meta;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private List<String> readLastLines(Path file, int maxLines) throws IOException {
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
|
||||
long pointer = raf.length() - 1;
|
||||
|
||||
StringBuilder line = new StringBuilder();
|
||||
|
||||
while (pointer >= 0 && lines.size() < maxLines) {
|
||||
|
||||
raf.seek(pointer);
|
||||
|
||||
int ch = raf.read();
|
||||
|
||||
if (ch == '\n') {
|
||||
|
||||
if (line.length() > 0) {
|
||||
|
||||
lines.add(0, line.reverse().toString());
|
||||
|
||||
line.setLength(0);
|
||||
|
||||
}
|
||||
|
||||
} else if (ch != '\r') {
|
||||
|
||||
line.append((char) ch);
|
||||
|
||||
}
|
||||
|
||||
pointer--;
|
||||
|
||||
}
|
||||
|
||||
if (line.length() > 0 && lines.size() < maxLines) {
|
||||
|
||||
lines.add(0, line.reverse().toString());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> fetchScriptLogs() {
|
||||
|
||||
Map<String, Object> scripts = new LinkedHashMap<>();
|
||||
|
||||
scripts.put("orgWebBuild", scriptHealth(
|
||||
|
||||
"Website build log",
|
||||
|
||||
scriptsLogDir.resolve("org-web.log")
|
||||
|
||||
));
|
||||
|
||||
scripts.put("calibreSync", calibreSyncHealth(scriptsLogDir.resolve("org-books-calibre.log")));
|
||||
|
||||
return scripts;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> scriptHealth(String label, Path path) {
|
||||
|
||||
Map<String, Object> info = statLog(path);
|
||||
|
||||
info.put("label", label);
|
||||
|
||||
applyStale(info);
|
||||
|
||||
return info;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> calibreSyncHealth(Path path) {
|
||||
|
||||
Map<String, Object> info = statLog(path);
|
||||
|
||||
info.put("label", "Calibre export");
|
||||
|
||||
applyStale(info);
|
||||
|
||||
if (Files.isRegularFile(path)) {
|
||||
|
||||
try {
|
||||
|
||||
List<String> tail = readLastLines(path, 1);
|
||||
|
||||
if (!tail.isEmpty()) {
|
||||
|
||||
info.put("lastLine", tail.get(tail.size() - 1));
|
||||
|
||||
}
|
||||
|
||||
} catch (IOException ex) {
|
||||
|
||||
info.put("readError", ex.getMessage());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return info;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Map<String, Object> statLog(Path path) {
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
|
||||
info.put("path", path.toString());
|
||||
|
||||
if (Files.isRegularFile(path)) {
|
||||
|
||||
try {
|
||||
|
||||
Instant mtime = Files.getLastModifiedTime(path).toInstant();
|
||||
|
||||
info.put("mtime", mtime.toString());
|
||||
|
||||
info.put("size", Files.size(path));
|
||||
|
||||
info.put("exists", true);
|
||||
|
||||
} catch (IOException ex) {
|
||||
|
||||
info.put("error", ex.getMessage());
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
info.put("exists", false);
|
||||
|
||||
}
|
||||
|
||||
return info;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void applyStale(Map<String, Object> info) {
|
||||
|
||||
Object mtimeObj = info.get("mtime");
|
||||
|
||||
if (mtimeObj == null) {
|
||||
|
||||
info.put("stale", true);
|
||||
|
||||
info.put("staleReason", "missing");
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Instant mtime = Instant.parse(mtimeObj.toString());
|
||||
|
||||
boolean stale = Duration.between(mtime, Instant.now()).toHours() > SCRIPT_STALE_HOURS;
|
||||
|
||||
info.put("stale", stale);
|
||||
|
||||
if (stale) {
|
||||
|
||||
info.put("staleReason", "older than " + SCRIPT_STALE_HOURS + "h");
|
||||
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
|
||||
info.put("stale", true);
|
||||
|
||||
info.put("staleReason", "unparseable mtime");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
5
src/main/resources/db/migration/V1__timesheet_year.sql
Executable file
5
src/main/resources/db/migration/V1__timesheet_year.sql
Executable file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS timesheet_year (
|
||||
year INT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
6
src/main/resources/db/migration/V2__ensure_timesheet_year.sql
Executable file
6
src/main/resources/db/migration/V2__ensure_timesheet_year.sql
Executable file
@@ -0,0 +1,6 @@
|
||||
-- Ensures timesheet table exists when V1 was skipped by Flyway baseline (non-empty schema, no table yet).
|
||||
CREATE TABLE IF NOT EXISTS timesheet_year (
|
||||
year INT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
43
src/test/java/org/zaine/app/service/ZoneManifestServiceTest.java
Executable file
43
src/test/java/org/zaine/app/service/ZoneManifestServiceTest.java
Executable file
@@ -0,0 +1,43 @@
|
||||
package org.zaine.app.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
class ZoneManifestServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void loadEnrichedManifest_injectsWidgetsAndIntegrations() throws Exception {
|
||||
Path manifest = tempDir.resolve("manifest.json");
|
||||
Files.writeString(manifest, """
|
||||
{
|
||||
"version": 1,
|
||||
"dashboard": { "title": "Test", "tagline": "Tag" },
|
||||
"links": [],
|
||||
"builds": [],
|
||||
"status": { "health": "/api/health" }
|
||||
}
|
||||
""");
|
||||
|
||||
ZoneManifestService service = new ZoneManifestService(new ObjectMapper(), manifest.toString());
|
||||
Map<String, Object> loaded = service.loadEnrichedManifest();
|
||||
|
||||
assertEquals(2, loaded.get("version"));
|
||||
assertTrue(loaded.containsKey("integrations"));
|
||||
assertTrue(loaded.containsKey("widgets"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> widgets = (List<Map<String, Object>>) loaded.get("widgets");
|
||||
assertEquals(3, widgets.size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user