diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ab5c177..68bdf98 100755 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 }} diff --git a/README.md b/README.md index cf998de..11cd9ca 100755 --- a/README.md +++ b/README.md @@ -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: diff --git a/backups/systemd-20260604-121946/org-backend.service.permanent-copy b/backups/systemd-20260604-121946/org-backend.service.permanent-copy new file mode 100755 index 0000000..9d6c380 --- /dev/null +++ b/backups/systemd-20260604-121946/org-backend.service.permanent-copy @@ -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 diff --git a/backups/systemd-20260604-121946/org-backend.service.symlink-target b/backups/systemd-20260604-121946/org-backend.service.symlink-target new file mode 100755 index 0000000..116d0c8 --- /dev/null +++ b/backups/systemd-20260604-121946/org-backend.service.symlink-target @@ -0,0 +1 @@ +/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service diff --git a/backups/systemd-20260604-121946/org-backend.service.wants-symlink-target b/backups/systemd-20260604-121946/org-backend.service.wants-symlink-target new file mode 100755 index 0000000..116d0c8 --- /dev/null +++ b/backups/systemd-20260604-121946/org-backend.service.wants-symlink-target @@ -0,0 +1 @@ +/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service diff --git a/backups/systemd-20260604-121946/summary.txt b/backups/systemd-20260604-121946/summary.txt new file mode 100755 index 0000000..37c0b94 --- /dev/null +++ b/backups/systemd-20260604-121946/summary.txt @@ -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 diff --git a/misc/org-backend.env.example b/misc/org-backend.env.example new file mode 100755 index 0000000..e11a249 --- /dev/null +++ b/misc/org-backend.env.example @@ -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 diff --git a/misc/org-backend.service b/misc/org-backend.service index 9d6c380..c76d5a0 100755 --- a/misc/org-backend.service +++ b/misc/org-backend.service @@ -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 diff --git a/pom.xml b/pom.xml index 3a2f795..a4487b3 100755 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,12 @@ postgresql + + + org.flywaydb + flyway-core + + org.xerial sqlite-jdbc diff --git a/scripts/deploy-systemd.sh b/scripts/deploy-systemd.sh index 374a9a8..e48c926 100755 --- a/scripts/deploy-systemd.sh +++ b/scripts/deploy-systemd.sh @@ -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}" diff --git a/src/main/java/org/zaine/app/config/RestClientConfig.java b/src/main/java/org/zaine/app/config/RestClientConfig.java new file mode 100755 index 0000000..9199e84 --- /dev/null +++ b/src/main/java/org/zaine/app/config/RestClientConfig.java @@ -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(); + } +} diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index 20ead76..2ca4e01 100755 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -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 BuildRunStateService buildRunState; - 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 AtomicReference webProcess = new AtomicReference<>(); + private final AtomicReference emacsProcess = new AtomicReference<>(); - private final AtomicReference webProcess = new AtomicReference<>(); - private final AtomicReference roamProcess = new AtomicReference<>(); - private final AtomicReference emacsProcess = new AtomicReference<>(); - private final AtomicReference 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 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 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 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 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 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 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 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 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 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 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 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 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> 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 startBuild( String buildDir, String logPath, - AtomicBoolean runningFlag, AtomicReference processRef, - boolean isWeb) { + boolean webBuild, AtomicReference 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 startCommand( String runDir, String logPath, - AtomicBoolean runningFlag, AtomicReference processRef) { + AtomicReference 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 killProcess( AtomicReference 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); diff --git a/src/main/java/org/zaine/app/controller/zone/TimesheetController.java b/src/main/java/org/zaine/app/controller/zone/TimesheetController.java new file mode 100755 index 0000000..f29258c --- /dev/null +++ b/src/main/java/org/zaine/app/controller/zone/TimesheetController.java @@ -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); + } + + @Operation(summary = "Replace timesheet year") + @PutMapping("/{year}") + public JsonNode putYear( + @PathVariable int year, + @RequestBody JsonNode body, + @RequestHeader(value = API_KEY_HEADER, required = false) String apiKey, + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestHeader(value = HttpHeaders.COOKIE, required = false) String cookie) { + return timesheetService.putYear(year, body, apiKey, authorization, cookie); + } + + @Operation(summary = "Merge entries into timesheet year") + @PostMapping("/{year}/merge") + public JsonNode mergeYear( + @PathVariable int year, + @RequestBody JsonNode body, + @RequestHeader(value = API_KEY_HEADER, required = false) String apiKey, + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestHeader(value = HttpHeaders.COOKIE, required = false) String cookie) { + return timesheetService.mergeYear(year, body, apiKey, authorization, cookie); + } + + @Operation(summary = "Export timesheet year as JSON download") + @GetMapping("/{year}/export") + public ResponseEntity exportYear(@PathVariable int year) { + JsonNode data = timesheetService.getYear(year); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"timesheet-" + year + ".json\"") + .contentType(MediaType.APPLICATION_JSON) + .body(data); + } + + @Operation(summary = "Week summary for dashboard widget") + @GetMapping("/{year}/summary") + public Object weekSummary(@PathVariable int year) { + return timesheetService.weekSummary(year); + } +} diff --git a/src/main/java/org/zaine/app/controller/zone/ZoneController.java b/src/main/java/org/zaine/app/controller/zone/ZoneController.java new file mode 100755 index 0000000..1463c78 --- /dev/null +++ b/src/main/java/org/zaine/app/controller/zone/ZoneController.java @@ -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 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 status() { + return statusService.collectStatus(); + } +} diff --git a/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java b/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java index 41df243..53568d9 100755 --- a/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java @@ -1,36 +1,49 @@ 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> handleNotFound( - HttpServletRequest request - ) { + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity> 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> handleRuntime( - RuntimeException ex, - HttpServletRequest request - ) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> 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> 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() ) ); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/model/TimesheetYear.java b/src/main/java/org/zaine/app/model/TimesheetYear.java new file mode 100755 index 0000000..673f3d9 --- /dev/null +++ b/src/main/java/org/zaine/app/model/TimesheetYear.java @@ -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; + } +} diff --git a/src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java b/src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java new file mode 100755 index 0000000..ac630a9 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java @@ -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 { +} diff --git a/src/main/java/org/zaine/app/service/BuildRunStateService.java b/src/main/java/org/zaine/app/service/BuildRunStateService.java new file mode 100755 index 0000000..2211da3 --- /dev/null +++ b/src/main/java/org/zaine/app/service/BuildRunStateService.java @@ -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 lastRunsSnapshot() { + Map 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) {} +} diff --git a/src/main/java/org/zaine/app/service/RpgSaveService.java b/src/main/java/org/zaine/app/service/RpgSaveService.java index 86a96e9..1f0a2f5 100755 --- a/src/main/java/org/zaine/app/service/RpgSaveService.java +++ b/src/main/java/org/zaine/app/service/RpgSaveService.java @@ -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; diff --git a/src/main/java/org/zaine/app/service/TimesheetService.java b/src/main/java/org/zaine/app/service/TimesheetService.java new file mode 100755 index 0000000..24498aa --- /dev/null +++ b/src/main/java/org/zaine/app/service/TimesheetService.java @@ -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 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 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 weekSummary(int year) { + Map summary = new LinkedHashMap<>(); + summary.put("year", year); + Optional 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"); + } + } +} diff --git a/src/main/java/org/zaine/app/service/ZoneManifestService.java b/src/main/java/org/zaine/app/service/ZoneManifestService.java new file mode 100755 index 0000000..27e19c1 --- /dev/null +++ b/src/main/java/org/zaine/app/service/ZoneManifestService.java @@ -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 loadEnrichedManifest() throws IOException { + if (!Files.isRegularFile(manifestPath)) { + throw new IOException("Manifest not found: " + manifestPath); + } + String raw = Files.readString(manifestPath); + Map 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 defaultIntegrations() { + Map 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> 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" + ) + ); + } +} diff --git a/src/main/java/org/zaine/app/service/ZoneStatusService.java b/src/main/java/org/zaine/app/service/ZoneStatusService.java new file mode 100755 index 0000000..8e77f17 --- /dev/null +++ b/src/main/java/org/zaine/app/service/ZoneStatusService.java @@ -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 collectStatus() { + + Map 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 fetchAuthoringDetail() { + + try { + + ResponseEntity response = restTemplate.getForEntity( + + authoringUrl + "/api/build", Map.class); + + if (response.getBody() != null) { + + Map 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 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 fetchTimesheetSummary() { + try { + return timesheetService.weekSummary(java.time.Year.now().getValue()); + } catch (Exception ex) { + log.debug("Timesheet summary unavailable: {}", ex.getMessage()); + Map fallback = new LinkedHashMap<>(); + fallback.put("available", false); + fallback.put("message", "Timesheet unavailable"); + return fallback; + } + } + + private Map fetchWatcher() { + + Map 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 tailLog(Path logFile, int maxLines) { + + Map 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 readLastLines(Path file, int maxLines) throws IOException { + + List 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 fetchScriptLogs() { + + Map 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 scriptHealth(String label, Path path) { + + Map info = statLog(path); + + info.put("label", label); + + applyStale(info); + + return info; + + } + + + + private Map calibreSyncHealth(Path path) { + + Map info = statLog(path); + + info.put("label", "Calibre export"); + + applyStale(info); + + if (Files.isRegularFile(path)) { + + try { + + List 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 statLog(Path path) { + + Map 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 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"); + + } + + } + +} + diff --git a/src/main/resources/db/migration/V1__timesheet_year.sql b/src/main/resources/db/migration/V1__timesheet_year.sql new file mode 100755 index 0000000..7ab9aa6 --- /dev/null +++ b/src/main/resources/db/migration/V1__timesheet_year.sql @@ -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() +); diff --git a/src/main/resources/db/migration/V2__ensure_timesheet_year.sql b/src/main/resources/db/migration/V2__ensure_timesheet_year.sql new file mode 100755 index 0000000..b60ecd2 --- /dev/null +++ b/src/main/resources/db/migration/V2__ensure_timesheet_year.sql @@ -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() +); diff --git a/src/test/java/org/zaine/app/service/ZoneManifestServiceTest.java b/src/test/java/org/zaine/app/service/ZoneManifestServiceTest.java new file mode 100755 index 0000000..f10b520 --- /dev/null +++ b/src/test/java/org/zaine/app/service/ZoneManifestServiceTest.java @@ -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 loaded = service.loadEnrichedManifest(); + + assertEquals(2, loaded.get("version")); + assertTrue(loaded.containsKey("integrations")); + assertTrue(loaded.containsKey("widgets")); + @SuppressWarnings("unchecked") + List> widgets = (List>) loaded.get("widgets"); + assertEquals(3, widgets.size()); + } +}