updates
All checks were successful
Build Org Backend / build (push) Successful in 12s

This commit is contained in:
2026-06-04 14:32:21 +01:00
parent 3b504aeea8
commit e9914e7fa8
25 changed files with 1377 additions and 255 deletions

View File

@@ -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 }}

View File

@@ -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:

View 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

View File

@@ -0,0 +1 @@
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service

View File

@@ -0,0 +1 @@
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service

View 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
View 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

View File

@@ -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

View File

@@ -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>

View File

@@ -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}"

View 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();
}
}

View File

@@ -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);

View 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);
}

View 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();
}
}

View File

@@ -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()
)
);
}

View 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;
}
}

View 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> {
}

View 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) {}
}

View File

@@ -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;

View 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");
}
}
}

View 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"
)
);
}
}

View 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");
}
}
}

View 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()
);

View 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()
);

View 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());
}
}