refactor(web): move website into turbo app workspace
This commit is contained in:
parent
2664804e91
commit
b4e626a868
66 changed files with 318 additions and 298 deletions
675
apps/web/src/lib/server/datasources/index.ts
Normal file
675
apps/web/src/lib/server/datasources/index.ts
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
import type {
|
||||
DashboardDocument,
|
||||
DashboardModule,
|
||||
MetricValue,
|
||||
ServiceEntry,
|
||||
ServiceGroup,
|
||||
Severity,
|
||||
StatusItem,
|
||||
StatusStrip,
|
||||
TelemetryCard,
|
||||
} from "$lib/model";
|
||||
|
||||
export interface DatasourceResolutionOptions {
|
||||
fetch?: DatasourceFetch;
|
||||
prometheusBaseUrl?: string;
|
||||
prometheusRangeSeconds?: number;
|
||||
prometheusStepSeconds?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function resolveDashboardDatasources(
|
||||
document: DashboardDocument,
|
||||
options: DatasourceResolutionOptions = {},
|
||||
): Promise<DashboardDocument> {
|
||||
const context = datasourceContext(options);
|
||||
const telemetry = await Promise.all(
|
||||
document.telemetry.map((card) => resolveTelemetryCard(card, context)),
|
||||
);
|
||||
const serviceGroups = await Promise.all(
|
||||
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
||||
);
|
||||
const modules = await resolveModules(document.modules || [], serviceGroups, context);
|
||||
const statusStrips = await resolveStatusStrips(
|
||||
document.statusStrips,
|
||||
document.metadata.refreshIntervalSeconds,
|
||||
serviceGroups,
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
...structuredClone(document),
|
||||
telemetry,
|
||||
serviceGroups,
|
||||
modules,
|
||||
statusStrips,
|
||||
};
|
||||
}
|
||||
|
||||
interface DatasourceContext {
|
||||
fetch: DatasourceFetch;
|
||||
prometheusBaseUrl: string;
|
||||
prometheusRangeSeconds: number;
|
||||
prometheusStepSeconds: number;
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
type DatasourceFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface PrometheusVectorResult {
|
||||
metric?: Record<string, string>;
|
||||
value?: [number, string];
|
||||
}
|
||||
|
||||
interface PrometheusMatrixResult {
|
||||
metric?: Record<string, string>;
|
||||
values?: Array<[number, string]>;
|
||||
}
|
||||
|
||||
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
||||
return {
|
||||
fetch: options.fetch || globalThis.fetch,
|
||||
prometheusBaseUrl:
|
||||
options.prometheusBaseUrl ||
|
||||
process.env.PROMETHEUS_BASE_URL ||
|
||||
"https://prometheus.dimensionlab.net",
|
||||
prometheusRangeSeconds: options.prometheusRangeSeconds || 60 * 60,
|
||||
prometheusStepSeconds: options.prometheusStepSeconds || 120,
|
||||
requestTimeoutMs: options.requestTimeoutMs || 2_500,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTelemetryCard(
|
||||
card: TelemetryCard,
|
||||
context: DatasourceContext,
|
||||
): Promise<TelemetryCard> {
|
||||
if (card.datasource?.type !== "external" || card.datasource.adapter !== "prometheus") {
|
||||
return structuredClone(card);
|
||||
}
|
||||
|
||||
try {
|
||||
const [instant, range] = await Promise.all([
|
||||
prometheusQuery(card.datasource.reference, context),
|
||||
prometheusRangeQuery(card.datasource.reference, context),
|
||||
]);
|
||||
const current = pickMaxVectorResult(instant);
|
||||
const currentValue = Number(current?.value?.[1]);
|
||||
const sparkline = matrixPoints(range);
|
||||
|
||||
if (!Number.isFinite(currentValue)) return markStale(card, "no telemetry data");
|
||||
|
||||
return {
|
||||
...structuredClone(card),
|
||||
value: metricValueWithLiveNumber(card.value, currentValue),
|
||||
severity: severityForValue(currentValue, card.thresholds),
|
||||
detail: prometheusMetricDetail(current?.metric || {}, card.detail),
|
||||
sparkline: sparkline.length ? sparkline : card.sparkline,
|
||||
};
|
||||
} catch {
|
||||
return markStale(card, card.detail || "telemetry unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServiceGroup(
|
||||
group: ServiceGroup,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceGroup> {
|
||||
return {
|
||||
...structuredClone(group),
|
||||
services: await Promise.all(
|
||||
group.services.map((service) => resolveService(service, context)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveService(
|
||||
service: ServiceEntry,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
const datasource = service.datasource;
|
||||
if (datasource?.type !== "external") return structuredClone(service);
|
||||
|
||||
if (datasource.adapter === "http-status") {
|
||||
return resolveHttpStatusService(service, datasource.reference, context);
|
||||
}
|
||||
|
||||
if (datasource.adapter === "prometheus") {
|
||||
return resolvePrometheusService(service, datasource.reference, context);
|
||||
}
|
||||
|
||||
return structuredClone(service);
|
||||
}
|
||||
|
||||
async function resolveHttpStatusService(
|
||||
service: ServiceEntry,
|
||||
reference: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
const request = parseHttpStatusReference(reference);
|
||||
if (!request) return structuredClone(service);
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const response = await fetchWithTimeout(
|
||||
context.fetch,
|
||||
request.url,
|
||||
{
|
||||
cache: "no-store",
|
||||
method: request.method,
|
||||
},
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
const elapsedMs = Math.max(0, Math.round(Date.now() - startedAt));
|
||||
const badge = await uptimeBadge(response);
|
||||
if (badge) {
|
||||
const ping = badge.ping;
|
||||
const status = badge.status || "UNKNOWN";
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: uptimeBadgeSeverity(status),
|
||||
detail: Number.isFinite(ping)
|
||||
? `${Math.round(ping as number)} ms`
|
||||
: status.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: response.ok ? "ok" : response.status >= 500 ? "danger" : "warning",
|
||||
detail: response.ok ? `${elapsedMs} ms` : `HTTP ${response.status}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: "unavailable",
|
||||
detail: "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePrometheusService(
|
||||
service: ServiceEntry,
|
||||
reference: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
try {
|
||||
const result = pickMaxVectorResult(await prometheusQuery(reference, context));
|
||||
const value = Number(result?.value?.[1]);
|
||||
const ok = Number.isFinite(value) && value > 0;
|
||||
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: ok ? "ok" : "unavailable",
|
||||
detail: ok ? "up" : "down",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: "unavailable",
|
||||
detail: "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveModules(
|
||||
modules: DashboardModule[],
|
||||
serviceGroups: ServiceGroup[],
|
||||
context: DatasourceContext,
|
||||
): Promise<DashboardModule[]> {
|
||||
const resolved = await Promise.all(
|
||||
modules.map((module) => resolveModule(module, context)),
|
||||
);
|
||||
return resolved.map((module) =>
|
||||
module.id === "runtime-health-summary"
|
||||
? runtimeHealthSummary(module, serviceGroups)
|
||||
: module,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveModule(
|
||||
module: DashboardModule,
|
||||
context: DatasourceContext,
|
||||
): Promise<DashboardModule> {
|
||||
if (module.datasource?.type !== "external" || module.datasource.adapter !== "weather") {
|
||||
return structuredClone(module);
|
||||
}
|
||||
|
||||
try {
|
||||
const weather = await requestJson<OpenMeteoResponse>(
|
||||
context.fetch,
|
||||
openMeteoUrl(module.datasource.reference),
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
const current = weather.current;
|
||||
const temperature = Number(current?.temperature_2m);
|
||||
if (!Number.isFinite(temperature)) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
severity: "stale",
|
||||
detail: "weather unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
const apparent = Number(current?.apparent_temperature);
|
||||
const wind = Number(current?.wind_speed_10m);
|
||||
const condition = weatherCondition(Number(current?.weather_code));
|
||||
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${temperature.toFixed(1)} C`,
|
||||
detail: [
|
||||
condition,
|
||||
Number.isFinite(apparent) ? `feels ${apparent.toFixed(1)} C` : "",
|
||||
Number.isFinite(wind) ? `wind ${Math.round(wind)} km/h` : "",
|
||||
].filter(Boolean).join(" - "),
|
||||
severity: "ok",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
severity: "stale",
|
||||
detail: "weather unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeHealthSummary(
|
||||
module: DashboardModule,
|
||||
serviceGroups: ServiceGroup[],
|
||||
): DashboardModule {
|
||||
const services = serviceGroups.flatMap((group) => group.services);
|
||||
const down = services.filter((service) =>
|
||||
service.severity === "danger" || service.severity === "unavailable"
|
||||
).length;
|
||||
const warning = services.filter((service) => service.severity === "warning").length;
|
||||
const ok = services.filter((service) => service.severity === "ok").length;
|
||||
|
||||
if (down > 0) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${down} service${down === 1 ? "" : "s"} down`,
|
||||
detail: `${warning} warning${warning === 1 ? "" : "s"} - ${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "danger",
|
||||
};
|
||||
}
|
||||
|
||||
if (warning > 0) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
|
||||
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "warning",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: "all systems operational",
|
||||
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveStatusStrips(
|
||||
strips: StatusStrip[],
|
||||
refreshIntervalSeconds: number | undefined,
|
||||
serviceGroups: ServiceGroup[],
|
||||
context: DatasourceContext,
|
||||
): Promise<StatusStrip[]> {
|
||||
const [uptime, loadAverage] = await Promise.all([
|
||||
prometheusScalar(
|
||||
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
|
||||
context,
|
||||
).catch(() => null),
|
||||
prometheusLoadAverage(context).catch(() => null),
|
||||
]);
|
||||
const health = serviceHealthSummary(serviceGroups);
|
||||
|
||||
return strips.map((strip) => ({
|
||||
...structuredClone(strip),
|
||||
items: strip.items.map((item) =>
|
||||
resolveStatusItem(item, {
|
||||
health,
|
||||
loadAverage,
|
||||
refreshIntervalSeconds,
|
||||
uptime,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveStatusItem(
|
||||
item: StatusItem,
|
||||
values: {
|
||||
health: { severity: Severity; value: string };
|
||||
loadAverage: string | null;
|
||||
refreshIntervalSeconds?: number;
|
||||
uptime: number | null;
|
||||
},
|
||||
): StatusItem {
|
||||
if (item.id === "system-status") {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: values.health.value,
|
||||
severity: values.health.severity,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "last-sync") {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: "just now",
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "uptime" && values.uptime !== null) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: formatDuration(values.uptime),
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "load-avg" && values.loadAverage) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: values.loadAverage,
|
||||
severity: "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "auto-refresh" && values.refreshIntervalSeconds) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: `${values.refreshIntervalSeconds}s`,
|
||||
severity: "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
return structuredClone(item);
|
||||
}
|
||||
|
||||
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
|
||||
severity: Severity;
|
||||
value: string;
|
||||
} {
|
||||
const services = serviceGroups.flatMap((group) => group.services);
|
||||
const down = services.filter((service) =>
|
||||
service.severity === "danger" || service.severity === "unavailable"
|
||||
).length;
|
||||
const warning = services.filter((service) => service.severity === "warning").length;
|
||||
|
||||
if (down > 0) {
|
||||
return {
|
||||
severity: "danger",
|
||||
value: `${down} service${down === 1 ? "" : "s"} down`,
|
||||
};
|
||||
}
|
||||
|
||||
if (warning > 0) {
|
||||
return {
|
||||
severity: "warning",
|
||||
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
severity: "ok",
|
||||
value: "All systems operational",
|
||||
};
|
||||
}
|
||||
|
||||
async function prometheusQuery(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<PrometheusVectorResult[]> {
|
||||
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query", {
|
||||
query,
|
||||
});
|
||||
const payload = await requestJson<PrometheusResponse<PrometheusVectorResult>>(
|
||||
context.fetch,
|
||||
url,
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
return payload.status === "success" ? payload.data.result || [] : [];
|
||||
}
|
||||
|
||||
async function prometheusRangeQuery(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<PrometheusMatrixResult[]> {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - context.prometheusRangeSeconds;
|
||||
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query_range", {
|
||||
query,
|
||||
start: String(start),
|
||||
end: String(end),
|
||||
step: String(context.prometheusStepSeconds),
|
||||
});
|
||||
const payload = await requestJson<PrometheusResponse<PrometheusMatrixResult>>(
|
||||
context.fetch,
|
||||
url,
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
return payload.status === "success" ? payload.data.result || [] : [];
|
||||
}
|
||||
|
||||
async function prometheusScalar(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<number | null> {
|
||||
const result = pickMaxVectorResult(await prometheusQuery(query, context));
|
||||
const value = Number(result?.value?.[1]);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function prometheusLoadAverage(context: DatasourceContext): Promise<string | null> {
|
||||
const [one, five, fifteen] = await Promise.all([
|
||||
prometheusScalar('node_load1{job="node",host="linux-infra"}', context),
|
||||
prometheusScalar('node_load5{job="node",host="linux-infra"}', context),
|
||||
prometheusScalar('node_load15{job="node",host="linux-infra"}', context),
|
||||
]);
|
||||
|
||||
if (one === null || five === null || fifteen === null) return null;
|
||||
return [one, five, fifteen].map((value) => value.toFixed(2)).join(" ");
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
fetch: DatasourceFetch,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const response = await fetchWithTimeout(
|
||||
fetch,
|
||||
url,
|
||||
{ cache: "no-store" },
|
||||
timeoutMs,
|
||||
);
|
||||
if (!response.ok) throw new Error(`request failed: ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetch: DatasourceFetch,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function prometheusApiUrl(
|
||||
baseUrl: string,
|
||||
pathname: string,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
const url = new URL(pathname, ensureTrailingSlash(baseUrl));
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function pickMaxVectorResult(
|
||||
results: PrometheusVectorResult[],
|
||||
): PrometheusVectorResult | null {
|
||||
return results.reduce<PrometheusVectorResult | null>((winner, item) => {
|
||||
if (!winner) return item;
|
||||
return Number(item.value?.[1]) > Number(winner.value?.[1]) ? item : winner;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function matrixPoints(results: PrometheusMatrixResult[]): number[] {
|
||||
const byTimestamp = new Map<number, number>();
|
||||
|
||||
for (const series of results) {
|
||||
for (const [timestamp, value] of series.values || []) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) continue;
|
||||
const previous = byTimestamp.get(timestamp);
|
||||
if (previous === undefined || numeric > previous) {
|
||||
byTimestamp.set(timestamp, numeric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTimestamp.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
function metricValueWithLiveNumber(value: MetricValue, nextValue: number): MetricValue {
|
||||
if (value.kind === "text") return value;
|
||||
return {
|
||||
...value,
|
||||
value: value.kind === "percent" ? clamp(nextValue, 0, 100) : Math.max(0, nextValue),
|
||||
};
|
||||
}
|
||||
|
||||
function severityForValue(
|
||||
value: number,
|
||||
thresholds: TelemetryCard["thresholds"],
|
||||
): Severity {
|
||||
if (thresholds?.danger !== undefined && value >= thresholds.danger) return "danger";
|
||||
if (thresholds?.warning !== undefined && value >= thresholds.warning) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function prometheusMetricDetail(
|
||||
metric: Record<string, string>,
|
||||
fallback = "",
|
||||
): string {
|
||||
const host = metric.host || metric.instance || metric.job;
|
||||
const detail = metric.mountpoint || metric.name || metric.container || metric.id;
|
||||
const parts = [host, detail].filter(Boolean);
|
||||
if (parts.length) return parts.join(" ");
|
||||
return fallback.replace(/^fallback\s*-\s*/i, "") || "telemetry";
|
||||
}
|
||||
|
||||
function markStale(card: TelemetryCard, detail: string): TelemetryCard {
|
||||
return {
|
||||
...structuredClone(card),
|
||||
severity: "stale",
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHttpStatusReference(reference: string): { method: string; url: string } | null {
|
||||
const match = /^(GET|HEAD|POST)\s+(.+)$/i.exec(reference.trim());
|
||||
if (!match) return null;
|
||||
return { method: match[1].toUpperCase(), url: match[2] };
|
||||
}
|
||||
|
||||
interface PrometheusResponse<T> {
|
||||
status: string;
|
||||
data: {
|
||||
result?: T[];
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenMeteoResponse {
|
||||
current?: {
|
||||
apparent_temperature?: number;
|
||||
temperature_2m?: number;
|
||||
weather_code?: number;
|
||||
wind_speed_10m?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UptimeBadgeResponse {
|
||||
status?: string;
|
||||
ping?: number;
|
||||
}
|
||||
|
||||
async function uptimeBadge(response: Response): Promise<UptimeBadgeResponse | null> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("json")) return null;
|
||||
|
||||
try {
|
||||
const payload = await response.clone().json() as UptimeBadgeResponse;
|
||||
return typeof payload.status === "string" ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function uptimeBadgeSeverity(status = ""): Severity {
|
||||
const normalized = status.toUpperCase();
|
||||
if (normalized === "UP") return "ok";
|
||||
if (normalized === "PENDING" || normalized === "MAINTENANCE") return "warning";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
function openMeteoUrl(reference: string): string {
|
||||
const params = new URLSearchParams();
|
||||
const serialized = reference.startsWith("open-meteo:")
|
||||
? reference.slice("open-meteo:".length)
|
||||
: reference;
|
||||
|
||||
new URLSearchParams(serialized).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
params.set(
|
||||
"current",
|
||||
"temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
|
||||
);
|
||||
params.set("timezone", "Europe/Amsterdam");
|
||||
|
||||
return `https://api.open-meteo.com/v1/forecast?${params.toString()}`;
|
||||
}
|
||||
|
||||
function weatherCondition(code: number): string {
|
||||
if (code === 0) return "Clear";
|
||||
if ([1, 2].includes(code)) return "Partly cloudy";
|
||||
if (code === 3) return "Cloudy";
|
||||
if ([45, 48].includes(code)) return "Fog";
|
||||
if (code >= 51 && code <= 67) return "Rain";
|
||||
if (code >= 71 && code <= 77) return "Snow";
|
||||
if (code >= 80 && code <= 82) return "Showers";
|
||||
if (code >= 95) return "Thunderstorm";
|
||||
return "Mixed";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue