optimized events
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s

This commit is contained in:
BuTzZ
2026-07-28 14:36:47 +02:00
parent b98ec8bc6f
commit 593dbd5f85
42 changed files with 2405 additions and 283 deletions
+43 -5
View File
@@ -14,12 +14,15 @@ import {
} from "@watermaps/shared";
import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js";
import { appConfig } from "./services/config.js";
import { buildAppConfig } from "./services/config.js";
import {
FairwayService,
type FairwayGraphLookup
} from "./services/fairways.js";
import { FeatureService } from "./services/features.js";
import {
FeatureService,
FeatureSourceUnavailableError
} from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js";
import type { FetchLike } from "./services/http.js";
@@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
await fairwayService.close?.();
});
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
app.get("/health", async () => ({
ok: true,
service: "watermaps-api",
featureSource: featureService.sourceMode
}));
app.get("/api/config", async () => appConfig);
app.get("/ready", async (_request, reply) => {
const featureSource = await featureService.checkReadiness();
const body = {
ok: featureSource.ready,
service: "watermaps-api",
checks: {
features: featureSource
}
};
return featureSource.ready
? body
: reply.code(503).send(body);
});
app.get("/api/config", async () => {
const featureSource = await featureService.checkReadiness();
return buildAppConfig({
featureSource: featureSource.ready ? featureSource.source : "unavailable",
liveFairwayExtraction: env.liveOsmFairways
});
});
app.get("/api/weather/marine", async (request, reply) => {
const parsed = coordinateQuerySchema.safeParse(request.query);
@@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
return featureService.getFeatures(parsed.data);
try {
return await featureService.getFeatures(parsed.data);
} catch (error) {
if (error instanceof FeatureSourceUnavailableError) {
app.log.warn({ error }, "feature source unavailable");
return reply.code(503).send({
error: error.code,
message: error.message
});
}
throw error;
}
});
app.post("/api/routes", async (request, reply) => {
+7 -2
View File
@@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
return {
port: Number(env.PORT ?? 5174),
host: env.HOST ?? "0.0.0.0",
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
databaseUrl: nonEmptyString(env.DATABASE_URL),
redisUrl: nonEmptyString(env.REDIS_URL),
localFairwaysPath:
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
@@ -26,3 +26,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
env.NODE_ENV !== "test"
};
}
function nonEmptyString(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
+75 -64
View File
@@ -1,67 +1,78 @@
import type { AppConfig } from "@watermaps/shared";
import type { FeatureSourceMode } from "./features.js";
export const appConfig: AppConfig = {
appName: "Watermaps",
region: "Deutschland/EU",
disclaimer:
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
featureFlags: {
gpsTracking: true,
compass: true,
marineWeather: true,
tides: true,
manualRouting: true,
liveFairwayExtraction: true,
postgisFeatures: true,
bridgeAndDepthOverlays: true,
inlandWaterwayRouting: true,
routeAlternatives: true,
lockAndHarbourContacts: true,
routeWaypoints: true,
departureTimeForecasts: true,
liveWaterLevels: true,
voyageStages: true,
gpxExport: true,
offlineRoutes: true,
offlineVisitedMapResources: true,
routeDeviationAlarm: true,
offlineTiles: true
},
layers: [
{
id: "openfreemap",
name: "Basiskarte",
kind: "style",
url: "https://tiles.openfreemap.org/styles/bright",
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
defaultVisible: true
},
{
id: "openseamap-seamarks",
name: "Seezeichen",
kind: "raster-tile",
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
defaultVisible: true,
opacity: 0.95
},
{
id: "emodnet-bathymetry",
name: "Bathymetrie",
kind: "wms",
tileUrl:
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
attribution: "Bathymetry © EMODnet Bathymetry",
defaultVisible: false,
opacity: 0.55
}
],
attribution: [
"© OpenStreetMap contributors",
"© OpenSeaMap contributors",
"© EMODnet Bathymetry",
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
"Weather, waves and current forecast © Open-Meteo"
]
type AppConfigCapabilities = {
featureSource: FeatureSourceMode;
liveFairwayExtraction: boolean;
};
export function buildAppConfig(capabilities: AppConfigCapabilities): AppConfig {
const postgisFeatures = capabilities.featureSource === "postgis";
return {
appName: "Watermaps",
region: "Deutschland/EU",
disclaimer:
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
featureFlags: {
gpsTracking: true,
compass: true,
marineWeather: true,
tides: true,
manualRouting: true,
liveFairwayExtraction: capabilities.liveFairwayExtraction,
postgisFeatures,
bridgeAndDepthOverlays: postgisFeatures,
inlandWaterwayRouting: true,
routeAlternatives: true,
lockAndHarbourContacts: postgisFeatures,
routeEvents: postgisFeatures,
routeWaypoints: true,
departureTimeForecasts: true,
liveWaterLevels: true,
voyageStages: true,
gpxExport: true,
offlineRoutes: true,
offlineVisitedMapResources: true,
routeDeviationAlarm: true,
offlineTiles: true
},
layers: [
{
id: "openfreemap",
name: "Basiskarte",
kind: "style",
url: "https://tiles.openfreemap.org/styles/bright",
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
defaultVisible: true
},
{
id: "openseamap-seamarks",
name: "Seezeichen",
kind: "raster-tile",
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
defaultVisible: true,
opacity: 0.95
},
{
id: "emodnet-bathymetry",
name: "Bathymetrie",
kind: "wms",
tileUrl:
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
attribution: "Bathymetry © EMODnet Bathymetry",
defaultVisible: false,
opacity: 0.55
}
],
attribution: [
"© OpenStreetMap contributors",
"© OpenSeaMap contributors",
"© EMODnet Bathymetry",
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
"Weather, waves and current forecast © Open-Meteo"
]
};
}
+131 -28
View File
@@ -11,6 +11,21 @@ export type FeatureQuery = {
layers: string[];
};
export type FeatureSourceMode = "postgis" | "demo" | "unavailable";
export type FeatureSourceReadiness = {
ready: boolean;
source: FeatureSourceMode;
detail?:
| "not_configured"
| "database_unreachable"
| "schema_missing"
| "data_missing";
missingLayers?: Array<"harbours" | "locks" | "bridges">;
};
export type FeatureDatabase = Pick<pg.Pool, "query" | "end">;
type FeatureCollection = {
type: "FeatureCollection";
features: Array<{
@@ -20,7 +35,7 @@ type FeatureCollection = {
properties: Record<string, unknown>;
}>;
metadata: {
source: "postgis" | "demo" | "unavailable";
source: Exclude<FeatureSourceMode, "unavailable">;
warning?: string;
deduplication?: {
inputPoiCount: number;
@@ -30,6 +45,15 @@ type FeatureCollection = {
};
};
export class FeatureSourceUnavailableError extends Error {
readonly code = "feature_source_unavailable";
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "FeatureSourceUnavailableError";
}
}
const demoFeatures = [
{
type: "Feature" as const,
@@ -65,29 +89,110 @@ const demoFeatures = [
];
export class FeatureService {
private pool: pg.Pool | null;
private pool: FeatureDatabase | null;
private demoData: boolean;
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) {
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
constructor(
env: Pick<ApiEnv, "databaseUrl" | "demoData">,
dependencies: { pool?: FeatureDatabase } = {}
) {
this.pool =
env.databaseUrl && !env.demoData
? dependencies.pool ??
new pg.Pool({
connectionString: env.databaseUrl,
connectionTimeoutMillis: 3_000,
query_timeout: 3_000
})
: null;
this.demoData = env.demoData;
}
get sourceMode(): FeatureSourceMode {
if (this.demoData) {
return "demo";
}
return this.pool ? "postgis" : "unavailable";
}
async checkReadiness(): Promise<FeatureSourceReadiness> {
if (this.demoData) {
return { ready: true, source: "demo" };
}
if (!this.pool) {
return {
ready: false,
source: "unavailable",
detail: "not_configured"
};
}
try {
const result = await this.pool.query<{
marine_features: string | null;
marine_fairway_edges: string | null;
}>(`
SELECT
to_regclass('public.marine_features')::text AS marine_features,
to_regclass('public.marine_fairway_edges')::text AS marine_fairway_edges
`);
const schema = result.rows[0];
if (!schema?.marine_features || !schema.marine_fairway_edges) {
return {
ready: false,
source: "postgis",
detail: "schema_missing"
};
}
const dataResult = await this.pool.query<{
harbours: boolean;
locks: boolean;
bridges: boolean;
}>(`
SELECT
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'harbours') AS harbours,
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'locks') AS locks,
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'bridges') AS bridges
`);
const data = dataResult.rows[0];
const missingLayers = (["harbours", "locks", "bridges"] as const).filter(
(layer) => !data?.[layer]
);
if (missingLayers.length > 0) {
return {
ready: false,
source: "postgis",
detail: "data_missing",
missingLayers
};
}
return { ready: true, source: "postgis" };
} catch {
return {
ready: false,
source: "postgis",
detail: "database_unreachable"
};
}
}
async getFeatures(query: FeatureQuery): Promise<FeatureCollection> {
if (this.pool && !this.demoData) {
return this.getPostgisFeatures(query);
try {
return await this.getPostgisFeatures(query);
} catch (error) {
throw new FeatureSourceUnavailableError(
"Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar.",
{ cause: error }
);
}
}
if (!this.demoData) {
return {
type: "FeatureCollection",
features: [],
metadata: {
source: "unavailable",
warning:
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
}
};
throw new FeatureSourceUnavailableError(
"Keine lokale Karten- und Ereignisdatenbank konfiguriert."
);
}
const [minLon, minLat, maxLon, maxLat] = query.bbox;
@@ -151,16 +256,6 @@ export class FeatureService {
FROM marine_features
WHERE layer = ANY($1::text[])
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
AND (
layer <> 'bridges'
OR properties ? 'seamark:bridge:clearance_height'
OR properties ? 'seamark:bridge:clearance_height_safe'
OR properties ? 'maxheight'
OR properties ? 'maxheight:physical'
OR lower(COALESCE(properties->>'bridge', '')) = ANY(
ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening']
)
)
)
SELECT
id::text,
@@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI
"seamark:lock_basin:communication_channel",
"seamark:radio_station:channel"
]);
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
const operator = firstStringProperty(sourceProperties, ["operator"]);
const openingHours = firstStringProperty(sourceProperties, [
"opening_hours",
"lock:opening_hours",
"service_times"
]);
const operator = firstStringProperty(sourceProperties, [
"operator",
"operator:name",
"owner"
]);
const address = normalizedAddress(sourceProperties);
const sourceUrl = firstStringProperty(sourceProperties, [
"sourceUrl",
@@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([
"lock",
"obstacle",
"bridge",
"bridge:movable",
"maxheight",
"maxheight:physical",
"height",
@@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record<string, unknown>) {
"seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe",
"maxheight",
"maxheight:physical",
"height"
"maxheight:physical"
];
for (const key of candidates) {