2026-07-23 12:19:43 +07:00
|
|
|
import http from "node:http";
|
2026-07-23 17:05:08 +07:00
|
|
|
import { NextResponse } from "next/server";
|
2026-07-23 06:55:25 +07:00
|
|
|
|
2026-07-23 12:19:43 +07:00
|
|
|
const JAEGER = "http://jaeger:16686";
|
|
|
|
|
const PROMETHEUS = "http://prometheus:9090";
|
|
|
|
|
const DOCKER_SOCK = "/var/run/docker.sock";
|
2026-07-23 06:55:25 +07:00
|
|
|
|
|
|
|
|
interface Container {
|
|
|
|
|
Names: string[];
|
|
|
|
|
State: string;
|
|
|
|
|
Labels: Record<string, string>;
|
|
|
|
|
NetworkSettings?: { Networks?: Record<string, unknown> };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Trace {
|
|
|
|
|
service: string;
|
|
|
|
|
operation: string;
|
|
|
|
|
duration: number;
|
|
|
|
|
spans: number;
|
|
|
|
|
hasError: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Service {
|
|
|
|
|
name: string;
|
|
|
|
|
state: string;
|
|
|
|
|
hasWeb: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dockerFetch(path: string): Promise<unknown> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-07-23 12:19:43 +07:00
|
|
|
http
|
|
|
|
|
.get({ socketPath: DOCKER_SOCK, path }, (res) => {
|
|
|
|
|
let data = "";
|
2026-07-23 17:05:08 +07:00
|
|
|
res.on("data", (c: string) => {
|
|
|
|
|
data += c;
|
|
|
|
|
});
|
2026-07-23 12:19:43 +07:00
|
|
|
res.on("end", () => {
|
|
|
|
|
try {
|
|
|
|
|
resolve(JSON.parse(data));
|
|
|
|
|
} catch {
|
|
|
|
|
resolve(null);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.on("error", reject);
|
2026-07-23 06:55:25 +07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchJSON(url: string): Promise<unknown> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
|
|
|
return res.json();
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseServices(containers: Container[]): Service[] {
|
2026-07-23 12:19:43 +07:00
|
|
|
const project = containers.find((c) => c.Labels["com.docker.compose.project"])
|
|
|
|
|
?.Labels["com.docker.compose.project"];
|
2026-07-23 06:55:25 +07:00
|
|
|
|
|
|
|
|
return containers
|
|
|
|
|
.filter((c) => {
|
2026-07-23 12:19:43 +07:00
|
|
|
if (!c.NetworkSettings?.Networks?.["app-shared-net"]) return false;
|
|
|
|
|
if (project && c.Labels["com.docker.compose.project"] !== project)
|
|
|
|
|
return false;
|
2026-07-23 06:55:25 +07:00
|
|
|
return true;
|
|
|
|
|
})
|
|
|
|
|
.map((c) => ({
|
2026-07-23 12:19:43 +07:00
|
|
|
name: c.Names[0].replace(/^\//, ""),
|
2026-07-23 06:55:25 +07:00
|
|
|
state: c.State,
|
|
|
|
|
hasWeb: Object.keys(c.Labels).some(
|
2026-07-23 12:19:43 +07:00
|
|
|
(k) => k.startsWith("traefik.http.routers.") && k.endsWith(".rule"),
|
2026-07-23 06:55:25 +07:00
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchTraces(): Promise<Trace[]> {
|
|
|
|
|
const svcRes = await fetchJSON(`${JAEGER}/api/services`);
|
2026-07-23 12:19:43 +07:00
|
|
|
if (!svcRes || !Array.isArray((svcRes as { data?: string[] }).data))
|
|
|
|
|
return [];
|
2026-07-23 06:55:25 +07:00
|
|
|
|
|
|
|
|
const now = Date.now() * 1000;
|
|
|
|
|
const start = now - 5 * 60 * 1_000_000;
|
|
|
|
|
const all: Trace[] = [];
|
|
|
|
|
|
|
|
|
|
for (const service of (svcRes as { data: string[] }).data) {
|
|
|
|
|
const d = await fetchJSON(
|
|
|
|
|
`${JAEGER}/api/traces?service=${encodeURIComponent(service)}&start=${start}&end=${now}&limit=5&lookback=5m`,
|
|
|
|
|
);
|
|
|
|
|
if (!d || !Array.isArray((d as { data?: unknown[] }).data)) continue;
|
|
|
|
|
|
2026-07-23 12:19:43 +07:00
|
|
|
for (const t of (
|
|
|
|
|
d as {
|
|
|
|
|
data: {
|
|
|
|
|
duration: number;
|
|
|
|
|
spans: {
|
|
|
|
|
operationName: string;
|
|
|
|
|
processID: string;
|
|
|
|
|
tags: { key: string; value: unknown }[];
|
|
|
|
|
}[];
|
|
|
|
|
processes: Record<string, { serviceName: string }>;
|
|
|
|
|
}[];
|
|
|
|
|
}
|
|
|
|
|
).data) {
|
2026-07-23 06:55:25 +07:00
|
|
|
if (!t.spans?.length) continue;
|
|
|
|
|
const span = t.spans[0];
|
2026-07-23 12:19:43 +07:00
|
|
|
const svc = t.processes[span.processID]?.serviceName || "unknown";
|
2026-07-23 06:55:25 +07:00
|
|
|
const hasError = t.spans.some((s) =>
|
2026-07-23 12:19:43 +07:00
|
|
|
s.tags?.some((tag) => tag.key === "error" && tag.value === true),
|
2026-07-23 06:55:25 +07:00
|
|
|
);
|
|
|
|
|
all.push({
|
|
|
|
|
service: svc,
|
|
|
|
|
operation: span.operationName,
|
|
|
|
|
duration: t.duration,
|
|
|
|
|
spans: t.spans.length,
|
|
|
|
|
hasError,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return all.sort((a, b) => b.duration - a.duration).slice(0, 20);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 07:44:52 +07:00
|
|
|
async function promRange(query: string, steps = 20): Promise<number[]> {
|
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
const u = `${PROMETHEUS}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${now - 300}&end=${now}&step=${(300 / steps).toFixed(0)}`;
|
|
|
|
|
const d = await fetchJSON(u);
|
2026-07-23 12:19:43 +07:00
|
|
|
const result = (d as { data?: { result?: { values?: unknown[][] }[] } })?.data
|
|
|
|
|
?.result;
|
2026-07-23 07:52:18 +07:00
|
|
|
if (!result?.[0]?.values) return [];
|
2026-07-23 12:19:43 +07:00
|
|
|
return result[0].values.map((v) => {
|
|
|
|
|
const f = parseFloat(v[1] as string);
|
2026-07-23 17:05:08 +07:00
|
|
|
return Number.isNaN(f) ? 0 : f;
|
2026-07-23 12:19:43 +07:00
|
|
|
});
|
2026-07-23 07:44:52 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-23 06:55:25 +07:00
|
|
|
async function promQuery(query: string): Promise<number | null> {
|
|
|
|
|
const d = await fetchJSON(
|
|
|
|
|
`${PROMETHEUS}/api/v1/query?query=${encodeURIComponent(query)}`,
|
|
|
|
|
);
|
2026-07-23 12:19:43 +07:00
|
|
|
const result = (d as { data?: { result?: { value?: unknown[] }[] } })?.data
|
|
|
|
|
?.result;
|
2026-07-23 06:55:25 +07:00
|
|
|
if (!result?.length) return null;
|
|
|
|
|
const val = result[0].value?.[1];
|
|
|
|
|
return val ? parseFloat(val as string) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DashboardData {
|
|
|
|
|
services: Service[];
|
|
|
|
|
traces: Trace[];
|
|
|
|
|
node: {
|
|
|
|
|
cpu: number | null;
|
|
|
|
|
ram: number | null;
|
|
|
|
|
disk: number | null;
|
|
|
|
|
load1: number | null;
|
|
|
|
|
load5: number | null;
|
|
|
|
|
load15: number | null;
|
|
|
|
|
netIn: number | null;
|
|
|
|
|
netOut: number | null;
|
|
|
|
|
};
|
2026-07-23 07:44:52 +07:00
|
|
|
rps: number[];
|
|
|
|
|
latency: number[];
|
|
|
|
|
errors: number[];
|
|
|
|
|
traceVolume: number[];
|
2026-07-23 06:55:25 +07:00
|
|
|
links: { url: string; label: string }[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function GET() {
|
|
|
|
|
const [containersRaw] = await Promise.all([
|
2026-07-23 12:19:43 +07:00
|
|
|
dockerFetch("/containers/json?all=true") as Promise<Container[]>,
|
2026-07-23 06:55:25 +07:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const containers = Array.isArray(containersRaw) ? containersRaw : [];
|
|
|
|
|
const services = parseServices(containers);
|
|
|
|
|
|
2026-07-23 12:19:43 +07:00
|
|
|
const [
|
|
|
|
|
traces,
|
|
|
|
|
cpu,
|
|
|
|
|
ram,
|
|
|
|
|
disk,
|
|
|
|
|
load1,
|
|
|
|
|
load5,
|
|
|
|
|
load15,
|
|
|
|
|
netIn,
|
|
|
|
|
netOut,
|
|
|
|
|
rps,
|
|
|
|
|
latency,
|
|
|
|
|
errors,
|
|
|
|
|
] = await Promise.all([
|
|
|
|
|
fetchTraces(),
|
|
|
|
|
promQuery(
|
|
|
|
|
`100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)`,
|
|
|
|
|
),
|
|
|
|
|
promQuery(
|
|
|
|
|
`(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100`,
|
|
|
|
|
),
|
|
|
|
|
promQuery(
|
|
|
|
|
`(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100`,
|
|
|
|
|
),
|
|
|
|
|
promQuery("node_load1"),
|
|
|
|
|
promQuery("node_load5"),
|
|
|
|
|
promQuery("node_load15"),
|
|
|
|
|
promQuery(`rate(node_network_receive_bytes_total{device!="lo"}[1m])`),
|
|
|
|
|
promQuery(`rate(node_network_transmit_bytes_total{device!="lo"}[1m])`),
|
|
|
|
|
promRange("sum(rate(traefik_service_requests_total[1m]))"),
|
|
|
|
|
promRange(
|
|
|
|
|
"avg(traefik_service_request_duration_seconds_sum / traefik_service_request_duration_seconds_count) * 1000",
|
|
|
|
|
),
|
|
|
|
|
promRange('sum(rate(traefik_service_requests_total{code=~"5.."}[1m]))'),
|
|
|
|
|
]);
|
2026-07-23 06:55:25 +07:00
|
|
|
|
|
|
|
|
const links: { url: string; label: string }[] = [
|
2026-07-23 12:19:43 +07:00
|
|
|
{ url: "/jaeger", label: "Jaeger UI" },
|
2026-07-23 06:55:25 +07:00
|
|
|
];
|
2026-07-23 12:19:43 +07:00
|
|
|
if (containers.some((c) => c.Names?.some((n) => n.includes("prometheus")))) {
|
|
|
|
|
links.push({ url: "/api/prometheus/targets", label: "Prometheus" });
|
2026-07-23 06:55:25 +07:00
|
|
|
}
|
2026-07-23 12:19:43 +07:00
|
|
|
links.push({
|
|
|
|
|
url: "https://github.com/asepharyana/asepharyana-hub",
|
|
|
|
|
label: "GitHub",
|
|
|
|
|
});
|
2026-07-23 06:55:25 +07:00
|
|
|
|
2026-07-23 12:19:43 +07:00
|
|
|
const domains = ["asepharyana.my.id", "asepharyana.web.id"];
|
2026-07-23 06:55:25 +07:00
|
|
|
for (const s of services) {
|
2026-07-23 12:19:43 +07:00
|
|
|
if (s.hasWeb && s.state === "running") {
|
2026-07-23 06:55:25 +07:00
|
|
|
links.push({ url: `https://${s.name}.${domains[0]}`, label: s.name });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 07:44:52 +07:00
|
|
|
const traceVolume = [traces.length];
|
|
|
|
|
|
2026-07-23 06:55:25 +07:00
|
|
|
const data: DashboardData = {
|
|
|
|
|
services,
|
|
|
|
|
traces,
|
|
|
|
|
node: { cpu, ram, disk, load1, load5, load15, netIn, netOut },
|
2026-07-23 12:19:43 +07:00
|
|
|
rps,
|
|
|
|
|
latency,
|
|
|
|
|
errors,
|
|
|
|
|
traceVolume,
|
2026-07-23 06:55:25 +07:00
|
|
|
links,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return NextResponse.json(data);
|
|
|
|
|
}
|