Initial commit

This commit is contained in:
Alois 2026-07-29 22:19:13 +02:00
commit 025c8c8e16
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
46 changed files with 13890 additions and 0 deletions

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/"!</div>
}

38
apps/frontend/index.html Normal file
View file

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en" class="dark" data-theme="methanium">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171e26" />
<title>Methanium Status</title>
<style>
html.dark,
html.dark body {
background-color: #171e26;
color: #d2e0f0;
color-scheme: dark;
}
html.light,
html.light body {
background-color: #edf1f4;
color: #10161d;
color-scheme: light;
}
</style>
<script>
const savedTheme = localStorage.getItem("theme");
const theme =
savedTheme === "light" ||
(savedTheme === "system" &&
matchMedia("(prefers-color-scheme: light)").matches)
? "light"
: "dark";
document.documentElement.classList.replace("dark", theme);
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
{
"name": "@methanium/status-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@methanium/status-mtp": "workspace:*",
"@methanium/ui": "*",
"@tanstack/react-router": "^1.131.35",
"lucide-react": "^1.21.0",
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/router-plugin": "^1.131.35",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.3.1",
"vite": "^7.1.12"
}
}

View file

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
<path d="m2686 5604 312 564-179 263-517 17Z"/>
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="85.836182mm"
height="90.476906mm"
viewBox="0 0 85.836183 90.476906"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_raw_outline.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2"
inkscape:cx="191.5"
inkscape:cy="117.25"
inkscape:window-width="2500"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1"
radius="1"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="swatch15"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop15" /></linearGradient><linearGradient
id="swatch10"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop10" /></linearGradient><linearGradient
id="swatch3"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop3" /></linearGradient><linearGradient
id="swatch2"
inkscape:swatch="solid"><stop
style="stop-color:#b8f8ff;stop-opacity:1;"
offset="0"
id="stop2" /></linearGradient><linearGradient
id="swatch1"
inkscape:swatch="solid"><stop
style="stop-color:#031616;stop-opacity:1;"
offset="0"
id="stop1" /></linearGradient><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
radius="3"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="bspline"
id="path-effect6"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="spiro"
id="path-effect5"
is_visible="true"
lpeversion="1" /><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1"><path
id="path3"
style="fill:#043b3c;stroke-width:0.320821"
inkscape:label="arrow"
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient15"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient16"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient17"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient18"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient19"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient20"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient21"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient22"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient23"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient25"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch2"
id="linearGradient1"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientTransform="matrix(1.0144629,0,0,1.0151924,217.46226,13.830072)" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-281.06837,-119.33314)"><g
id="g2"
transform="matrix(0.77770172,0,0,0.82714179,101.85385,-33.291714)"
inkscape:label="background"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path
id="rect1"
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1"
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3"
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6"
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><path
id="path1-5"
style="fill:none;stroke:url(#linearGradient1);stroke-width:2;stroke-dasharray:none"
inkscape:label="glow_outline"
d="m 323.73413,119.91784 -40.80246,15.9513 c -0.13609,0.0532 -0.26542,0.21326 -0.28843,0.35757 -2.90631,18.22859 -0.19736,34.27444 8.28042,47.36302 0.0794,0.12264 0.10916,0.33484 0.0657,0.47436 l -7.08112,22.75509 a 0.11130973,0.11130973 40.101955 0 0 0.15695,0.13218 l 18.7652,-9.59506 c 0.13011,-0.0665 0.32608,-0.044 0.43816,0.0497 5.75202,4.81168 12.45464,8.67546 20.46583,11.84762 0.13586,0.0538 0.3564,0.0538 0.49225,-3e-5 32.16862,-12.74646 46.41741,-39.59704 41.09168,-73.02692 -0.023,-0.14431 -0.15232,-0.30436 -0.28841,-0.35757 l -40.80297,-15.9513 a 0.67679193,0.67679193 179.99988 0 0 -0.49284,0 z"
sodipodi:nodetypes="cccccccc"
inkscape:original-d="m 323.98055,119.8215 -41.2953,16.14398 c -2.99079,18.43973 -0.24934,34.65473 8.38277,47.84598 l -7.23836,23.26035 19.23635,-9.83596 c 5.85568,4.9403 12.69983,8.88741 20.91454,12.11489 32.42602,-12.73985 46.75142,-39.74883 41.29581,-73.38526 z"
inkscape:path-effect="#path-effect1"
transform="matrix(0.98854883,0,0,0.98930922,3.7119485,1.7325084)" /><g
id="g3"
inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
transform="matrix(0.98854883,0,0,0.98930922,220.2086,17.64618)"><g
id="g1"
inkscape:label="outline"
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
id="path15-3-5"
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="filler"
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
transform="translate(159.54375,40.87617)" /></g><path
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
id="path2"
sodipodi:nodetypes="cccccccc"
inkscape:label="bolt" /><path
id="path1"
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="dark_outline"
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,72 @@
import { ExternalLink, Moon, Sun } from "lucide-react";
import { Button, useTheme } from "@methanium/ui";
import { useStatus } from "../../use-status";
import { formatTime } from "../../utils";
export function Header() {
const { resolvedPolarity, setTheme } = useTheme();
const { snapshot, error } = useStatus();
const isDark = resolvedPolarity === "dark";
return (
<header className="sticky top-0 z-40 h-12 border-b border-sidebar-border bg-sidebar text-sidebar-foreground shadow-sm">
<div className="flex h-full items-center justify-between overflow-x-auto px-[10px]">
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="mr-1 shrink-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ width: "46.59px", height: "36px", padding: "3px 13px" }}
render={<a href="/" aria-label="Methanium home" />}
>
<img src="/methanium.svg" alt="" className="h-8 w-6 object-contain" />
</Button>
<nav
className="mr-auto flex h-full shrink-0 items-center"
aria-label="Primary"
>
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a target="_blank" href="mailto:contact@methanium.net" />}
>
Contact us <ExternalLink />
</Button>
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a target="_blank" href="mailto:security@methanium.net" />}
>
Report security issue <ExternalLink />
</Button>
</nav>
<div className="flex h-full items-center gap-2">
<p className="text-sm text-muted-foreground">
{snapshot?.generatedAt
? `Updated ${formatTime(snapshot.generatedAt)}`
: (error?.message ?? "Waiting for data")}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="size-3.5" />
) : (
<Moon className="size-3.5" />
)}
</Button>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,32 @@
import "./styles.css";
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
import { Provider as MTPProvider } from "@methanium/status-mtp";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import { mtpOptions } from "./mtp-options.ts";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider
defaultTheme="dark"
themes={BUILT_IN_THEMES}
defaultParentThemeId="methanium"
>
<MTPProvider options={mtpOptions} authenticate={false}>
<RouterProvider router={router} />
</MTPProvider>
</ThemeProvider>
</StrictMode>,
);

View file

@ -0,0 +1,30 @@
import type { MTPClientOptions, MTPLogEvent } from "mtp";
const configuredUrl = import.meta.env.VITE_MTP_URL as string | undefined;
const configuredCertificateHash = import.meta.env.VITE_MTP_CERT_HASH as
string | undefined;
function logMTP(event: MTPLogEvent) {
const label = `[MTP] ${event.direction ?? "internal"} ${event.type}`;
if (event.hint === "error") {
console.error(label, event);
} else if (event.hint === "warning") {
console.warn(label, event);
} else {
console.info(label, event);
}
}
export const mtpOptions: MTPClientOptions | null = location.pathname.startsWith(
"/edit",
)
? null
: {
url: configuredUrl ?? `${location.origin}/mtp`,
descriptor: "methanium-status-frontend",
pings: false,
logger: logMTP,
serverCertificateHashes: configuredCertificateHash
? [configuredCertificateHash]
: undefined,
};

View file

@ -0,0 +1,77 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as EditRouteImport } from './routes/edit'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const EditRoute = EditRouteImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/edit'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/edit'
id: '__root__' | '/' | '/edit'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
EditRoute: typeof EditRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/edit': {
id: '/edit'
path: '/edit'
fullPath: '/edit'
preLoaderRoute: typeof EditRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
EditRoute: EditRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,19 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { Header } from "../components/layout/Header";
const EmptyPage = () => (
<main className="min-h-[calc(100vh-3rem)] bg-background text-2xl w-full h-full items-center justify-center flex font-medium">
404 Page Not Found
</main>
);
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground">
<Header />
<Outlet />
</div>
),
notFoundComponent: EmptyPage,
});

View file

@ -0,0 +1,391 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
cn,
} from "@methanium/ui";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { Incident, Severity } from "../status";
import { formatTime } from "../utils";
export const Route = createFileRoute("/edit")({
beforeLoad: async () => {
try {
const response = await fetch("/api/admin", { cache: "no-store" });
if (response.status !== 204) throw notFound();
} catch {
throw notFound();
}
},
component: IncidentEditor,
});
const severityLabels: Record<Severity, string> = {
minor: "Minor",
major: "Major",
critical: "Critical",
};
function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
const response = await fetch("/api/incidents", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
setIncidents((await response.json()) as Incident[]);
setError(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setSubmitting(true);
try {
const response = await fetch("/api/incidents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: data.get("title"),
message: data.get("message"),
severity: data.get("severity"),
}),
});
if (!response.ok) {
const body = (await response.json()) as { error?: string };
throw new Error(
body.error ?? `Request failed with HTTP ${response.status}`,
);
}
form.reset();
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSubmitting(false);
}
}
async function resolve(id: string) {
try {
const response = await fetch(`/api/incidents/${id}/resolve`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}
async function deleteIncident(id: string) {
setDeletingId(id);
try {
const response = await fetch(`/api/incidents/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setDeletingId(null);
}
}
const active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents
.filter((incident) => incident.resolvedAt)
.toReversed();
return (
<main className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 sm:px-6">
<header className="flex items-center gap-3">
<ShieldAlert className="size-7" />
<h1 className="text-xl font-medium">Incident management</h1>
</header>
{error && (
<div
role="alert"
className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{error}
</div>
)}
<div className="grid items-start gap-8 lg:grid-cols-[20rem_minmax(0,1fr)]">
<form
onSubmit={create}
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
>
<h2 className="font-medium">Create incident</h2>
<Label className="mt-5 block text-sm" htmlFor="title">
Title
</Label>
<Input
required
id="title"
name="title"
maxLength={120}
className="mt-2 w-full"
/>
<Label className="mt-4 block text-sm" htmlFor="message">
Message
</Label>
<Textarea
required
id="message"
name="message"
maxLength={2000}
rows={6}
className="py-2"
/>
<Label className="mt-4 block text-sm" htmlFor="severity">
Severity
</Label>
<Select name="severity" defaultValue="minor">
<SelectTrigger id="severity" className="mt-2 w-full">
<SelectValue>
{(value) => severityLabels[value as Severity]}
</SelectValue>
</SelectTrigger>
<SelectContent className="border!">
<SelectItem value="minor">Minor</SelectItem>
<SelectItem value="major">Major</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Publish incident
</Button>
</form>
<section aria-labelledby="active-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="active-incidents" className="font-medium">
Active incidents
</h2>
<span className="text-sm text-muted-foreground">
{active.length}
</span>
</div>
<div className="flex flex-col gap-3">
{loading && (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading incidents
</div>
)}
{!loading && active.length === 0 && (
<div className="rounded-xl border border-dashed border-[#C5CED6] px-4 py-10 text-center text-sm text-muted-foreground dark:border-[#445161]">
No active incidents
</div>
)}
{active.map((incident) => (
<article
key={incident.id}
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
>
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</h3>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void resolve(incident.id)}
>
<Check className="size-4" />
Resolve
</Button>
</div>
<IncidentExcerpt
incident={incident}
lines={3}
className="mt-3"
/>
</div>
<div className="flex gap-2 border-t border-[#C5CED6] px-4 py-2 text-xs text-muted-foreground dark:border-[#445161]">
<span className="capitalize">{incident.severity}</span>
<span>Started {formatTime(incident.createdAt)}</span>
</div>
</article>
))}
</div>
</section>
</div>
{resolved.length > 0 && (
<section aria-labelledby="resolved-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="resolved-incidents" className="font-medium">
Resolved
</h2>
<span className="text-sm text-muted-foreground">
{resolved.length}
</span>
</div>
<div className="divide-y divide-[#C5CED6] border-y border-[#C5CED6] dark:divide-[#445161] dark:border-[#445161]">
{resolved.map((incident) => (
<div
key={incident.id}
className="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="min-w-0">
<p className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</p>
<IncidentExcerpt incident={incident} lines={1} />
</div>
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
<p className="text-xs text-muted-foreground">
{formatTime(incident.resolvedAt!)}
</p>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deletingId === incident.id}
onClick={() => void deleteIncident(incident.id)}
>
{deletingId === incident.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Delete
</Button>
</div>
</div>
))}
</div>
</section>
)}
</main>
);
}
function IncidentExcerpt({
incident,
lines,
className,
}: {
incident: Incident;
lines: 1 | 3;
className?: string;
}) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const message = messageRef.current;
if (!message) return;
const update = () =>
setTruncated(
message.scrollHeight > message.clientHeight + 1 ||
message.scrollWidth > message.clientWidth + 1,
);
update();
const observer = new ResizeObserver(update);
observer.observe(message);
return () => observer.disconnect();
}, [incident.message]);
const severity =
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
return (
<div className={cn("min-w-0", className)}>
<p
ref={messageRef}
className={cn(
"text-sm text-muted-foreground [overflow-wrap:anywhere]",
lines === 1 ? "truncate" : "line-clamp-3 whitespace-pre-wrap",
)}
>
{incident.message}
</p>
{truncated && (
<Dialog>
<DialogTrigger
render={
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-0 py-0"
>
Read more
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident started {formatTime(incident.createdAt)}
{incident.resolvedAt &&
` and resolved ${formatTime(incident.resolvedAt)}`}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{incident.message}
</p>
</DialogContent>
</Dialog>
)}
</div>
);
}

View file

@ -0,0 +1,289 @@
import { createFileRoute } from "@tanstack/react-router";
import {
AlertTriangle,
Check,
ChevronRight,
Clock3,
ExternalLink,
RefreshCw,
ShieldAlert,
WifiOff,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Incident, Service, ServiceStatus } from "../status";
import { useStatus } from "../use-status";
import { formatTime } from "../utils";
import {
Button,
CardContent,
CardHeader,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
cn,
} from "@methanium/ui";
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const { snapshot } = useStatus();
const [showPastIncidents, setShowPastIncidents] = useState(false);
const services =
snapshot?.categories.flatMap((category) => category.services) ?? [];
const failures = services.filter(
(service) => service.status !== "online" && service.status !== "unknown",
);
const activeIncidents =
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? [];
const pastIncidents =
snapshot?.incidents
.filter((incident) => incident.resolvedAt)
.toReversed()
.slice(0, 5) ?? [];
const pending = services.some((service) => service.status === "unknown");
const ready = snapshot !== null && !pending;
const healthy =
ready && failures.length === 0 && activeIncidents.length === 0;
return (
<main className="flex flex-col pb-5">
<div className="w-full flex gap-3 justify-center items-center py-10">
<div>
{healthy ? (
<Check className="size-7" />
) : ready ? (
<AlertTriangle className="size-7" />
) : (
<RefreshCw className="size-7" />
)}
</div>
<div className="flex flex-col justify-start">
<p className="text-lg">
{healthy
? "Everythin's alright on our side"
: ready
? `${failures.length + activeIncidents.length} active disruption${failures.length + activeIncidents.length === 1 ? "" : "s"}`
: "Scraping our own data..."}
</p>
<p className="text-sm text-muted-foreground">
{!healthy && ready
? "More details below."
: snapshot
? ""
: "Connecting to Status Page backend..."}
</p>
</div>
</div>
{activeIncidents.length > 0 && (
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
{activeIncidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
{snapshot?.categories.map((category) => (
<div
key={category.id}
className="pt-4 text-sm flex flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
>
<CardHeader className="flex gap-3 items-center">
<img src={category.logo} alt={category.name} className="size-8" />
<p className="text-lg font-medium">{category.name}</p>
</CardHeader>
<CardContent className="px-0! flex flex-col gap-2 bg-[#E2E7EB] dark:bg-[#1D262F]">
{category.services.map((service, index) => (
<ServiceRow
key={service.id}
index={index}
max={category.services.length}
service={service}
/>
))}
</CardContent>
</div>
))}
</div>
{pastIncidents.length > 0 && (
<section className="pt-10 w-full flex flex-col items-center">
<Button
type="button"
variant="ghost"
aria-expanded={showPastIncidents}
aria-controls="past-incidents"
onClick={() => setShowPastIncidents((visible) => !visible)}
>
<ChevronRight
className={cn(
"size-4 transition-transform",
showPastIncidents && "rotate-90",
)}
/>
Show Past Incidents
</Button>
{showPastIncidents && (
<div
id="past-incidents"
className="mt-4 grid w-full grid-cols-1 justify-center gap-6 sm:grid-cols-[repeat(auto-fit,24rem)]"
>
{pastIncidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
</section>
)}
</main>
);
}
function IncidentCard({ incident }: { incident: Incident }) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const message = messageRef.current;
if (!message) return;
const update = () =>
setTruncated(message.scrollHeight > message.clientHeight + 1);
update();
const observer = new ResizeObserver(update);
observer.observe(message);
return () => observer.disconnect();
}, [incident.message]);
const severity =
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
return (
<article className="flex h-52 w-96 max-w-[calc(100vw-2rem)] flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] pt-4 text-sm dark:border-[#242D38] dark:bg-[#242D38]">
<CardHeader className="flex items-center gap-3">
<p className="line-clamp-2 text-lg font-medium [overflow-wrap:anywhere]">
{incident.title}
</p>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 bg-[#E2E7EB] px-0! dark:bg-[#1D262F]">
<p
ref={messageRef}
className="line-clamp-3 whitespace-pre-wrap px-4 pt-2 leading-5 [overflow-wrap:anywhere]"
>
{incident.message}
</p>
{truncated && (
<Dialog>
<DialogTrigger
render={
<Button
variant="link"
size="sm"
className="h-auto self-start px-4 py-0"
>
Read more
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident{" "}
{incident.resolvedAt ? "resolved" : "started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{incident.message}
</p>
</DialogContent>
</Dialog>
)}
<div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
<p>{severity}</p>
<p>
{incident.resolvedAt ? "Resolved" : "Started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</p>
</div>
</CardContent>
</article>
);
}
const statusDetails: Record<
ServiceStatus,
{ label: string; icon: typeof Check; color: string }
> = {
online: { label: "Operational", icon: Check, color: "text-emerald-500" },
unknown: { label: "Pending", icon: Clock3, color: "text-muted-foreground" },
offline: { label: "Offline", icon: WifiOff, color: "text-red-500" },
timed_out: { label: "Timed out", icon: Clock3, color: "text-red-500" },
http_error: {
label: "HTTP error",
icon: AlertTriangle,
color: "text-red-500",
},
http3_error: {
label: "HTTP/3 error",
icon: ShieldAlert,
color: "text-red-500",
},
};
function ServiceRow({
service,
index,
max,
}: {
service: Service;
index: number;
max: number;
}) {
const details = statusDetails[service.status];
const Icon = details.icon;
return (
<div
className={cn(
"px-4 flex items-center justify-between gap-40 py-2",
index < max - 1
? "border-border border-b border-[#C5CED6] dark:border-[#242D38]"
: "",
)}
>
<div className="min-w-0">
<a
href={service.url}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-1.5 font-medium hover:underline"
>
<span className="truncate">{service.name}</span>
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-60" />
</a>
<p className="mt-1 truncate text-xs text-muted-foreground">
{service.error ??
(service.latencyMs !== null
? `${service.latencyMs} ms${service.checkHttp3 ? " (HTTP/3 verified)" : ""}`
: new URL(service.url).hostname)}
</p>
</div>
<div
className={`flex items-center gap-2 text-xs font-medium ${details.color}`}
>
<Icon className="size-4" />
{details.label}
{service.statusCode && service.status === "http_error"
? ` ${service.statusCode}`
: ""}
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
export type ServiceStatus =
"unknown" | "online" | "offline" | "timed_out" | "http_error" | "http3_error";
export type Service = {
id: string;
name: string;
url: string;
checkHttp3: boolean;
status: ServiceStatus;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
checkedAt: string | null;
};
export type Category = {
id: string;
name: string;
logo: string;
services: Service[];
};
export type Severity = "minor" | "major" | "critical";
export type Incident = {
id: string;
title: string;
message: string;
severity: Severity;
createdAt: string;
resolvedAt: string | null;
};
export type Snapshot = {
categories: Category[];
incidents: Incident[];
generatedAt: string;
};
export function parseSnapshot(message: { data: unknown }): Snapshot {
const data = message.data as Record<string, unknown>;
if (typeof data?.Payload !== "string") {
throw new Error("Status response did not contain a payload");
}
return JSON.parse(data.Payload) as Snapshot;
}

View file

@ -0,0 +1,36 @@
@import "tailwindcss";
@import "@methanium/ui/index.css";
@custom-variant dark (&:where(.dark, .dark *));
html {
scroll-behavior: smooth;
}
body {
margin: 0;
}
.status-canvas {
background-color: var(--background);
background-image:
linear-gradient(
to right,
color-mix(in oklab, var(--border) 34%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in oklab, var(--border) 34%, transparent) 1px,
transparent 1px
),
radial-gradient(
circle at 50% 0%,
color-mix(in oklab, var(--primary) 7%, transparent),
transparent 36rem
);
background-size:
48px 48px,
48px 48px,
100% 100%;
}

View file

@ -0,0 +1,55 @@
import { useMTP } from "@methanium/status-mtp";
import { useEffect, useState } from "react";
import { parseSnapshot, type Snapshot } from "./status";
export function useStatus() {
const { contextReady, error: connectionError, send, subscribe } = useMTP();
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!contextReady) {
console.info("[Status] waiting for MTP readiness", {
connectionError,
});
return;
}
console.info("[Status] MTP ready; subscribing and requesting snapshot");
const receive = (message: { data: unknown }) => {
try {
const nextSnapshot = parseSnapshot(message);
console.info("[Status] received snapshot", {
categories: nextSnapshot.categories.length,
incidents: nextSnapshot.incidents.length,
generatedAt: nextSnapshot.generatedAt,
});
setSnapshot(nextSnapshot);
setError(null);
} catch (cause) {
const nextError =
cause instanceof Error ? cause : new Error(String(cause));
console.error("[Status] failed to parse snapshot", nextError, message);
setError(nextError);
}
};
const unsubscribe = subscribe("StatusSnapshot", receive);
void send("GetStatus", {}, { responseType: "StatusSnapshot" })
.then(receive)
.catch((cause) => {
const nextError =
cause instanceof Error ? cause : new Error(String(cause));
console.error("[Status] snapshot request failed", nextError);
setError(nextError);
});
return unsubscribe;
}, [connectionError, contextReady, send, subscribe]);
return {
snapshot,
connected: contextReady,
error: error ?? connectionError,
};
}

View file

@ -0,0 +1,6 @@
export function formatTime(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}

View file

@ -0,0 +1,9 @@
protocol_version: "1.0"
type_maps:
"1.0":
CommunicationTypes:
GetStatus: 32
StatusSnapshot: 33
DataTypes:
Payload: 32

View file

@ -0,0 +1,35 @@
import { methaniumUi } from "@methanium/ui/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { mtp } from "mtp/vite";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
server: {
https: {
cert: fs.readFileSync(path.resolve(root, "../../.dev/cert.pem")),
key: fs.readFileSync(path.resolve(root, "../../.dev/key.pem")),
},
},
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
tailwindcss(),
react(),
methaniumUi({ defaultThemeId: "methanium" }),
mtp({ typeMaps: "./type-maps.yaml" }) as Plugin,
],
resolve: {
alias: {
"@methanium/status-mtp": path.resolve(
root,
"../../packages/mtp/index.ts",
),
},
},
});