generated from methanium/template
Initial commit
This commit is contained in:
commit
025c8c8e16
46 changed files with 13890 additions and 0 deletions
|
|
@ -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
38
apps/frontend/index.html
Normal 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>
|
||||
28
apps/frontend/package.json
Normal file
28
apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
apps/frontend/public/methanium.svg
Normal file
27
apps/frontend/public/methanium.svg
Normal 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 |
270
apps/frontend/public/tensamin.svg
Normal file
270
apps/frontend/public/tensamin.svg
Normal 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 |
72
apps/frontend/src/components/layout/Header.tsx
Normal file
72
apps/frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/frontend/src/main.tsx
Normal file
32
apps/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
30
apps/frontend/src/mtp-options.ts
Normal file
30
apps/frontend/src/mtp-options.ts
Normal 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,
|
||||
};
|
||||
77
apps/frontend/src/routeTree.gen.ts
Normal file
77
apps/frontend/src/routeTree.gen.ts
Normal 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>()
|
||||
19
apps/frontend/src/routes/__root.tsx
Normal file
19
apps/frontend/src/routes/__root.tsx
Normal 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,
|
||||
});
|
||||
391
apps/frontend/src/routes/edit.tsx
Normal file
391
apps/frontend/src/routes/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
apps/frontend/src/routes/index.tsx
Normal file
289
apps/frontend/src/routes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/frontend/src/status.ts
Normal file
46
apps/frontend/src/status.ts
Normal 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;
|
||||
}
|
||||
36
apps/frontend/src/styles.css
Normal file
36
apps/frontend/src/styles.css
Normal 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%;
|
||||
}
|
||||
55
apps/frontend/src/use-status.ts
Normal file
55
apps/frontend/src/use-status.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
apps/frontend/src/utils.ts
Normal file
6
apps/frontend/src/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
9
apps/frontend/type-maps.yaml
Normal file
9
apps/frontend/type-maps.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
protocol_version: "1.0"
|
||||
|
||||
type_maps:
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
GetStatus: 32
|
||||
StatusSnapshot: 33
|
||||
DataTypes:
|
||||
Payload: 32
|
||||
35
apps/frontend/vite.config.ts
Normal file
35
apps/frontend/vite.config.ts
Normal 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",
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue