From 95eab7f584636afa4fb40af1a151750166a87a70 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Sat, 1 Aug 2026 02:27:47 +0200 Subject: [PATCH] statuspage: monitor lab/home machines with a small status page Go backend probes machines over ICMP/TCP, runs a configurable script over SSH for checks and numeric metrics, and keeps history in SQLite. Preact + Vite frontend shows machine groups, per-machine metric plots, and cross-machine metric aggregates. - server/: status, history, metrics, aggregate, refresh APIs - src/: Preact + Vite frontend - Single YAML config; documented example in example.config.yaml - SQLite data stored under data.volume/ (bind-mounted in Docker) - Multi-stage alpine Dockerfile with BuildKit cache mounts - LICENSE: AGPL-3.0 --- .gitignore | 47 ++ Dockerfile | 30 ++ LICENSE | 661 ++++++++++++++++++++++++ README.md | 319 ++++++++++++ bun.lock | 275 ++++++++++ docker-compose.yml | 12 + example.config.yaml | 84 ++++ go.mod | 15 + go.sum | 16 + index.html | 13 + package.json | 23 + server/config.go | 197 ++++++++ server/history.go | 402 +++++++++++++++ server/main.go | 98 ++++ server/monitor.go | 771 ++++++++++++++++++++++++++++ src/app.tsx | 1162 +++++++++++++++++++++++++++++++++++++++++++ src/jsx.d.ts | 13 + src/main.tsx | 10 + src/styles.css | 1018 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 31 ++ vite.config.ts | 11 + 21 files changed, 5208 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bun.lock create mode 100644 docker-compose.yml create mode 100644 example.config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 index.html create mode 100644 package.json create mode 100644 server/config.go create mode 100644 server/history.go create mode 100644 server/main.go create mode 100644 server/monitor.go create mode 100644 src/app.tsx create mode 100644 src/jsx.d.ts create mode 100644 src/main.tsx create mode 100644 src/styles.css create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb1913f --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +bin/ + +*.db + +*.db-wal +*.db-shm + +# local configs (contain ssh keys) +*.local* + +# docker data volume +data.volume/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5233dfd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.7 + +FROM oven/bun:1-alpine AS frontend +WORKDIR /ui +COPY package.json bun.lock ./ +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile +COPY . . +RUN bun run build + +FROM golang:1.26-alpine AS backend +RUN apk add --no-cache build-base +WORKDIR /src +RUN --mount=type=bind,source=.,target=/src \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download +RUN --mount=type=bind,source=.,target=/src \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=1 go build -o /out/statuspage ./server + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=backend /out/statuspage /app/statuspage +COPY --from=frontend /ui/dist /app/dist +VOLUME /app/data.volume +EXPOSE 5000 +ENTRYPOINT ["/app/statuspage"] +CMD ["-c", "/app/config.local.yaml", "--addr", ":5000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33df30d --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +# statuspage + +A status page that monitors the machines in your lab (or home). It probes +machines over ICMP and TCP, runs a script over SSH to collect checks and +numeric metrics, and keeps recent history in SQLite. + +The backend is a single Go binary, the frontend is a Preact + Vite page, and +everything is configured with one YAML file. + +## Features + +- ICMP + TCP probes per machine, combined into a single status (`up`, + `degraded`, `down`, `unknown`). +- Optional SSH check that runs a script and parses: + - check lines like `name:status:info` (e.g. `vulkan:on:AMD Radeon`); + - metric lines like `name:metric:value` (e.g. `cpu_pct:metric:42.5`) that get + accumulated into a SQLite time series; + - free-form text after a `---` separator (shown raw in the modal). +- Per-machine history (status ticks + incidents) and per-metric line plots with + hover tooltips. +- Interactive refresh button (opt-in) to re-probe a machine on demand. +- A neo-brutalist web UI with no runtime JS framework bloat (Preact + Vite). + +## Layout + +``` +statuspage +├── server/ the Go backend +│ ├── main.go entrypoint, flags, routes +│ ├── config.go yaml config loading + defaults +│ ├── monitor.go probes, ssh runs, status/history/metrics handlers +│ └── history.go sqlite history + metrics storage and downsampling +├── go.mod module definition (statuspage) +├── src/ the frontend (Preact + TS + CSS) +├── example.config.yaml documented, fully commented-out example config +├── config.local.yaml local config (gitignored, contains ssh keys) +└── data.volume/ created at runtime, holds history.db +``` + +## Requirements + +- Go (recent, 1.22+ for `http.ServeMux` patterns). +- A C compiler for `mattn/go-sqlite3` (the `CGO_ENABLED=1` build). +- `bun` (or `npm`) for the frontend build. + +## Building + +```sh +bun install +bun run build +CGO_ENABLED=1 go build -o bin/statuspage ./server +``` + +Run it: + +```sh +./bin/statuspage -c config.local.yaml +``` + +Then open `http://localhost:5000`. + +### Docker + +```sh +cp config.local.yaml config.local.yaml # your config goes here +docker compose up -d --build +``` + +The container mounts `./data.volume` over `/app/data.volume` (the default +database location) and `./config.local.yaml` read-only at +`/app/config.local.yaml`. `NET_RAW` is added so ICMP probes work; without it +the app falls back to TCP-only. + +### Development mode + +The backend can skip serving the built frontend while you work on it with Vite: + +```sh +./bin/statuspage -c config.local.yaml --dev-server # terminal 1 +bun dev # terminal 2, http://localhost:5173 +``` + +`vite.config.ts` proxies `/api` to `http://localhost:5000`, so the backend must +be running too. + +### Flags + +| flag | default | description | +| ------------------- | -------------- | -------------------------------------------------- | +| `-c, --config` | `config.local.yaml` | path to the yaml config file | +| `--addr` | `:5000` | listen address | +| `--db` | `data.volume/history.db` | sqlite database file | +| `--dev` | `false` | run `bun run build` before serving `dist/` | +| `--dev-server` | `false` | don't serve `dist/` (Vite dev server handles it) | + +## Configuration + +Everything lives in a single yaml file. A fully documented, commented-out +example with every option is in `example.config.yaml` — copy it to +`config.local.yaml` and fill it in. The top-level layout: + +```yaml +title: "My machines" +interactive: true +shared_metric_window: false + +ping: { ... } +ssh: { ... } + +metrics: + cpu_pct: { min: 0, max: 100 } + +groups: + - name: "Lab" + machines: + - host: a1.example.net +``` + +### `title` + +```yaml +title: "Laboratori Dipartimento di Matematica" +``` + +Shown in the header and the browser tab. Defaults to `Status Page`. + +### `interactive` + +```yaml +interactive: true +``` + +Whether clients can trigger an on-demand probe via the refresh button on each +machine card. When `false`, the endpoint refuses refresh requests. + +### `shared_metric_window` + +```yaml +shared_metric_window: false +``` + +Controls how metric plots share their x (time) axis: + +- `false` (default): each metric plot scales to its own recorded time span, so + a brand new metric isn't drowned out by an older one. +- `true`: all plots are aligned to the same window (the last ~24h) and are + downsampled together, so you can compare shapes directly. + +### `ping` + +```yaml +ping: + interval: 10s + timeout: 2s + tcp_port: 22 +``` + +| key | default | description | +| ---------- | ------- | ----------------------------------------- | +| `interval` | `10s` | how often to probe | +| `timeout` | `2s` | per-probe timeout | +| `tcp_port` | `22` | port to TCP-connect for the tcp check | + +Only IPv4 addresses are probed (IPv6 is ignored). + +### `ssh` + +```yaml +ssh: + interval: 5m + user: labuser + port: 22 + key: | + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- + script: | + echo "tmp:on:$(df -h /tmp 2>/dev/null | awk 'NR==2 {print $2}')" + echo "cpu_pct:metric:$(top -bn1 2>/dev/null | awk '/%Cpu/ {printf "%.1f", 100-$8}')" + echo "---" + echo "kernel: $(uname -sr)" +``` + +| key | default | description | +| ---------- | --------- | ---------------------------------------- | +| `interval` | `10m` | how often to run the script over ssh | +| `user` | `root` | ssh user | +| `port` | `22` | ssh port | +| `key` | — | PEM private key (the only supported auth)| +| `script` | — | shell script to run; see script format | + +If `key` or `script` is empty, the ssh check is skipped for that machine. + +#### Script format + +One line per check, `name:status:info`: + +``` +vulkan:on:AMD Radeon RX 570 +nvidia:off:no-nvidia-gpus +``` + +`status` can be `on`, `off`, `down`, or anything else (shown as `unknown`). + +A line with `name:metric:value` is treated as a numeric metric, accumulated in +SQLite and drawn as a line plot: + +``` +cpu_pct:metric:42.5 +ram_pct:metric:61.3 +disk_free:metric:198.2GB +``` + +The value is parsed as a leading number with an optional unit suffix. + +Everything after the first line containing `---` is treated as free-form output +and shown raw in the machine modal: + +``` +--- +hostname: a1 +kernel: Linux 6.8.0 +uptime: up 3 days +``` + +#### `metrics` + +Suggested `min`/`max` ranges for metric plots, keyed by metric name: + +```yaml +metrics: + cpu_pct: + min: 0 + max: 100 + ram_pct: + min: 0 + max: 100 + disk_free: + min: 0 +``` + +These are soft bounds: the plot scales to fit the data if a value goes outside +the range, and the current value is highlighted (with a wavy underline) when +out of range. + +### `groups` + +```yaml +groups: + - name: "Aula 3" + machines: + - host: a3-dott1.example.net + - name: "front door pi" + host: 192.168.1.10 +``` + +A `machines` entry only needs `host`; `name` overrides the display name. +Machines listed at the top level (outside any group) go into a `Machines` +section instead. + +Per-machine overrides for `ping` and `ssh` are supported, and inherit +unset values from the top level: + +```yaml +groups: + - name: "Lab" + machines: + - host: special.example.net + ping: + interval: 30s + ssh: + interval: 1m +``` + +## HTTP API + +| method | path | description | +| ------ | ------------------------ | -------------------------------------------- | +| `GET` | `/api/status` | current status, stats, metric ranges | +| `GET` | `/api/history/{machine}` | status tick history (`?limit=`, max 500) | +| `GET` | `/api/metrics/{machine}` | metric time series (see below) | +| `POST` | `/api/refresh/{machine}` | schedule an on-demand probe (interactive) | + +`/api/metrics/{machine}` query params: + +| param | description | +| ------------ | ------------------------------------------------ | +| `name` | restrict to one metric name | +| `min`, `max` | unix seconds window to fetch | +| `max_points` | max returned points (downsampled, default 100) | +| `shared` | align downsampling to the `min`/`max` window | + +The frontend asks for the last ~24h at `max_points=100` per metric. + +## Storage + +A single SQLite file holds everything: + +- `history` — status ticks per machine. +- `metrics` — accumulated metric samples per machine/name. + +It's capped at 200k history rows (5000 per machine) and 5000 samples per +metric name, pruned every 10 minutes. The footer shows the database size and a +rough "size in a month" estimate. + +## Notes + +- The SSH host key check is disabled, since lab machines change frequently. Be + aware of the implications if you expose this outside a trusted network. +- SSH key authentication only; password auth is not supported. +- ICMP requires a raw socket or `CAP_NET_RAW`. When unavailable, the app falls + back to TCP-only probes. +- The frontend polls `/api/status` every 3 seconds. +- The database grows while the server runs; a prune loop keeps it bounded, but + it is not a production-grade time-series store. + +## License + +GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..34b8831 --- /dev/null +++ b/bun.lock @@ -0,0 +1,275 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "statuspage", + "dependencies": { + "@fontsource/inria-sans": "^5.3.0", + "@fontsource/iosevka-etoile": "^5.3.0", + "preact": "^10.29.7", + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.6", + "@types/bun": "latest", + "vite": "^8.2.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], + + "@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + + "@fontsource/inria-sans": ["@fontsource/inria-sans@5.3.0", "", {}, "sha512-rTqLkvlaJ8ys39iH4iyC8ltntGwFXu6Q8A5C5PpOEi4xk8zjddEoS1RYCjJxUv1WVKDL3SOjdh8dfrx3Pf7fvA=="], + + "@fontsource/iosevka-etoile": ["@fontsource/iosevka-etoile@5.3.0", "", {}, "sha512-fuUgKPpwQxNghd3tak28lfwAOLd7wI7A9Bttx+BuKCqV9IBYA469dMQAVAnDnzxBC5xItASAJvEfMlvn51PfWQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + + "@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + + "@preact/preset-vite": ["@preact/preset-vite@2.10.6", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="], + + "@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="], + + "@prefresh/core": ["@prefresh/core@1.5.10", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w=="], + + "@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="], + + "@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.1", "", { "os": "none", "cpu": "arm64" }, "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.1", "", { "dependencies": { "@emnapi/core": "2.0.0-alpha.3", "@emnapi/runtime": "2.0.0-alpha.3", "@napi-rs/wasm-runtime": "^1.2.0" } }, "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + + "babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.399", "", {}, "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], + + "preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="], + + "rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stack-trace": ["stack-trace@1.0.0", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@8.2.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.23", "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ=="], + + "vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..57ffa49 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + statuspage: + build: . + container_name: statuspage + ports: + - "5000:5000" + volumes: + - ./data.volume:/app/data.volume + - ./config.local.yaml:/app/config.local.yaml:ro + cap_add: + - NET_RAW + restart: unless-stopped diff --git a/example.config.yaml b/example.config.yaml new file mode 100644 index 0000000..e4aa6c3 --- /dev/null +++ b/example.config.yaml @@ -0,0 +1,84 @@ +# statuspage example configuration +# +# Everything in this file is commented out on purpose. Uncomment (and adjust) +# the bits you need. See the README for the full reference. +# +# Save a copy as config.local.yaml and start the server with: +# +# ./bin/statuspage -c config.local.yaml +# +# or point Docker at it (see docker-compose.yml). + +# title: "My machines" # shown in the header and browser tab +# # default: "Status Page" + +# interactive: true # allow on-demand probes via the refresh +# # button on each machine card +# # default: false + +# shared_metric_window: false # align all metric plot x-axes to the same +# # window (the last ~24h) so shapes can be +# # compared directly +# # default: false + +# ---------- global ping defaults ---------- +# ping: +# interval: 10s # how often to probe default: 10s +# timeout: 2s # per-probe timeout default: 2s +# tcp_port: 22 # port to TCP-connect to default: 22 +# # (only IPv4 hosts are probed) + +# ---------- global ssh defaults ---------- +# ssh: +# interval: 10m # how often to run the script default: 10m +# user: root # ssh user default: root +# port: 22 # ssh port default: 22 +# key: | # PEM private key (the only supported auth) +# -----BEGIN OPENSSH PRIVATE KEY----- +# ... +# -----END OPENSSH PRIVATE KEY----- +# script: | # shell script to run over ssh +# echo "tmp:on:$(df -h /tmp 2>/dev/null | awk 'NR==2 {print $2}')" +# echo "cpu_pct:metric:$(top -bn1 2>/dev/null | awk '/%Cpu/ {printf "%.1f", 100-$8}')" +# echo "---" +# echo "kernel: $(uname -sr)" +# +# Script format, one line per item: +# name:status:info -> a check chip (status: on / off / down / anything) +# name:metric:value -> a numeric metric, accumulated into the history db +# --- -> everything after the first such line is shown raw +# +# If key or script is empty, the ssh check is skipped for that machine. + +# ---------- suggested y-axis bounds for metric plots ---------- +# metrics: +# cpu_pct: +# min: 0 # soft bounds: the plot only extends the +# max: 100 # axis if the data goes outside +# ram_pct: +# min: 0 +# max: 100 +# disk_free: +# min: 0 + +# ---------- machines grouped by section ---------- +# groups: +# - name: Aula 3 +# machines: +# - host: a3-dott1.example.net +# - host: a3-dott2.example.net +# name: dottorandi 2 # optional display name (defaults to host) +# ping: # per-machine overrides, inherit the rest +# interval: 30s +# tcp_port: 22 +# ssh: +# interval: 1m +# user: root + +# ---------- machines without a group (shown under "Machines") ---------- +# machines: +# - host: router.example.net +# name: router +# ping: +# interval: 5s +# tcp_port: 80 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d51973 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module statuspage + +go 1.26.4 + +require ( + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/mattn/go-sqlite3 v1.14.49 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3485160 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html new file mode 100644 index 0000000..0d4c87c --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Status Page + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4a1ce8 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "statuspage", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.6", + "@types/bun": "latest", + "vite": "^8.2.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@fontsource/inria-sans": "^5.3.0", + "@fontsource/iosevka-etoile": "^5.3.0", + "preact": "^10.29.7" + } +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..e92f2f3 --- /dev/null +++ b/server/config.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type Duration struct { + time.Duration +} + +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + var s string + if err := node.Decode(&s); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + d.Duration = dur + return nil +} + +type PingConfig struct { + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` + TCPPort int `yaml:"tcp_port"` +} + +type SSHConfig struct { + Interval Duration `yaml:"interval"` + User string `yaml:"user"` + Port int `yaml:"port"` + Key string `yaml:"key"` + Script string `yaml:"script"` +} + +type MachineConfig struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Ping *PingConfig `yaml:"ping"` + SSH *SSHConfig `yaml:"ssh"` +} + +type GroupConfig struct { + Name string `yaml:"name"` + Machines []MachineConfig `yaml:"machines"` +} + +type MetricRange struct { + Min *float64 `yaml:"min"` + Max *float64 `yaml:"max"` +} + +type RawConfig struct { + Title string `yaml:"title"` + Interactive bool `yaml:"interactive"` + SharedMetricWindow bool `yaml:"shared_metric_window"` + Ping *PingConfig `yaml:"ping"` + SSH *SSHConfig `yaml:"ssh"` + Metrics map[string]MetricRange `yaml:"metrics"` + Groups []GroupConfig `yaml:"groups"` + Machines []MachineConfig `yaml:"machines"` +} + +type Machine struct { + ID string + Name string + Host string + Group string + Ping PingConfig + SSH SSHConfig +} + +type Config struct { + Title string + Interactive bool + SharedMetricWindow bool + Ping PingConfig + SSH SSHConfig + Metrics map[string]MetricRange + Groups []GroupConfig + Machines []Machine +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var raw RawConfig + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + cfg := &Config{ + Title: raw.Title, + Interactive: raw.Interactive, + SharedMetricWindow: raw.SharedMetricWindow, + Ping: PingConfig{ + Interval: Duration{time.Second * 10}, + Timeout: Duration{time.Second * 2}, + TCPPort: 22, + }, + SSH: SSHConfig{ + Interval: Duration{time.Minute * 10}, + User: "root", + Port: 22, + }, + } + if raw.Ping != nil { + if raw.Ping.Interval.Duration > 0 { + cfg.Ping.Interval = raw.Ping.Interval + } + if raw.Ping.Timeout.Duration > 0 { + cfg.Ping.Timeout = raw.Ping.Timeout + } + if raw.Ping.TCPPort != 0 { + cfg.Ping.TCPPort = raw.Ping.TCPPort + } + } + if raw.SSH != nil { + if raw.SSH.Interval.Duration > 0 { + cfg.SSH.Interval = raw.SSH.Interval + } + if raw.SSH.User != "" { + cfg.SSH.User = raw.SSH.User + } + if raw.SSH.Port != 0 { + cfg.SSH.Port = raw.SSH.Port + } + cfg.SSH.Key = raw.SSH.Key + cfg.SSH.Script = raw.SSH.Script + } + if cfg.Title == "" { + cfg.Title = "Status Page" + } + + cfg.Groups = raw.Groups + cfg.Metrics = raw.Metrics + for _, g := range raw.Groups { + for _, mc := range g.Machines { + cfg.Machines = append(cfg.Machines, resolveMachine(mc, g.Name, cfg)) + } + } + for _, mc := range raw.Machines { + cfg.Machines = append(cfg.Machines, resolveMachine(mc, "", cfg)) + } + return cfg, nil +} + +func resolveMachine(mc MachineConfig, group string, cfg *Config) Machine { + m := Machine{ + ID: mc.Host, + Name: mc.Host, + Host: mc.Host, + Group: group, + Ping: cfg.Ping, + SSH: cfg.SSH, + } + if mc.Name != "" { + m.Name = mc.Name + } + if mc.Ping != nil { + if mc.Ping.Interval.Duration > 0 { + m.Ping.Interval = mc.Ping.Interval + } + if mc.Ping.Timeout.Duration > 0 { + m.Ping.Timeout = mc.Ping.Timeout + } + if mc.Ping.TCPPort != 0 { + m.Ping.TCPPort = mc.Ping.TCPPort + } + } + if mc.SSH != nil { + if mc.SSH.Interval.Duration > 0 { + m.SSH.Interval = mc.SSH.Interval + } + if mc.SSH.User != "" { + m.SSH.User = mc.SSH.User + } + if mc.SSH.Port != 0 { + m.SSH.Port = mc.SSH.Port + } + if mc.SSH.Key != "" { + m.SSH.Key = mc.SSH.Key + } + if mc.SSH.Script != "" { + m.SSH.Script = mc.SSH.Script + } + } + return m +} diff --git a/server/history.go b/server/history.go new file mode 100644 index 0000000..adee1fa --- /dev/null +++ b/server/history.go @@ -0,0 +1,402 @@ +package main + +import ( + "context" + "database/sql" + "log" + "os" + "sort" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +const historyPerMachine = 5000 +const historyTotalCap = 200000 +const metricsPerName = 5000 +const historyPruneInterval = 10 * time.Minute + +type HistoryEntry struct { + TS int64 `json:"ts"` + Status string `json:"status"` + IP string `json:"ip"` +} + +type MetricEntry struct { + TS int64 `json:"ts"` + Name string `json:"name"` + Value float64 `json:"value"` + Unit string `json:"unit"` +} + +type History struct { + db *sql.DB + path string +} + +func NewHistory(path string) (*History, error) { + dsn := "file:" + path + + "?_journal_mode=WAL" + + "&_synchronous=NORMAL" + + "&_busy_timeout=5000" + + "&_foreign_keys=on" + + "&_txlock=immediate" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + + const schema = ` + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine TEXT NOT NULL, + ts INTEGER NOT NULL, + status TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_history_machine_ts ON history(machine, ts); + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine TEXT NOT NULL, + name TEXT NOT NULL, + ts INTEGER NOT NULL, + value REAL NOT NULL, + unit TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_metrics_machine_name_ts ON metrics(machine, name, ts);` + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, err + } + return &History{db: db, path: path}, nil +} + +func (h *History) Close() error { + return h.db.Close() +} + +func (h *History) Size() int64 { + if fi, err := os.Stat(h.path); err == nil { + return fi.Size() + } + return 0 +} + +func (h *History) Stats() (rows int, firstTS int64) { + h.db.QueryRow(`SELECT COUNT(*), COALESCE(MIN(ts), 0) FROM history`).Scan(&rows, &firstTS) + return +} + +func (h *History) Record(machine, status, ip string) { + _, err := h.db.Exec( + `INSERT INTO history (machine, ts, status, ip) VALUES (?, ?, ?, ?)`, + machine, time.Now().Unix(), status, ip, + ) + if err != nil { + log.Printf("history insert (%s): %v", machine, err) + } +} + +func (h *History) RecordMetric(machine, name string, ts int64, value float64, unit string) { + _, err := h.db.Exec( + `INSERT INTO metrics (machine, name, ts, value, unit) VALUES (?, ?, ?, ?, ?)`, + machine, name, ts, value, unit, + ) + if err != nil { + log.Printf("metric insert (%s/%s): %v", machine, name, err) + } +} + +func (h *History) Query(machine string, limit int) ([]HistoryEntry, error) { + if limit <= 0 || limit > historyPerMachine { + limit = historyPerMachine + } + rows, err := h.db.Query( + `SELECT ts, status, ip FROM history WHERE machine = ? ORDER BY id DESC LIMIT ?`, + machine, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + entries := []HistoryEntry{} + for rows.Next() { + var e HistoryEntry + if err := rows.Scan(&e.TS, &e.Status, &e.IP); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64, maxPoints int, shared bool) ([]MetricEntry, error) { + if maxPoints <= 0 || maxPoints > metricsPerName*5 { + maxPoints = 100 + } + q := `SELECT ts, name, value, unit FROM metrics WHERE machine = ?` + args := []any{machine} + if name != "" { + q += ` AND name = ?` + args = append(args, name) + } + if minTS > 0 { + q += ` AND ts >= ?` + args = append(args, minTS) + } + if maxTS > 0 { + q += ` AND ts < ?` + args = append(args, maxTS) + } + q += ` ORDER BY id ASC LIMIT ?` + args = append(args, metricsPerName*5) + rows, err := h.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + entries := []MetricEntry{} + for rows.Next() { + var e MetricEntry + if err := rows.Scan(&e.TS, &e.Name, &e.Value, &e.Unit); err != nil { + return nil, err + } + entries = append(entries, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + var lo, hi int64 + if shared { + lo = minTS + hi = maxTS + } + return downsampleMetrics(entries, lo, hi, maxPoints), nil +} + +func downsampleMetrics(entries []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry { + if maxPoints < 1 { + return entries + } + groups := map[string][]MetricEntry{} + order := []string{} + for _, e := range entries { + if _, ok := groups[e.Name]; !ok { + order = append(order, e.Name) + } + groups[e.Name] = append(groups[e.Name], e) + } + out := []MetricEntry{} + for _, name := range order { + out = append(out, downsampleGroup(groups[name], lo, hi, maxPoints)...) + } + sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS }) + return out +} + +func downsampleGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry { + if len(g) <= maxPoints { + return g + } + return alignGroup(g, lo, hi, maxPoints) +} + +// alignGroup downsamples g to at most maxPoints buckets whose boundaries are +// aligned to the [lo, hi] window, so all machines share the same bucket centers. +func alignGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry { + if len(g) == 0 { + return nil + } + if lo <= 0 { + lo = g[0].TS + } + if hi <= 0 { + hi = g[len(g)-1].TS + } + span := hi - lo + if span <= 0 { + span = 1 + } + bucketSize := float64(span) / float64(maxPoints) + buckets := make([][]MetricEntry, maxPoints) + for _, e := range g { + idx := int(float64(e.TS-lo) / bucketSize) + if idx >= maxPoints { + idx = maxPoints - 1 + } + if idx < 0 { + idx = 0 + } + buckets[idx] = append(buckets[idx], e) + } + res := make([]MetricEntry, 0, maxPoints) + for i, b := range buckets { + if len(b) == 0 { + continue + } + var sum float64 + for _, e := range b { + sum += e.Value + } + last := b[len(b)-1] + res = append(res, MetricEntry{ + TS: lo + int64((float64(i)+0.5)*bucketSize), + Name: last.Name, + Value: sum / float64(len(b)), + Unit: last.Unit, + }) + } + return res +} + +func (h *History) QueryMetricsAll(minTS, maxTS int64) ([]MetricAggregate, [2]int64, error) { + q := `SELECT machine, ts, name, value, unit FROM metrics` + args := []any{} + if minTS > 0 { + q += ` WHERE ts >= ?` + args = append(args, minTS) + } + if maxTS > 0 { + if len(args) > 0 { + q += ` AND ts < ?` + } else { + q += ` WHERE ts < ?` + } + args = append(args, maxTS) + } + q += ` ORDER BY machine, name, id ASC LIMIT ?` + args = append(args, metricsPerName*10) + rows, err := h.db.Query(q, args...) + if err != nil { + return nil, [2]int64{}, err + } + defer rows.Close() + + type rawEntry struct { + machine string + entry MetricEntry + } + raw := []rawEntry{} + var firstTS, lastTS int64 + for rows.Next() { + var e rawEntry + if err := rows.Scan(&e.machine, &e.entry.TS, &e.entry.Name, &e.entry.Value, &e.entry.Unit); err != nil { + return nil, [2]int64{}, err + } + if firstTS == 0 || e.entry.TS < firstTS { + firstTS = e.entry.TS + } + if e.entry.TS > lastTS { + lastTS = e.entry.TS + } + raw = append(raw, e) + } + if err := rows.Err(); err != nil { + return nil, [2]int64{}, err + } + + if minTS > 0 && firstTS < minTS { + firstTS = minTS + } + if maxTS > 0 && lastTS > maxTS { + lastTS = maxTS + } + + type nameGroup struct { + unit string + series []MetricSeries + } + byName := map[string]*nameGroup{} + order := []string{} + for _, r := range raw { + ng, ok := byName[r.entry.Name] + if !ok { + ng = &nameGroup{unit: r.entry.Unit} + byName[r.entry.Name] = ng + order = append(order, r.entry.Name) + } + found := false + for i := range ng.series { + if ng.series[i].Machine == r.machine { + ng.series[i].Samples = append(ng.series[i].Samples, r.entry) + found = true + break + } + } + if !found { + ng.series = append(ng.series, MetricSeries{Machine: r.machine, Samples: []MetricEntry{r.entry}}) + } + } + + out := make([]MetricAggregate, 0, len(order)) + for _, name := range order { + ng := byName[name] + agg := MetricAggregate{Name: name, Unit: ng.unit, Series: make([]MetricSeries, 0, len(ng.series))} + for _, s := range ng.series { + sort.Slice(s.Samples, func(i, j int) bool { return s.Samples[i].TS < s.Samples[j].TS }) + agg.Series = append(agg.Series, s) + } + out = append(out, agg) + } + return out, [2]int64{firstTS, lastTS}, nil +} + +type MetricSeries struct { + Machine string `json:"machine"` + Samples []MetricEntry `json:"samples"` +} + +type MetricAggregate struct { + Name string `json:"name"` + Unit string `json:"unit"` + Series []MetricSeries `json:"series"` +} + +func (h *History) prune() { + res, err := h.db.Exec( + `DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY id DESC LIMIT ?)`, + historyTotalCap, + ) + if err != nil { + log.Printf("history prune: %v", err) + } else { + n, _ := res.RowsAffected() + if n > 0 { + log.Printf("history pruned %d rows (kept last %d)", n, historyTotalCap) + } + } + + res, err = h.db.Exec(` + DELETE FROM metrics WHERE id NOT IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY machine, name ORDER BY id DESC) AS rn + FROM metrics + ) WHERE rn <= ? + )`, metricsPerName) + if err != nil { + log.Printf("metrics prune: %v", err) + return + } + n, _ := res.RowsAffected() + if n > 0 { + log.Printf("metrics pruned %d rows (kept last %d per name)", n, metricsPerName) + } +} + +func (h *History) pruneLoop(ctx context.Context) { + ticker := time.NewTicker(historyPruneInterval) + defer ticker.Stop() + h.prune() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.prune() + } + } +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..311ce42 --- /dev/null +++ b/server/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/pflag" +) + +func buildFrontend() error { + cmd := "npm" + if _, err := exec.LookPath("npm"); err != nil { + cmd = "bun" + } + c := exec.Command(cmd, "run", "build") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + log.Printf("running %s run build", cmd) + return c.Run() +} + +func spaHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + rel := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if rel != "." && rel != "" { + p := filepath.Join("dist", rel) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + http.ServeFile(w, r, p) + return + } + } + http.ServeFile(w, r, filepath.Join("dist", "index.html")) + } +} + +func main() { + dev := pflag.Bool("dev", false, "run `npm run build` before serving dist/") + devServer := pflag.Bool("dev-server", false, "frontend served by the vite dev server, do not serve dist/") + addr := pflag.String("addr", ":5000", "listen address") + configPath := pflag.StringP("config", "c", "config.local.yaml", "yaml config file") + dbPath := pflag.String("db", "data.volume/history.db", "sqlite history database file") + pflag.Parse() + + if dir := filepath.Dir(*dbPath); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Fatalf("creating db dir: %v", err) + } + } + + if *dev { + if err := buildFrontend(); err != nil { + log.Fatalf("build failed: %v", err) + } + } + + cfg, err := LoadConfig(*configPath) + if err != nil { + log.Fatalf("loading config: %v", err) + } + log.Printf("loaded %d machines from %s", len(cfg.Machines), *configPath) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + history, err := NewHistory(*dbPath) + if err != nil { + log.Fatalf("opening history db: %v", err) + } + defer history.Close() + go history.pruneLoop(ctx) + + mon := NewMonitor(cfg, history) + mon.Start(ctx) + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/status", mon.statusHandler) + mux.HandleFunc("GET /api/history/{machine}", mon.historyHandler) + mux.HandleFunc("GET /api/metrics/aggregate", mon.metricsAggregateHandler) + mux.HandleFunc("GET /api/metrics/{machine}", mon.metricsHandler) + mux.HandleFunc("POST /api/refresh/{machine}", mon.refreshHandler) + if !*devServer { + mux.HandleFunc("GET /", spaHandler()) + } + + log.Printf("listening on %s", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Fatal(err) + } +} diff --git a/server/monitor.go b/server/monitor.go new file mode 100644 index 0000000..f413435 --- /dev/null +++ b/server/monitor.go @@ -0,0 +1,771 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math/rand/v2" + "net" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +const statusUp = "up" +const statusDegraded = "degraded" +const statusDown = "down" +const statusUnknown = "unknown" + +const checkOK = "ok" +const checkFail = "fail" +const checkNA = "na" + +const maxIPHistory = 10 + +type sshResultState struct { + Ok bool `json:"ok"` + Result string `json:"result"` + ExitCode *int `json:"exitCode"` + LastRun time.Time `json:"lastRun"` + Error string `json:"error"` +} + +type metricState struct { + value float64 + unit string + updated time.Time +} + +type machineState struct { + mu sync.RWMutex + status string + icmp string + tcp string + lastPing time.Time + ip string + ips []string + sshResult *sshResultState + metrics map[string]metricState +} + +type metricStatus struct { + Name string `json:"name"` + Value float64 `json:"value"` + Unit string `json:"unit"` + Updated *time.Time `json:"updated"` +} + +type machineStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + IP string `json:"ip"` + IPs []string `json:"ips"` + Status string `json:"status"` + ICMP string `json:"icmp"` + TCP string `json:"tcp"` + LastPing *time.Time `json:"lastPing"` + SSHConfigured bool `json:"sshConfigured"` + SSH *sshResultState `json:"ssh"` + Metrics []metricStatus `json:"metrics"` +} + +type groupStatus struct { + Name string `json:"name"` + Machines []machineStatus `json:"machines"` +} + +type statsPayload struct { + PingInterval string `json:"pingInterval"` + PingTimeout string `json:"pingTimeout"` + TCPPort int `json:"tcpPort"` + SSHInterval string `json:"sshInterval"` + SSHEnabled bool `json:"sshEnabled"` + DBSize int64 `json:"dbSize"` + DBRows int `json:"dbRows"` + DBSizeMonth int64 `json:"dbSizeMonth"` +} + +type metricRange struct { + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` +} + +type statusPayload struct { + Title string `json:"title"` + Interactive bool `json:"interactive"` + SharedMetricWindow bool `json:"sharedMetricWindow"` + Stats statsPayload `json:"stats"` + MetricRanges map[string]metricRange `json:"metricRanges"` + Groups []groupStatus `json:"groups"` + Machines []machineStatus `json:"machines"` +} + +type Monitor struct { + cfg *Config + machines []Machine + machinesByID map[string]Machine + interactive bool + ctx context.Context + history *History + + icmpMu sync.Mutex + icmp *icmp.PacketConn + icmpSeq atomic.Uint32 + icmpID int + + statesMu sync.RWMutex + states map[string]*machineState +} + +func NewMonitor(cfg *Config, history *History) *Monitor { + m := &Monitor{ + cfg: cfg, + machines: cfg.Machines, + machinesByID: make(map[string]Machine, len(cfg.Machines)), + interactive: cfg.Interactive, + ctx: context.Background(), + history: history, + states: make(map[string]*machineState, len(cfg.Machines)), + } + for _, mc := range cfg.Machines { + m.states[mc.ID] = &machineState{status: statusUnknown, icmp: checkNA, tcp: checkNA} + m.machinesByID[mc.ID] = mc + } + conn, err := icmp.ListenPacket("udp4", "0.0.0.0") + if err != nil { + conn, err = icmp.ListenPacket("ip4:icmp", "0.0.0.0") + } + if err != nil { + log.Printf("icmp unavailable (%v); falling back to TCP-only probes", err) + m.icmp = nil + } else { + m.icmp = conn + if la, ok := conn.LocalAddr().(*net.UDPAddr); ok { + m.icmpID = la.Port + } + log.Printf("icmp ok, will use it with tcp fallback") + } + return m +} + +func (m *Monitor) Start(ctx context.Context) { + m.ctx = ctx + for _, mc := range m.machines { + go m.pingLoop(ctx, mc) + go m.sshLoop(ctx, mc) + } +} + +func (m *Monitor) Refresh(mc Machine) { + go m.probe(m.ctx, mc) + go m.runSSH(m.ctx, mc) +} + +func (m *Monitor) pingLoop(ctx context.Context, mc Machine) { + run := func() { + m.probe(ctx, mc) + } + run() + ticker := time.NewTicker(mc.Ping.Interval.Duration) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + +func (m *Monitor) state(mc Machine) *machineState { + m.statesMu.RLock() + s := m.states[mc.ID] + m.statesMu.RUnlock() + return s +} + +func (m *Monitor) probe(ctx context.Context, mc Machine) { + st := m.state(mc) + if st == nil { + return + } + + timeout := mc.Ping.Timeout.Duration + if timeout <= 0 { + timeout = 2 * time.Second + } + + resolveCtx, cancel := context.WithTimeout(ctx, timeout) + ips, err := net.DefaultResolver.LookupHost(resolveCtx, mc.Host) + cancel() + if err != nil { + m.setProbeResult(st, statusDown, checkFail, checkFail, "", nil) + m.recordHistory(mc.ID, statusDown, "") + log.Printf("ping %s -> %s (resolve failed: %v)", mc.Host, statusDown, err) + return + } + // only ping over ipv4, ignore ipv6 addresses + var v4 []string + for _, a := range ips { + if net.ParseIP(a).To4() != nil { + v4 = append(v4, a) + } + } + if len(v4) == 0 { + m.setProbeResult(st, statusDown, checkFail, checkFail, "", nil) + m.recordHistory(mc.ID, statusDown, "") + log.Printf("ping %s -> %s (no ipv4 addresses)", mc.Host, statusDown) + return + } + + ip := v4[0] + m.recordIP(st, v4) + + icmpOK := m.icmpPing(net.ParseIP(ip), timeout) + tcpOK := m.tcpPing(net.ParseIP(ip), mc.Ping.TCPPort, timeout) + + icmp := checkNA + if m.icmp != nil { + if icmpOK { + icmp = checkOK + } else { + icmp = checkFail + } + } + tcp := checkNA + if tcpOK { + tcp = checkOK + } else { + tcp = checkFail + } + + status := statusDown + if icmpOK || tcpOK { + status = statusUp + st.mu.RLock() + sshBad := st.sshResult != nil && !st.sshResult.Ok + st.mu.RUnlock() + if (icmp == checkOK && tcp == checkFail) || + (icmp == checkFail && tcp == checkOK) || sshBad { + status = statusDegraded + } + } + + now := time.Now() + m.setProbeResult(st, status, icmp, tcp, ip, &now) + m.recordHistory(mc.ID, status, ip) + log.Printf("ping %s (%s) icmp=%s tcp=%s -> %s", mc.Host, ip, icmp, tcp, status) +} + +func (m *Monitor) recordHistory(machine, status, ip string) { + if m.history != nil { + m.history.Record(machine, status, ip) + } +} + +func (m *Monitor) setProbeResult(st *machineState, status, icmp, tcp, ip string, last *time.Time) { + st.mu.Lock() + defer st.mu.Unlock() + st.status = status + st.icmp = icmp + st.tcp = tcp + if ip != "" { + st.ip = ip + } + if last != nil { + st.lastPing = *last + } +} + +func (m *Monitor) recordIP(st *machineState, ips []string) { + st.mu.Lock() + defer st.mu.Unlock() + for _, ip := range ips { + if ip == st.ip { + continue + } + st.ips = append(st.ips, ip) + } + if len(st.ips) > maxIPHistory { + st.ips = st.ips[len(st.ips)-maxIPHistory:] + } +} + +func (m *Monitor) icmpPing(ip net.IP, timeout time.Duration) bool { + if m.icmp == nil || ip == nil { + return false + } + m.icmpMu.Lock() + defer m.icmpMu.Unlock() + + seq := int(m.icmpSeq.Add(1)) + wm := icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ID: m.icmpID, Seq: seq, Data: []byte("lab-status")}, + } + wb, err := wm.Marshal(nil) + if err != nil { + return false + } + if _, err := m.icmp.WriteTo(wb, &net.UDPAddr{IP: ip}); err != nil { + return false + } + m.icmp.SetReadDeadline(time.Now().Add(timeout)) + reply := make([]byte, 1500) + for { + n, peer, err := m.icmp.ReadFrom(reply) + if err != nil { + return false + } + pa, ok := peer.(*net.UDPAddr) + if !ok || !pa.IP.Equal(ip) { + continue + } + rm, err := icmp.ParseMessage(1, reply[:n]) + if err != nil { + continue + } + if rm.Type != ipv4.ICMPTypeEchoReply { + continue + } + if echo, ok := rm.Body.(*icmp.Echo); ok && echo.ID == m.icmpID && echo.Seq == seq { + return true + } + } +} + +func (m *Monitor) tcpPing(ip net.IP, port int, timeout time.Duration) bool { + if ip == nil { + return false + } + addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return false + } + conn.Close() + return true +} + +func (m *Monitor) sshLoop(ctx context.Context, mc Machine) { + if mc.SSH.Key == "" || mc.SSH.Script == "" { + return + } + interval := mc.SSH.Interval.Duration + if interval <= 0 { + interval = 10 * time.Minute + } + jitter := interval / 4 + if jitter <= 0 { + jitter = interval + } + // stagger the first run within [0, interval) so machines don't all connect at once + timer := time.NewTimer(rand.N(interval)) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + m.runSSH(ctx, mc) + // drift each run by a random offset to keep machines out of phase + timer.Reset(interval + rand.N(jitter)) + } + } +} + +func (m *Monitor) runSSH(ctx context.Context, mc Machine) { + start := time.Now() + st := m.state(mc) + if st == nil { + return + } + + key, err := ssh.ParsePrivateKey([]byte(mc.SSH.Key)) + if err != nil { + log.Printf("ssh %s: bad key: %v", mc.Host, err) + m.setSSHError(st, fmt.Sprintf("bad ssh key: %v", err)) + return + } + + cfg := &ssh.ClientConfig{ + User: mc.SSH.User, + Auth: []ssh.AuthMethod{ssh.PublicKeys(key)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: mc.Ping.Timeout.Duration, + } + + addr := net.JoinHostPort(mc.Host, strconv.Itoa(mc.SSH.Port)) + log.Printf("ssh %s: connecting to %s", mc.Host, addr) + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + log.Printf("ssh %s: dial failed: %v", mc.Host, err) + m.setSSHError(st, fmt.Sprintf("ssh dial: %v", err)) + return + } + defer client.Close() + log.Printf("ssh %s: connected", mc.Host) + + session, err := client.NewSession() + if err != nil { + log.Printf("ssh %s: session failed: %v", mc.Host, err) + m.setSSHError(st, fmt.Sprintf("ssh session: %v", err)) + return + } + defer session.Close() + + timeout := mc.SSH.Interval.Duration + if timeout > 30*time.Minute { + timeout = 30 * time.Minute + } + + log.Printf("ssh %s: running script", mc.Host) + type cmdResult struct { + out []byte + err error + } + done := make(chan cmdResult, 1) + go func() { + out, err := session.CombinedOutput(mc.SSH.Script) + done <- cmdResult{out, err} + }() + + select { + case r := <-done: + var exitCode *int + if r.err != nil { + var ee *ssh.ExitError + if errors.As(r.err, &ee) { + ec := ee.ExitStatus() + exitCode = &ec + log.Printf("ssh %s: script done in %s, exit %d", mc.Host, time.Since(start), ec) + } else { + log.Printf("ssh %s: script failed: %v", mc.Host, r.err) + m.setSSHError(st, fmt.Sprintf("ssh command: %v", r.err)) + return + } + } else { + log.Printf("ssh %s: script done in %s, exit 0", mc.Host, time.Since(start)) + } + st.mu.Lock() + st.sshResult = &sshResultState{ + Ok: exitCode == nil, + Result: string(r.out), + ExitCode: exitCode, + LastRun: time.Now(), + } + st.mu.Unlock() + if exitCode == nil { + m.accumulateMetrics(st, mc.ID, string(r.out), time.Now()) + } + case <-time.After(timeout): + session.Close() + log.Printf("ssh %s: timed out after %s", mc.Host, timeout) + m.setSSHError(st, fmt.Sprintf("ssh command timed out after %s", timeout)) + case <-ctx.Done(): + log.Printf("ssh %s: aborted", mc.Host) + return + } +} + +func (m *Monitor) setSSHError(st *machineState, msg string) { + st.mu.Lock() + defer st.mu.Unlock() + st.sshResult = &sshResultState{ + Ok: false, + Error: msg, + LastRun: time.Now(), + ExitCode: nil, + } +} + +type parsedMetric struct { + name string + value float64 + unit string +} + +func parseMetrics(out string) []parsedMetric { + metrics := []parsedMetric{} + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "---") { + break + } + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + name := strings.TrimSpace(parts[0]) + status := strings.TrimSpace(parts[1]) + if name == "" || status != "metric" { + continue + } + val := strings.TrimSpace(parts[2]) + num := 0.0 + unit := "" + for i, r := range val { + if (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '+' { + continue + } + if p, err := strconv.ParseFloat(strings.TrimSpace(val[:i]), 64); err == nil { + num = p + unit = strings.TrimSpace(val[i:]) + } + break + } + if unit == "" && val != "" { + if p, err := strconv.ParseFloat(val, 64); err == nil { + num = p + } else { + continue + } + } + metrics = append(metrics, parsedMetric{name: name, value: num, unit: unit}) + } + return metrics +} + +func (m *Monitor) accumulateMetrics(st *machineState, machineID, out string, now time.Time) { + metrics := parseMetrics(out) + if len(metrics) == 0 { + return + } + ts := now.Unix() + st.mu.Lock() + if st.metrics == nil { + st.metrics = make(map[string]metricState, len(metrics)) + } + for _, p := range metrics { + st.metrics[p.name] = metricState{value: p.value, unit: p.unit, updated: now} + } + st.mu.Unlock() + if m.history != nil { + for _, p := range metrics { + m.history.RecordMetric(machineID, p.name, ts, p.value, p.unit) + } + } +} + +func (m *Monitor) StatusPayload() statusPayload { + var dbSize, dbSizeMonth int64 + var dbRows int + if m.history != nil { + dbSize = m.history.Size() + rows, firstTS := m.history.Stats() + dbRows = rows + if rows > 0 && firstTS > 0 { + elapsed := time.Now().Unix() - firstTS + if elapsed < 1 { + elapsed = 1 + } + perSec := float64(dbSize) / float64(elapsed) + dbSizeMonth = dbSize + int64(perSec*float64(30*24*3600)) + } + } + p := statusPayload{ + Title: m.cfg.Title, + Interactive: m.interactive, + SharedMetricWindow: m.cfg.SharedMetricWindow, + Stats: statsPayload{ + PingInterval: m.cfg.Ping.Interval.Duration.String(), + PingTimeout: m.cfg.Ping.Timeout.Duration.String(), + TCPPort: m.cfg.Ping.TCPPort, + SSHInterval: m.cfg.SSH.Interval.Duration.String(), + SSHEnabled: m.cfg.SSH.Key != "" && m.cfg.SSH.Script != "", + DBSize: dbSize, + DBRows: dbRows, + DBSizeMonth: dbSizeMonth, + }, + Groups: []groupStatus{}, + Machines: []machineStatus{}, + } + if len(m.cfg.Metrics) > 0 { + p.MetricRanges = make(map[string]metricRange, len(m.cfg.Metrics)) + for name, r := range m.cfg.Metrics { + p.MetricRanges[name] = metricRange{Min: r.Min, Max: r.Max} + } + } + + grouped := map[string][]machineStatus{} + order := []string{} + + appendStatus := func(mc Machine, group string) { + st := m.state(mc) + ms := machineStatus{ + ID: mc.ID, + Name: mc.Name, + Host: mc.Host, + SSHConfigured: mc.SSH.Key != "" && mc.SSH.Script != "", + } + if st != nil { + st.mu.RLock() + ms.Status = st.status + ms.ICMP = st.icmp + ms.TCP = st.tcp + ms.IP = st.ip + ms.IPs = append([]string{}, st.ips...) + if !st.lastPing.IsZero() { + t := st.lastPing + ms.LastPing = &t + } + if st.sshResult != nil { + s := *st.sshResult + ms.SSH = &s + } + if len(st.metrics) > 0 { + ms.Metrics = make([]metricStatus, 0, len(st.metrics)) + for name, mtr := range st.metrics { + ms.Metrics = append(ms.Metrics, metricStatus{ + Name: name, + Value: mtr.value, + Unit: mtr.unit, + Updated: &mtr.updated, + }) + } + } + st.mu.RUnlock() + } else { + ms.Status = statusUnknown + ms.ICMP = checkNA + ms.TCP = checkNA + } + if group == "" { + p.Machines = append(p.Machines, ms) + return + } + if _, ok := grouped[group]; !ok { + order = append(order, group) + } + grouped[group] = append(grouped[group], ms) + } + + for _, g := range m.cfg.Groups { + for _, mc := range g.Machines { + appendStatus(resolveMachine(mc, g.Name, m.cfg), g.Name) + } + } + for _, mc := range m.cfg.Machines { + if mc.Group == "" { + appendStatus(mc, "") + } + } + + for _, name := range order { + p.Groups = append(p.Groups, groupStatus{Name: name, Machines: grouped[name]}) + } + return p +} + +func (m *Monitor) statusHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(m.StatusPayload()) +} + +func (m *Monitor) historyHandler(w http.ResponseWriter, r *http.Request) { + machine := r.PathValue("machine") + if machine == "" { + http.Error(w, "missing machine", http.StatusBadRequest) + return + } + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil { + limit = 500 + } + entries, err := m.history.Query(machine, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func (m *Monitor) metricsHandler(w http.ResponseWriter, r *http.Request) { + if m.history == nil { + http.Error(w, "no history db", http.StatusInternalServerError) + return + } + machine := r.PathValue("machine") + if machine == "" { + http.Error(w, "missing machine", http.StatusBadRequest) + return + } + q := r.URL.Query() + maxPoints := 100 + if v := q.Get("max_points"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + maxPoints = n + } + } else if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + maxPoints = n + } + } + var minTS, maxTS int64 + if v := q.Get("min"); v != "" { + minTS, _ = strconv.ParseInt(v, 10, 64) + } + if v := q.Get("max"); v != "" { + maxTS, _ = strconv.ParseInt(v, 10, 64) + } + entries, err := m.history.QueryMetrics(machine, q.Get("name"), minTS, maxTS, maxPoints, q.Get("shared") != "") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func (m *Monitor) metricsAggregateHandler(w http.ResponseWriter, r *http.Request) { + if m.history == nil { + http.Error(w, "no history db", http.StatusInternalServerError) + return + } + q := r.URL.Query() + var minTS, maxTS int64 + if v := q.Get("min"); v != "" { + minTS, _ = strconv.ParseInt(v, 10, 64) + } + if v := q.Get("max"); v != "" { + maxTS, _ = strconv.ParseInt(v, 10, 64) + } + metrics, window, err := m.history.QueryMetricsAll(minTS, maxTS) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Window [2]int64 `json:"window"` + Metrics []MetricAggregate `json:"metrics"` + }{window, metrics}) +} + +func (m *Monitor) refreshHandler(w http.ResponseWriter, r *http.Request) { + if !m.interactive { + http.Error(w, "interactive mode disabled", http.StatusForbidden) + return + } + id := r.PathValue("machine") + mc, ok := m.machinesByID[id] + if !ok { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + log.Printf("interactive refresh scheduled for %s (%s)", mc.Name, mc.Host) + m.Refresh(mc) + w.WriteHeader(http.StatusAccepted) +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..14812fa --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,1162 @@ +import { useEffect, useRef, useState } from 'preact/hooks' + +type Status = 'up' | 'degraded' | 'down' | 'unknown' +type Check = 'ok' | 'fail' | 'na' +type ChipState = 'ok' | 'fail' | 'na' | 'off' | 'unknown' + +interface SshResult { + ok: boolean + result: string + exitCode: number | null + lastRun: string | null + error: string | null +} + +interface ScriptChip { + name: string + status: string + info: string +} + +interface MetricStatus { + name: string + value: number + unit: string + updated: string | null +} + +interface Machine { + id: string + name: string + host: string + ip: string + ips: string[] + status: Status + icmp: Check + tcp: Check + lastPing: string | null + sshConfigured: boolean + ssh: SshResult | null + metrics: MetricStatus[] +} + +interface Group { + name: string + machines: Machine[] +} + +interface Stats { + pingInterval: string + pingTimeout: string + tcpPort: number + sshInterval: string + sshEnabled: boolean + dbSize: number + dbRows: number + dbSizeMonth: number +} + +interface StatusPayload { + title: string + interactive: boolean + sharedMetricWindow?: boolean + stats: Stats + metricRanges?: Record + groups: Group[] + machines: Machine[] +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let v = n + let i = -1 + do { + v /= 1024 + i++ + } while (v >= 1024 && i < units.length - 1) + return `${v.toFixed(1)} ${units[i]}` +} + +function formatTime(ts: string | null): string { + if (!ts) return 'never' + const d = new Date(ts) + return isNaN(d.getTime()) ? ts : d.toLocaleString() +} + +function formatMetric(m: MetricStatus): string { + const v = Number.isInteger(m.value) + ? String(m.value) + : m.value.toFixed(1) + return m.unit ? `${v} ${m.unit}` : v +} + +function groupIcon(name: string): string { + const n = name.toLowerCase() + if (n.includes('home')) return 'mdi:home' + if (n.includes('lab')) return 'mdi:flask' + if (n.includes('gpu') || n.includes('compute') || n.includes('render')) return 'mdi:desktop-tower' + if (n.includes('server') || n.includes('data') || n.includes('prod')) return 'mdi:server-network' + if (n.includes('class') || n.includes('aula') || n.includes('room') || n.includes('student')) return 'mdi:school' + return 'mdi:folder-outline' +} + +function Dot({ status }: { status: Status }) { + return +} + +function Chip({ + label, + state, + value, +}: { + label: string + state?: ChipState + value?: string +}) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const ref = useRef(null) + const hasValue = value !== undefined + const show = () => { + const el = ref.current + if (!el) return + const r = el.getBoundingClientRect() + setPos({ x: r.left + r.width / 2, y: r.top }) + } + const hide = () => setPos(null) + return ( + <> + + {label} + {!hasValue && } + + {hasValue && pos && ( + + {value} + + )} + + ) +} + +function CheckState({ state }: { state: ChipState }) { + return ( + + + {state} + + ) +} + +function parseScriptChips(out: string): ScriptChip[] { + const chips: ScriptChip[] = [] + for (const line of out.split('\n')) { + if (line.includes('---')) break + const i = line.indexOf(':') + if (i <= 0) continue + const name = line.slice(0, i).trim() + const rest = line.slice(i + 1) + const j = rest.indexOf(':') + if (j < 0) continue + const status = rest.slice(0, j).trim() + const info = rest.slice(j + 1).trim() + if (!name || !status || status === 'metric') continue + chips.push({ name, status, info }) + } + return chips +} + +function scriptChipState(status: string): ChipState { + if (status === 'on') return 'ok' + if (status === 'off') return 'off' + if (status === 'down') return 'fail' + return 'unknown' +} + +function MachineCard({ + m, + onOpen, + interactive, + onRefresh, +}: { + m: Machine + onOpen: (m: Machine) => void + interactive: boolean + onRefresh: (m: Machine) => void +}) { + const sshState: ChipState = !m.sshConfigured + ? 'off' + : !m.ssh + ? 'unknown' + : m.ssh.ok + ? 'ok' + : 'fail' + const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] + return ( +
onOpen(m)} + role="button" + tabIndex={0} + > +
+ + {m.name} + {interactive && ( + + )} +
+
{m.ip || '—'}
+
+ + + {m.sshConfigured && } +
+ +
e.stopPropagation()}> +
+
+ + ip + {m.ip || 'unknown'} + + + last ping + {formatTime(m.lastPing)} + +
+ + + {m.status} + +
+ {scriptChips.length > 0 && ( + + + {scriptChips.map((c) => ( + + + + + ))} + +
{c.name} + {c.status === 'unknown' ? 'unknown' : c.info || c.status} +
+ )} +
+
+ ) +} + +function Grid({ + machines, + onOpen, + interactive, + onRefresh, +}: { + machines: Machine[] + onOpen: (m: Machine) => void + interactive: boolean + onRefresh: (m: Machine) => void +}) { + return ( +
+ {machines.map((m) => ( + + ))} +
+ ) +} + +interface HistoryEntry { + ts: number + status: Status + ip: string +} + +interface MetricEntry { + ts: number + name: string + value: number + unit: string +} + +interface MetricSeries { + machine: string + samples: MetricEntry[] +} + +interface MetricAggregate { + name: string + unit: string + series: MetricSeries[] +} + +interface MetricAggregatePayload { + window: [number, number] + metrics: MetricAggregate[] +} + +function hashColor(s: string): string { + let h = 5381 + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0 + return `hsl(${h % 360} 62% 42%)` +} + +function formatTick(ts: number): string { + const d = new Date(ts * 1000) + return isNaN(d.getTime()) ? String(ts) : d.toLocaleString() +} + +function LinePlot({ + values, + times, + color, + unit, + yMin, + yMax, + xMin, + xMax, + width = 400, + height = 90, + pad = 10, +}: { + values: number[] + times: number[] + color: string + unit?: string + yMin?: number + yMax?: number + xMin?: number + xMax?: number + width?: number + height?: number + pad?: number +}) { + if (values.length < 2 || times.length !== values.length) return null + const first = values[0]! + const last = values[values.length - 1]! + const dataMin = Math.min(...values) + const dataMax = Math.max(...values) + const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin + const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax + const span = max - min || 1 + const t0 = xMin ?? times[0]! + const t1 = xMax ?? times[times.length - 1]! + const tspan = t1 - t0 || 1 + const xForT = (t: number) => + pad + ((t - t0) / tspan) * (width - pad * 2) + const x = (i: number) => xForT(times[i]!) + const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) + const path = values + .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`) + .join(' ') + + const plotLeft = (pad / width) * 100 + const plotRight = 100 - plotLeft + const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ + left: plotLeft + f * (plotRight - plotLeft), + label: formatTickShort(t0 + f * tspan), + transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', + })) + + const [hover, setHover] = useState<{ i: number; x: number; y: number } | null>(null) + const boxRef = useRef(null) + const tipRef = useRef(null) + + useEffect(() => { + const tip = tipRef.current + const box = boxRef.current + if (!hover || !tip || !box) return + const br = box.getBoundingClientRect() + const w = tip.offsetWidth + const minX = br.left + w / 2 + 4 + const maxX = br.right - w / 2 - 4 + tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` + tip.style.top = `${hover.y}px` + }, [hover]) + + return ( +
+
{ + const r = e.currentTarget.getBoundingClientRect() + const vbx = ((e.clientX - r.left) / r.width) * width + const targetT = + t0 + ((vbx - pad) / (width - pad * 2)) * tspan + let idx = 0 + let best = Infinity + for (let i = 0; i < values.length; i++) { + const d = Math.abs(times[i]! - targetT) + if (d < best) { + best = d + idx = i + } + } + setHover({ + i: idx, + x: r.left + (x(idx) / width) * r.width, + y: r.top + (y(values[idx]!) / height) * r.height, + }) + }} + onMouseLeave={() => setHover(null)} + > + + + + + + {hover && ( +
+ )} + {hover && ( +
+ )} +
+ {hover + ? `${formatTickShort(times[hover.i]!)} · ${formatMetric({ name: '', value: values[hover.i]!, unit: unit ?? '', updated: null })}` + : ''} +
+
+
+ {marks.map((mk, i) => ( + + {mk.label} + + ))} +
+
+ ) +} + +function AggregatePlot({ + series, + color, + xMin, + xMax, + yMin, + yMax, + width = 400, + height = 90, + pad = 10, +}: { + series: MetricSeries[] + color: string + xMin?: number + xMax?: number + yMin?: number + yMax?: number + width?: number + height?: number + pad?: number +}) { + const samples = series.flatMap((s) => s.samples) + if (series.length === 0 || samples.length === 0) return null + const dataMin = Math.min(...samples.map((e) => e.value)) + const dataMax = Math.max(...samples.map((e) => e.value)) + const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin + const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax + const span = max - min || 1 + const t0 = xMin ?? Math.min(...samples.map((e) => e.ts)) + const t1 = xMax ?? Math.max(...samples.map((e) => e.ts)) + const tspan = t1 - t0 || 1 + + const xForT = (t: number) => pad + ((t - t0) / tspan) * (width - pad * 2) + const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) + + const paths = series + .map((s) => { + if (s.samples.length < 2) return null + return s.samples + .map((e, i) => `${i === 0 ? 'M' : 'L'}${xForT(e.ts).toFixed(1)} ${y(e.value).toFixed(1)}`) + .join(' ') + }) + .filter((p): p is string => p !== null) + const lineCount = Math.max(1, paths.length) + const opacity = 1 / lineCount + + const [hover, setHover] = useState<{ + sample: MetricEntry + machine: string + x: number + y: number + } | null>(null) + const boxRef = useRef(null) + const tipRef = useRef(null) + + useEffect(() => { + const tip = tipRef.current + const box = boxRef.current + if (!hover || !tip || !box) return + const br = box.getBoundingClientRect() + const w = tip.offsetWidth + const minX = br.left + w / 2 + 4 + const maxX = br.right - w / 2 - 4 + tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` + tip.style.top = `${hover.y}px` + }, [hover]) + + const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const plotLeft = (pad / width) * 100 + const plotRight = 100 - plotLeft + return { + left: plotLeft + f * (plotRight - plotLeft), + label: formatTickShort(t0 + f * tspan), + transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', + } + }) + + return ( +
+
{ + const r = e.currentTarget.getBoundingClientRect() + const vbx = ((e.clientX - r.left) / r.width) * width + const targetT = t0 + ((vbx - pad) / (width - pad * 2)) * tspan + let best: { sample: MetricEntry; machine: string } | null = null + let bd = Infinity + for (const s of series) { + for (const e0 of s.samples) { + const d = Math.abs(e0.ts - targetT) + if (d < bd) { + bd = d + best = { sample: e0, machine: s.machine } + } + } + } + if (!best) return + setHover({ + sample: best.sample, + machine: best.machine, + x: r.left + (xForT(best.sample.ts) / width) * r.width, + y: r.top + (y(best.sample.value) / height) * r.height, + }) + }} + onMouseLeave={() => setHover(null)} + > + + {paths.map((d, i) => ( + + ))} + + {hover && ( +
+ )} + {hover && ( +
+ )} +
+ {hover + ? `${formatTickShort(hover.sample.ts)} · ${hover.machine}: ${formatMetric({ name: hover.sample.name, value: hover.sample.value, unit: hover.sample.unit, updated: null })}` + : ''} +
+
+
+ {marks.map((mk, i) => ( + + {mk.label} + + ))} +
+
+ ) +} + +function formatTickShort(ts: number): string { + const d = new Date(ts * 1000) + if (isNaN(d.getTime())) return String(ts) + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}` +} + +interface Incident { + status: Status + start: number + end: number + count: number +} + +function groupIncidents(entries: HistoryEntry[]): Incident[] { + const out: Incident[] = [] + for (const e of entries) { + const last = out[out.length - 1] + if (last && last.status === e.status) { + last.end = e.ts + last.count++ + } else { + out.push({ status: e.status, start: e.ts, end: e.ts, count: 1 }) + } + } + return out +} + +function StatusModal({ + m, + onClose, + ranges, + sharedMetricWindow, +}: { + m: Machine + onClose: () => void + ranges?: Record + sharedMetricWindow?: boolean +}) { + const [hist, setHist] = useState(null) + const [metricEntries, setMetricEntries] = useState(null) + const ticksRef = useRef(null) + + useEffect(() => { + const el = ticksRef.current + if (el) el.scrollLeft = el.scrollWidth + }, [hist]) + + const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] + const scriptText = (() => { + if (!m.ssh) return '' + const out = m.ssh.error || m.ssh.result || '(no output)' + const sep = out.indexOf('---') + return sep === -1 ? out : out.slice(sep + 3).trim() + })() + + useEffect(() => { + let cancelled = false + setHist(null) + fetch(`/api/history/${encodeURIComponent(m.id)}?limit=500`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((entries: HistoryEntry[]) => { + if (!cancelled) setHist(entries) + }) + .catch((e) => { + if (!cancelled) setHist([]) + console.error('history fetch failed:', e) + }) + return () => { + cancelled = true + } + }, [m.id]) + + const metricNames = (m.metrics ?? []).map((mt) => mt.name) + + useEffect(() => { + let cancelled = false + setMetricEntries(null) + const nowS = Math.floor(Date.now() / 1000) + const min = nowS - 86400 + const shared = sharedMetricWindow ? '&shared=1' : '' + Promise.all( + metricNames.map((name) => + fetch( + `/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}&max_points=100${shared}` + ).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + ) + ) + .then((lists: MetricEntry[][]) => { + if (cancelled) return + const all: MetricEntry[] = [] + for (const l of lists) all.push(...l) + setMetricEntries(all) + }) + .catch((e) => { + if (!cancelled) setMetricEntries([]) + console.error('metrics fetch failed:', e) + }) + return () => { + cancelled = true + } + }, [m.id, metricNames.join(','), sharedMetricWindow]) + + const metricPlots = (() => { + if (!metricEntries) return null + const byName = new Map() + for (const e of metricEntries) { + if (!byName.has(e.name)) byName.set(e.name, []) + byName.get(e.name)!.push(e) + } + const winMax = Math.floor(Date.now() / 1000) + let winMin = winMax - 86400 + if (sharedMetricWindow && metricEntries.length > 0) { + const earliest = Math.min(...metricEntries.map((e) => e.ts)) + winMin = Math.max(winMin, earliest) + } + const plots: { + name: string + unit: string + values: number[] + times: number[] + yMin?: number + yMax?: number + xMin?: number + xMax?: number + }[] = [] + for (const [name, entries] of byName) { + const r = ranges?.[name] + plots.push({ + name, + unit: entries[0]?.unit ?? '', + values: entries.map((e) => e.value), + times: entries.map((e) => e.ts), + yMin: r?.min, + yMax: r?.max, + xMin: sharedMetricWindow ? winMin : undefined, + xMax: sharedMetricWindow ? winMax : undefined, + }) + } + plots.sort((a, b) => a.name.localeCompare(b.name)) + return plots + })() + + const ticks = hist ? [...hist].reverse() : [] + const counts = { up: 0, degraded: 0, down: 0, unknown: 0 } + hist?.forEach((e) => { + if (counts[e.status] !== undefined) counts[e.status]++ + }) + + return ( + + ) +} + +export function App() { + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + const [modal, setModal] = useState(null) + const [toast, setToast] = useState(null) + const [agg, setAgg] = useState(null) + + useEffect(() => { + if (!toast) return + const id = setTimeout(() => setToast(null), 2500) + return () => clearTimeout(id) + }, [toast]) + + useEffect(() => { + const poll = async () => { + try { + const res = await fetch('/api/status') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const d = (await res.json()) as StatusPayload + setData(d) + setErr(null) + document.title = d.title || 'Status Page' + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + } + poll() + const id = setInterval(poll, 3000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + let cancelled = false + const poll = async () => { + try { + const nowS = Math.floor(Date.now() / 1000) + const res = await fetch( + `/api/metrics/aggregate?min=${nowS - 86400}&max=${nowS}` + ) + if (!res.ok) return + const d = (await res.json()) as MetricAggregatePayload + if (!cancelled) setAgg(d) + } catch { + /* keep last known aggregate */ + } + } + poll() + const id = setInterval(poll, 15000) + return () => { + cancelled = true + clearInterval(id) + } + }, []) + + const openMachine = (m: Machine) => { + setModal(m) + } + + const refreshMachine = (m: Machine) => { + fetch(`/api/refresh/${encodeURIComponent(m.id)}`, { method: 'POST' }) + .then((r) => { + if (r.ok) setToast(`refresh scheduled for ${m.name}`) + }) + .catch((e) => console.error('refresh failed:', e)) + } + + return ( +
+
+

{data?.title || 'Status Page'}

+ {err && offline ({err})} +
+ up + degraded + down + unknown +
+
+ + {data && ( + <> + {data.groups?.map((g) => ( +
+

+ + {g.name} + {g.machines.length} +

+ +
+ ))} + {data.machines?.length > 0 && ( +
+

+ + Machines + {data.machines.length} +

+ +
+ )} + {data.groups?.length === 0 && data.machines?.length === 0 && ( +

no machines configured

+ )} + + )} + + {agg && agg.metrics.length > 0 && (() => { + const winMax = agg.window[1] + const winMin = Math.max(winMax - 86400, agg.window[0]) + return ( +
+

+ + Metric Aggregates + {agg.metrics.length} +

+
+ {agg.metrics.map((m) => ( +
+
+ + {m.name} + + {m.series.length} machine{m.series.length === 1 ? '' : 's'} + +
+ +
+ ))} +
+
+ ) + })()} + + {data && ( +
+ ping every {data.stats.pingInterval} + ping timeout {data.stats.pingTimeout} + {data.stats.sshEnabled && ( + ssh every {data.stats.sshInterval} + )} + history db {formatBytes(data.stats.dbSize)} + {data.stats.dbSizeMonth > 0 && ( + + ~{formatBytes(data.stats.dbSizeMonth)} in 1 month + + )} +
+ )} + + {modal && ( + setModal(null)} + ranges={data?.metricRanges} + sharedMetricWindow={data?.sharedMetricWindow} + /> + )} + {toast &&
{toast}
} +
+ ) +} diff --git a/src/jsx.d.ts b/src/jsx.d.ts new file mode 100644 index 0000000..30a6202 --- /dev/null +++ b/src/jsx.d.ts @@ -0,0 +1,13 @@ +declare module 'preact/jsx-runtime' { + namespace JSX { + interface IntrinsicElements { + 'iconify-icon': { + icon?: string + width?: number | string + height?: number | string + } + } + } +} + +export {} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..39da5ba --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { render } from 'preact' +import { App } from './app' +import '@fontsource/inria-sans/400.css' +import '@fontsource/inria-sans/700.css' +import '@fontsource/inria-sans/400-italic.css' +import '@fontsource/iosevka-etoile/400.css' +import '@fontsource/iosevka-etoile/600.css' +import './styles.css' + +render(, document.getElementById('app')!) diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..ebb84f7 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,1018 @@ +:root { + --bg: #f2f2f2; + --card: #fafafa; + --ink: #111; + --border: 2px solid #111; + --shadow: 0.35rem 0.35rem 0 #888; + --shadow-lg: 0.55rem 0.55rem 0 #888; + --radius: 0.5rem; + --green: #1aad4b; + --yellow: #e5b400; + --mono: "Iosevka Etoile", ui-monospace, "Cascadia Code", "JetBrains Mono", + Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + margin: 0; + min-height: 100%; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--ink); + font-family: "Inria Sans", -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + sans-serif; +} + +.page { + flex: 1; + width: 100%; + max-width: 75rem; + margin: 0 auto; + padding: 1.5rem; + display: flex; + flex-direction: column; +} + +#app { + display: flex; + flex-direction: column; + flex: 1; +} + +.header { + display: flex; + align-items: baseline; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.header h1 { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; + display: inline-block; + padding: 0.25rem 0.875rem; + background: var(--card); + border: var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.conn-err { + color: #a00; + font-weight: 700; +} + +.legend { + display: inline-flex; + margin-left: auto; + font-weight: 700; + font-size: 0.9rem; + flex-wrap: wrap; +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: var(--card); + border: var(--border); + padding: 0.25rem 0.75rem; +} + +.legend span:not(:first-child) { + border-left: none; +} + +.legend span:first-child { + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); +} + +.legend span:last-child { + border-top-right-radius: var(--radius); + border-bottom-right-radius: var(--radius); +} + +.group { + margin-bottom: 2rem; +} + +.group-name { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 1.25rem; + font-weight: 700; + margin: 0 0 1rem; +} + +.group-count { + background: var(--card); + border: var(--border); + border-radius: 999px; + box-shadow: 0.2rem 0.2rem 0 #888; + font-size: 0.85rem; + padding: 0.125rem 0.75rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.machine { + position: relative; + background: var(--card); + border: var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.75rem; + cursor: pointer; + user-select: none; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.machine[data-status="up"] { + background: #d9f2e0; +} + +.machine[data-status="degraded"] { + background: #fff3b8; +} + +.machine[data-status="down"] { + background: #2b2b2b; + color: #f2f2f2; +} + +.machine[data-status="unknown"] { + background-color: #e4e4e4; + background-image: linear-gradient( + 115deg, + transparent 35%, + rgba(255, 255, 255, 0.75) 50%, + transparent 65% + ); + background-size: 250% 100%; + background-repeat: no-repeat; + animation: glimmer 2.4s ease-in-out infinite; +} + +@keyframes glimmer { + 0% { + background-position: 140% 0; + } + 100% { + background-position: -40% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .machine[data-status="unknown"], + .chip.unknown, + .chip.na { + animation: none; + } +} + +.machine[data-status="down"] .machine-ip { + color: #bbb; +} + +.machine[data-status="down"] .dot { + border-color: #888; +} + +.machine[data-status="down"] .popover { + color: var(--ink); +} + +.machine[data-status="down"] .popover .dot { + border-color: #111; +} + +.machine:hover, +.machine:focus-visible { + z-index: 50; + outline: none; +} + +.machine:active { + transform: translate(0.15rem, 0.15rem); + box-shadow: 0.2rem 0.2rem 0 #888; +} + +.machine:active:has(.refresh-btn:active) { + transform: none; + box-shadow: var(--shadow); +} + +.machine-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.375rem; +} + +.machine-name { + font-weight: 700; + font-size: 0.95rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.refresh-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + padding: 0; + border: var(--border); + border-radius: var(--radius); + background: #fff; + color: #111; + cursor: pointer; + box-shadow: 0.125rem 0.125rem 0 #888; + transition: transform 0.05s ease, box-shadow 0.05s ease; +} + +.refresh-btn:hover { + transform: translate(-0.0625rem, -0.0625rem); + box-shadow: 0.1875rem 0.1875rem 0 #888; +} + +.refresh-btn:active { + transform: translate(0.0625rem, 0.0625rem); + box-shadow: 0 0 0 #888; +} + +.refresh-btn iconify-icon { + display: block; +} + +.machine-ip { + font-family: var(--mono); + font-weight: 600; + font-size: 0.8rem; + color: #555; + margin-bottom: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chips { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.chip { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.3125rem; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border: var(--border); + border-radius: 999px; + padding: 0.125rem 0.5rem; + background: #fff; + color: var(--ink); +} + +.chip-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; +} + +.chip.ok { + background: #c7ecd3; + color: #0a5c22; +} + +.chip.fail { + background: #ffd6d6; + color: #9c0000; +} + +.chip.na, +.chip.off { + background: #e5e5e5; + color: #666; +} + +.chip.unknown, +.chip.na { + background-color: #e5e5e5; + background-image: linear-gradient( + 115deg, + transparent 35%, + rgba(255, 255, 255, 0.9) 50%, + transparent 65% + ); + background-size: 250% 100%; + background-repeat: no-repeat; + animation: glimmer 2.4s ease-in-out infinite; + color: #777; +} + +.chip-pop { + position: fixed; + left: 0; + top: 0; + transform: translate(-50%, calc(-100% - 0.5rem)); + z-index: 300; + pointer-events: none; + background: #111; + color: #f2f2f2; + border: var(--border); + border-radius: 0.375rem; + box-shadow: 0.1875rem 0.1875rem 0 #888; + padding: 0.25rem 0.5625rem; + font-size: 0.72rem; + font-weight: 700; + text-transform: none; + letter-spacing: 0; + white-space: nowrap; +} + +.machine[data-status="down"] .chip { + background: #2a2a2a; + color: #eee; + border-color: #555; +} + +.badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + border: var(--border); + border-radius: 999px; + padding: 0.0625rem 0.5rem; +} + +.badge.up { + background: var(--green); + color: #fff; +} + +.badge.degraded { + background: var(--yellow); + color: #111; +} + +.badge.down { + background: #2b2b2b; + color: #fff; +} + +.badge.unknown { + background: #bbb; + color: #111; +} + +.dot { + flex: none; + width: 0.875rem; + height: 0.875rem; + border-radius: 50%; + border: 2px solid #111; +} + +.dot.up { + background: var(--green); + box-shadow: 0 0 0.5rem 0.0625rem rgba(26, 173, 75, 0.7); +} + +.dot.degraded { + background: var(--yellow); +} + +.dot.down { + background: #2b2b2b; +} + +.dot.unknown { + background: #ccc; +} + +.popover { + display: none; + position: absolute; + left: 50%; + top: calc(100% + 0.125rem); + transform: translateX(-50%); + width: calc(100% - 1rem); + max-width: 90vw; + z-index: 60; + background: var(--card); + border: var(--border); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + box-shadow: var(--shadow); + padding: 0.75rem; + text-align: left; + pointer-events: none; +} + +.machine:hover .popover, +.machine:focus-visible .popover { + display: block; + animation: popover-in 0.16s ease; + transform-origin: top center; +} + +@keyframes popover-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(-0.375rem) scaleY(0.5); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scaleY(1); + } +} + +.popover-head { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0.375rem 0.75rem; + padding-bottom: 0.625rem; + margin-bottom: 0.625rem; + border-bottom: 1px dashed #bbb; +} + +.popover-status { + grid-column: 2; + grid-row: 1 / 3; + justify-self: end; + align-self: start; + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.popover-meta { + display: grid; + grid-template-columns: 1fr; + gap: 0.375rem; + min-width: 0; +} + +.popover-meta-item { + display: flex; + flex-direction: column; + min-width: 0; +} + +.popover-meta-key { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #888; +} + +.popover-meta-val { + font-size: 0.82rem; + font-weight: 700; + word-break: break-word; +} + +.popover-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.popover-table th { + text-align: left; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.68rem; + color: #888; + padding: 0.15rem 0.625rem 0.15rem 0; + white-space: nowrap; + vertical-align: top; +} + +.popover-table td { + font-weight: 700; + word-break: break-word; + padding: 0.15rem 0; +} + +.script-out { + background: #1a1d1a; + color: #e9f7ea; + border-radius: 0.5rem; + border: 1px solid #2a2e2a; + padding: 0.5rem 0.625rem; + font-family: var(--mono); + font-weight: 600; + font-size: 0.88rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + max-height: 11.25rem; + overflow-y: auto; +} + +.script-out.tall { + max-height: 21.25rem; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1.25rem; +} + +.modal { + background: var(--card); + border: var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + padding: 1.25rem; + width: 45rem; + max-width: 100%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-head { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.75rem; +} + +.modal-head h2 { + margin: 0; + flex: 1; + font-size: 1.3rem; +} + +.modal-close { + background: var(--card); + border: var(--border); + border-radius: 0.625rem; + box-shadow: 0.2rem 0.2rem 0 #888; + font-size: 1.2rem; + font-weight: 700; + width: 2.125rem; + height: 2.125rem; + cursor: pointer; + line-height: 1; +} + +.modal-close:hover { + transform: translate(-0.1rem, -0.1rem); +} + +.modal-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem 1rem; +} + +.modal-cell { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.modal-cell.wide { + grid-column: span 2; +} + +.modal-cell.full { + grid-column: 1 / -1; +} + +.modal-cell-label { + font-weight: 700; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modal-cell-value { + font-weight: 700; + font-size: 1rem; + word-break: break-word; +} + +.metric-plots { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.metric-plot { + display: flex; + flex-direction: column; + gap: 0.25rem; + border: var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.625rem; +} + +.metric-plot-head { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.78rem; +} + +.metric-dot { + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + border: 1.5px solid #111; +} + +.metric-latest { + margin-left: auto; + font-family: var(--mono); + font-weight: 600; + font-size: 0.8rem; +} + +.metric-latest.out-of-range { + color: #e5484d; + text-decoration: underline wavy #e5484d; + text-underline-offset: 0.15em; +} + +.line-plot { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.line-plot-wrap { + position: relative; +} + +.line-plot-box { + position: relative; + cursor: crosshair; +} + +.plot-hover-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: #111; + opacity: 0.35; + pointer-events: none; +} + +.plot-hover-dot { + position: absolute; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #111; + border: 2px solid #fff; + transform: translate(-50%, -50%); + pointer-events: none; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +.plot-tip { + position: fixed; + transform: translate(-50%, calc(-100% - 0.375rem)); + background: #111; + color: #fff; + font-family: var(--mono); + font-weight: 600; + font-size: 0.62rem; + padding: 0.15rem 0.375rem; + border-radius: 0.25rem; + white-space: nowrap; + pointer-events: none; + z-index: 400; + left: 0; + top: 0; +} + +.agg-count { + margin-left: auto; + font-family: var(--mono); + font-size: 0.68rem; + color: #777; +} + +.plot-marks { + position: relative; + height: 0.8rem; + margin-top: 0.5rem; + pointer-events: none; +} + +.plot-mark { + position: absolute; + top: 0; + transform: translateX(-50%); + font-family: var(--mono); + font-weight: 600; + font-size: 0.62rem; + color: #777; + white-space: nowrap; +} + +.modal-cell-value.mono { + font-family: var(--mono); + font-weight: 600; + font-size: 0.9rem; +} + +.metric-value { + font-family: var(--mono); + font-weight: 600; +} + +@media (max-width: 640px) { + .modal-grid { + grid-template-columns: 1fr; + } + + .modal-cell.wide { + grid-column: auto; + } +} + +.check-state { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.check-dot { + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + border: 1.5px solid #111; +} + +.check-dot.ok { + background: var(--green); +} + +.check-dot.fail { + background: #e5484d; +} + +.check-dot.na, +.check-dot.off { + background: #ccc; +} + +.check-dot.unknown { + background: #ccc; + animation: glimmer 2.4s ease-in-out infinite; +} + +.empty { + background: var(--card); + border: var(--border); + border-radius: var(--radius); + padding: 1.5rem; + font-weight: 600; +} + +.hist { + margin-top: 1rem; + border-top: var(--border); + padding-top: 0.875rem; +} + +.hist-head { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.625rem; + font-size: 0.95rem; + flex-wrap: wrap; +} + +.hist-counts { + display: flex; + gap: 0.625rem; + font-size: 0.8rem; + font-weight: 700; + margin-left: auto; +} + +.hist-counts span { + display: inline-flex; + align-items: center; + gap: 0.3125rem; +} + +.hist-counts .dot { + width: 0.625rem; + height: 0.625rem; +} + +.ticks { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + gap: 0.125rem; + overflow-x: auto; + padding-bottom: 1.375rem; + margin-bottom: 0.75rem; +} + +.tick-cell { + position: relative; + flex: none; + display: inline-flex; + align-items: flex-start; +} + +.tick { + width: 0.25rem; + height: 1.375rem; + border-radius: 0.125rem; + border: 1px solid #111; +} + +.tick.up { + background: var(--green); + border-color: color-mix(in srgb, var(--green), black 25%); +} + +.tick.degraded { + background: var(--yellow); + border-color: color-mix(in srgb, var(--yellow), black 25%); +} + +.tick.down { + background: #2b2b2b; + border-color: color-mix(in srgb, #2b2b2b, white 30%); +} + +.tick.unknown { + background: #ccc; + border-color: color-mix(in srgb, #ccc, black 25%); +} + +.tick-mark { + position: absolute; + top: calc(100% + 0.3125rem); + left: 50%; + transform: translateX(-50%); + font-family: var(--mono); + font-weight: 600; + font-size: 0.62rem; + color: #777; + white-space: nowrap; + pointer-events: none; +} + +.incidents { + max-height: 13.75rem; + overflow-y: auto; + background: #111; + border-radius: 0.625rem; + padding: 0.5rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.incidents-title { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #999; + margin-bottom: 0.125rem; +} + +.incident-row { + display: flex; + align-items: center; + gap: 0.625rem; + font-family: var(--mono); + font-weight: 600; + font-size: 0.72rem; + color: #e8e8e8; +} + +.incident-row .badge { + font-size: 0.6rem; + min-width: 4.625rem; + text-align: center; +} + +.incident-row .muted { + color: #999; + margin-left: auto; +} + +.footer { + display: flex; + flex-wrap: wrap; + gap: 0.375rem 1.25rem; + margin-top: auto; + padding-top: 0.75rem; + border-top: var(--border); + font-family: var(--mono); + font-weight: 600; + font-size: 0.8rem; + color: #777; +} + +.footer span { + white-space: nowrap; +} + +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 200; + background: #111; + color: #f2f2f2; + border: var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.625rem 1.125rem; + font-weight: 700; + font-size: 0.9rem; + animation: toast-in 0.2s ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 0.5rem); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d682aa5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2b671b6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import preact from '@preact/preset-vite' + +export default defineConfig({ + plugins: [preact()], + server: { + proxy: { + '/api': 'http://localhost:5000', + }, + }, +})