Initial commit
parent
1bddfbe2aa
commit
ffb617d4ff
@ -0,0 +1,2 @@
|
||||
build/*
|
||||
config.json
|
@ -0,0 +1,70 @@
|
||||
import { TypeOf } from "../common/deps/zod.ts";
|
||||
import { makeServe, MakeHandlers, } from "../common/deps/yaypi.ts";
|
||||
|
||||
import { api, GetConfigurationResponseV1, StatusV1 } from "../common/api.ts";
|
||||
import { load as loadConfiguration, Configuration } from "./config.ts";
|
||||
|
||||
const handlers: MakeHandlers<typeof api> = {
|
||||
v1: {
|
||||
configuration: {
|
||||
get: async () => {
|
||||
const config = await loadConfiguration();
|
||||
|
||||
const res: GetConfigurationResponseV1 = {
|
||||
hosts: Object.keys(config.hosts),
|
||||
services: Object.entries(config.services).map(([k, v]) => ({
|
||||
name: k,
|
||||
publicUrl: v.publicUrl,
|
||||
})),
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
},
|
||||
host: {
|
||||
getStatus: async (hostname) => {
|
||||
const config = await loadConfiguration();
|
||||
if (!(hostname in config.hosts)) {
|
||||
return { error: "unknown host" };
|
||||
}
|
||||
|
||||
const status = await Deno.run({
|
||||
cmd: ["ping", "-c1", "-W2", config.hosts[hostname]],
|
||||
stdout: "null",
|
||||
stdin: "null",
|
||||
stderr: "null",
|
||||
}).status();
|
||||
|
||||
if (!status.success) {
|
||||
return StatusV1.Down;
|
||||
}
|
||||
|
||||
return StatusV1.Up;
|
||||
}
|
||||
},
|
||||
service: {
|
||||
getStatus: async (serviceName) => {
|
||||
const config = await loadConfiguration();
|
||||
|
||||
return getServiceStatus(serviceName, config);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const serve = makeServe(api, handlers, "/api");
|
||||
|
||||
const getServiceStatus = async (serviceName: string, config: TypeOf<typeof Configuration>) => {
|
||||
const sc = config.services[serviceName];
|
||||
if (!sc) {
|
||||
return { error: "unknown service" };
|
||||
}
|
||||
|
||||
switch (sc.type) {
|
||||
case "http": {
|
||||
const status = await fetch(sc.url);
|
||||
|
||||
return status.ok ? StatusV1.Up : StatusV1.Down;
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
import { object, record, string, literal, number } from "../common/deps/zod.ts";
|
||||
|
||||
const HttpService = object({
|
||||
type: literal("http"),
|
||||
url: string(),
|
||||
publicUrl: string().optional(),
|
||||
});
|
||||
export const Configuration = object({
|
||||
hostname: string().optional(),
|
||||
port: number().optional(),
|
||||
hosts: record(string()),
|
||||
services: record(HttpService),
|
||||
});
|
||||
|
||||
export const load = async () => {
|
||||
const contents = await Deno.readTextFile("./config.json");
|
||||
const json = JSON.parse(contents);
|
||||
|
||||
const config = await Configuration.parseAsync(json);
|
||||
|
||||
return config;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from "https://deno.land/std@0.149.0/http/mod.ts";
|
@ -0,0 +1 @@
|
||||
export * from "https://git.idylls.net/idylls/shirt/raw/branch/main/mod.ts";
|
@ -0,0 +1,72 @@
|
||||
import { serve as serveHttp } from "./deps/http.ts";
|
||||
import {
|
||||
compressResponse,
|
||||
setHeaders,
|
||||
orElse,
|
||||
filter,
|
||||
catching,
|
||||
log,
|
||||
} from "./deps/shirt.ts";
|
||||
|
||||
import { frontend } from "../build/frontend.ts";
|
||||
import { style } from "../build/style.ts";
|
||||
import { serve } from "./api.ts";
|
||||
import { load as loadConfiguration } from "./config.ts";
|
||||
|
||||
export const notFound = (_req: Request) => {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const internalServerError = (_req: Request, e: unknown) => {
|
||||
console.error(e);
|
||||
return new Response("Something went wrong", { status: 500 });
|
||||
}
|
||||
|
||||
const apiHandler = filter(
|
||||
(req) => new URL(req.url).pathname.split("/")[1] === "api",
|
||||
setHeaders([["content-type", "application/json"]], serve),
|
||||
);
|
||||
|
||||
const root = (req: Request) => {
|
||||
if (!req.url.endsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Response(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="module">
|
||||
${frontend}
|
||||
</script>
|
||||
<style>
|
||||
${style}
|
||||
</style>
|
||||
<title>Statue</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to view this page.
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
`, {
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
};
|
||||
|
||||
const listener =
|
||||
log(compressResponse(catching(orElse(
|
||||
apiHandler,
|
||||
orElse(root, notFound),
|
||||
), internalServerError)));
|
||||
|
||||
const config = await loadConfiguration();
|
||||
const port = config.port ?? 8888;
|
||||
const host = config.hostname ?? "localhost";
|
||||
const server = serveHttp(listener, {
|
||||
hostname: host,
|
||||
port,
|
||||
});
|
||||
|
||||
await server;
|
@ -0,0 +1,12 @@
|
||||
import { fs, emit } from "./build_deps.ts";
|
||||
|
||||
export const build = async (sourceMap = false) => {
|
||||
const bundle = await emit.bundle("frontend/main.ts", {
|
||||
compilerOptions: {
|
||||
inlineSourceMap: sourceMap,
|
||||
},
|
||||
});
|
||||
await fs.ensureDir("./build");
|
||||
await Deno.writeTextFile("./build/frontend.ts", `export const frontend = ${JSON.stringify(bundle.code)};`);
|
||||
await Deno.writeTextFile("./build/style.ts", `export const style = ${JSON.stringify(await Deno.readTextFile("frontend/style.css"))}`)
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * as fs from "https://deno.land/std@0.149.0/fs/mod.ts";
|
||||
export * as emit from "https://deno.land/x/emit@0.4.0/mod.ts";
|
@ -0,0 +1,35 @@
|
||||
import { object, array, string, TypeOf, nativeEnum, union } from "../common/deps/zod.ts";
|
||||
import { sig } from "../common/deps/yaypi.ts";
|
||||
|
||||
export const GetConfigurationResponseV1 = object({
|
||||
hosts: array(string()),
|
||||
services: array(object({
|
||||
name: string(),
|
||||
publicUrl: string().optional(),
|
||||
})),
|
||||
});
|
||||
export type GetConfigurationResponseV1 = TypeOf<typeof GetConfigurationResponseV1>;
|
||||
|
||||
export enum StatusV1 {
|
||||
Down = "down",
|
||||
Up = "up",
|
||||
}
|
||||
export const StatusResponseV1 = union([
|
||||
nativeEnum(StatusV1),
|
||||
object({ error: string() }),
|
||||
]);
|
||||
export type StatusResponseV1 = TypeOf<typeof StatusResponseV1>;
|
||||
|
||||
export const api = {
|
||||
v1: {
|
||||
configuration: {
|
||||
get: sig(undefined, GetConfigurationResponseV1),
|
||||
},
|
||||
host: {
|
||||
getStatus: sig(string(), StatusResponseV1),
|
||||
},
|
||||
service: {
|
||||
getStatus: sig(string(), StatusResponseV1),
|
||||
},
|
||||
},
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from "https://git.idylls.net/idylls/yaypi/raw/branch/main/mod.ts";
|
@ -0,0 +1 @@
|
||||
export * from "https://deno.land/x/zod@v3.17.10/mod.ts";
|
@ -0,0 +1,6 @@
|
||||
import { build } from "./build.ts";
|
||||
|
||||
await build(true);
|
||||
await Deno.run({
|
||||
cmd: ["deno", "run", "-A", "./backend/main.ts"],
|
||||
}).status();
|
@ -0,0 +1 @@
|
||||
export * from "https://git.idylls.net/idylls/swel/raw/branch/main/mod.ts";
|
@ -0,0 +1,88 @@
|
||||
import { makeClient, assert, ClientResponse, isHttpError, isValidationError } from "../common/deps/yaypi.ts";
|
||||
import { swel } from "./deps/swel.ts";
|
||||
|
||||
import { api, StatusResponseV1 } from "../common/api.ts";
|
||||
|
||||
const client = (() => {
|
||||
const l = window.location;
|
||||
const url = `${l.protocol}//${l.host}/api`;
|
||||
return makeClient(api, url);
|
||||
})();
|
||||
|
||||
const ServiceInfo = (
|
||||
name: string,
|
||||
getStatus: () => Promise<ClientResponse<StatusResponseV1>>,
|
||||
publicUrl?: string,
|
||||
) => {
|
||||
const nameEl = swel("div", { className: "name" }, name);
|
||||
const status = swel("div", { className: "status" }, "loading");
|
||||
const go = publicUrl ? swel("a", {
|
||||
className: "go",
|
||||
href: publicUrl,
|
||||
target: "_blank",
|
||||
on: { mouseenter: (e) => e.preventDefault() },
|
||||
}, "Go") : null;
|
||||
|
||||
const button = swel("button", {
|
||||
className: "status-button",
|
||||
tabIndex: 0,
|
||||
}, [nameEl, status]);
|
||||
|
||||
const refresh = async () => {
|
||||
button.removeEventListener("click", refresh);
|
||||
button.disabled = true;
|
||||
status.textContent = "loading";
|
||||
button.className = "status-button";
|
||||
|
||||
const st = await getStatus();
|
||||
if (isHttpError(st)) {
|
||||
status.textContent = "error";
|
||||
button.classList.add("error");
|
||||
} else if (isValidationError(st)) {
|
||||
status.textContent = st.error.toString();
|
||||
button.classList.add("error");
|
||||
} else if (typeof st === "object") {
|
||||
status.textContent = st.error;
|
||||
button.classList.add("error");
|
||||
} else {
|
||||
status.textContent = st;
|
||||
button.classList.add(st);
|
||||
}
|
||||
|
||||
button.addEventListener("click", refresh);
|
||||
button.disabled = false;
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
return swel("div", { className: "status-container", }, [button, go]);
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const resp = await client.v1.configuration.get().then(assert);
|
||||
|
||||
const hostsSection = swel("section", [
|
||||
swel("h1", "Hosts"),
|
||||
swel(
|
||||
"div",
|
||||
{ className: "hosts" },
|
||||
resp.hosts.map(h => ServiceInfo(
|
||||
h,
|
||||
() => client.v1.host.getStatus(h),
|
||||
))
|
||||
),
|
||||
]);
|
||||
|
||||
const servicesSection = swel("section", [
|
||||
swel("h1", "Services"),
|
||||
swel("div", { className: "services", }, resp.services.map(si => ServiceInfo(
|
||||
si.name,
|
||||
() => client.v1.service.getStatus(si.name),
|
||||
si.publicUrl,
|
||||
))),
|
||||
]);
|
||||
|
||||
document.body.replaceChildren(hostsSection, servicesSection);
|
||||
};
|
||||
|
||||
main();
|
@ -0,0 +1,122 @@
|
||||
/* Global Element Resets */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: sans;
|
||||
font-size: 24px;
|
||||
font-stretch: expanded;
|
||||
}
|
||||
|
||||
:root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 80ch;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
background: #137752;
|
||||
color: white;
|
||||
font-size: inherit;
|
||||
padding: 0.25em;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.refresh[disabled] {
|
||||
background: gray;
|
||||
}
|
||||
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-container > * {
|
||||
margin-left: -1px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.status-button {
|
||||
font-size: inherit;
|
||||
text-align: unset;
|
||||
flex: 1 1 max-content;
|
||||
|
||||
border: 1px solid black;
|
||||
padding: 0.5em;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-button.up:not(:hover) {
|
||||
background: #DCF9CD;
|
||||
}
|
||||
|
||||
.status-button.down:not(:hover) {
|
||||
background: #F9CDCD;
|
||||
}
|
||||
|
||||
.status-button.error:not(:hover) {
|
||||
background: #F9CDCD;
|
||||
}
|
||||
|
||||
.status-button.up .status {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.status-button.down .status {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.status-button.error .status {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.status-button .name {
|
||||
flex: 1 1 max-content;
|
||||
}
|
||||
|
||||
.go {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5em;
|
||||
|
||||
border: 1px solid black;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: #C6D4FF;
|
||||
}
|
||||
|
||||
.go:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
Loading…
Reference in New Issue