Initial commit

main
idylls 2 years ago
parent 1bddfbe2aa
commit ffb617d4ff
Signed by: idylls
GPG Key ID: 8A7167CBC2CC9F0F

2
.gitignore vendored

@ -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…
Cancel
Save