├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── Taskfile.yml ├── client-bundle ├── dist │ ├── main.css │ └── main.js ├── index.html ├── main.css ├── main.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── tailwind.config.js ├── tsconfig.json └── vite.config.js ├── example └── gomon.config.yml ├── go.mod ├── go.sum ├── internal ├── app │ └── app.go ├── config │ └── config.go ├── console │ └── streams.go ├── notification │ ├── notification.go │ └── notifier.go ├── process │ ├── dummy.go │ ├── oob.go │ ├── process.go │ ├── process_posix.go │ ├── process_test.go │ └── process_windows.go ├── proxy │ └── proxy.go ├── utils │ ├── database.go │ └── state.go ├── watcher │ └── watcher.go └── webui │ ├── index.templ │ ├── index_templ.go │ ├── server.go │ └── static.go ├── main.go └── screenshot └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __debug_bin* 3 | .vscode 4 | node_modules 5 | bin -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Simple Makefile for a Go project 2 | 3 | # Build the application 4 | all: build 5 | 6 | bindir: 7 | @mkdir -p bin 8 | 9 | build: bindir client 10 | @echo "Building..." 11 | @go build --race -o gomon main.go 12 | 13 | install: client 14 | @echo "Installing..." 15 | @go install github.com/jdudmesh/gomon 16 | 17 | # Run the application 18 | run: 19 | @go run main.go 20 | 21 | # Create DB container 22 | docker-run: 23 | @if docker compose up 2>/dev/null; then \ 24 | : ; \ 25 | else \ 26 | echo "Falling back to Docker Compose V1"; \ 27 | docker-compose up; \ 28 | fi 29 | 30 | # Shutdown DB container 31 | docker-down: 32 | @if docker compose down 2>/dev/null; then \ 33 | : ; \ 34 | else \ 35 | echo "Falling back to Docker Compose V1"; \ 36 | docker-compose down; \ 37 | fi 38 | 39 | # Test the application 40 | test: 41 | @echo "Testing..." 42 | @go test ./... -v 43 | 44 | # Clean the binary 45 | clean: 46 | @echo "Cleaning..." 47 | @rm -f main 48 | 49 | client: 50 | @echo "Building client..." 51 | @cd client-bundle && pnpm i && pnpm run build 52 | 53 | .PHONY: all build run test clean -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | `gomon` is a tool to monitor and hot reload go programs. The DX for many front end frameworks like NextJS is very good. Changing a Programs reload when file changes are detected and, for web apps, browsers are automatically reloaded. Commonly tools like `nodemon` and `Vite` are used to achieve this. 4 | 5 | The aim is to provide a similar experience to these tools for Go programs. 6 | 7 | For example usage see [this example](https://github.com/jdudmesh/gomon-example) 8 | 9 | ## Key features 10 | 11 | - `go run` a project and force hard restart based on file changes defined by a list of file extensions (typically `*.go`) 12 | - if the process fails to start then it is restarted using an exponential backoff strategy for up to 1 minute 13 | - alternatively specify a different initial command 14 | - perform a soft restart (e.g. reload templates) based on a file changes defined by second list of file extensions (typically `*.html`) 15 | - ignore file changes in specified directories (e.g. `vendor`) 16 | - load environment variables from e.g. `.env` files 17 | - run scripts for generated files based on globs e.g. \*.templ 18 | - Proxy http requests to the downstream project and automatically inject an HMR script 19 | - Fire a page reload in the browser on hard or soft restart using SSE 20 | - Implements a Web UI which displays and can search console logs with history 21 | - prestart - run a list of tasks before running the main entrypoint e.g. `go generate` 22 | - proxy only - if you're running your project in a debugger you can run the proxy only so that downstream proxies (e.g. caddy) aren't broken 23 | 24 | ## UI Screenshot 25 | 26 | ![UI Screenshot](https://github.com/jdudmesh/gomon/blob/main/screenshot/screenshot.png?raw=true) 27 | 28 | # Usage 29 | 30 | ## Installation 31 | 32 | Install the tool as follows: 33 | 34 | ```bash 35 | go install github.com/jdudmesh/gomon@latest 36 | ``` 37 | 38 | ## Basic Usage 39 | 40 | In your project directory run: 41 | 42 | ```bash 43 | gomon 44 | ``` 45 | 46 | This will simply `go run` your project and restart on changes to `*.go` files. 47 | 48 | `gomon` supports a number of command line parameters: 49 | 50 | ```bash 51 | --conf - specify a config file (see below) 52 | --dir - use an alternative root directory 53 | --env - a comma separated list of environment variable files to load e.g. .env,.env.local 54 | --proxy-only - don't start the child process, just run the proxy 55 | ``` 56 | 57 | ## Working Directory 58 | 59 | The working directory for `gomon` is the current directory unless: 60 | 61 | 1. if a root directory is specified then that is used 62 | 2. otherwise, if specified in the config file that is used 63 | 3. otherwise, the current directory is used 64 | 65 | ## Config files 66 | 67 | If a config file is specified, or one is found in the working directory, then that is used. Command line flags override config file values. 68 | 69 | The config file is a YAML file as follows: 70 | 71 | ```yaml 72 | command: 73 | entrypoint: 74 | entrypointArgs: 75 | templatePathGlob: 76 | 77 | envFiles: # changes to env files always trigger a hard reload 78 | - 79 | - ... 80 | 81 | reloadOnUnhandled: true|false #if true then any file changes (not just .go files) will restart process 82 | 83 | rootDirectory: 84 | entrypoint: 85 | entrypointArgs: [] 86 | 87 | excludePaths: [] 88 | hardReload: [] 89 | softReload: [] 90 | 91 | prestart: # these tasks will always run before `go run ` e.g. `go generate` 92 | - 93 | 94 | generated: 95 | : 96 | - 97 | - "__soft_reload" | "__hard_reload" #trigger manual reload on completion 98 | 99 | envFiles: 100 | - 101 | reloadOnUnhandled: true|false # cold reload by default if file not otherwise handled 102 | proxy: 103 | enabled: true # start a proxy server to inject HMR script 104 | port: 105 | downstream: 106 | host: # e.g. localhost:8081 107 | timeout: # downstream request timeout 108 | ui: 109 | enabled: true 110 | port: 4001 111 | ``` 112 | 113 | ## Web UI 114 | `gomon` now supports a Web UI which displays captured console output. The aim is to make this fully searchable and to pretty print JSON logs where possible. 115 | 116 | To enable ass the `ui` key to the config and set `enabled` to `true`. By default the UI listens on port 4001 but you can change it in the config. All log events are stored in a SQLITE database in a `.gomon` folder in the target project. This means that the output of previous runs of the code persists and can be searched. Don't forget to put `.gomon` in your `.gitignore` file. 117 | 118 | 119 | ## Template files 120 | If your project contains Go HTML templates then you can reload them by defining them in the config file using the softReload property. `gomon` uses IPC to trigger a reload and wait for confirmation before triggering a hot reload in the downstream browsers. The project must make use of the [the `gomon` client](https://github.com/jdudmesh/gomon-client). 121 | 122 | For example: 123 | ```go 124 | package main 125 | 126 | import ( 127 | "net/http" 128 | 129 | client "github.com/jdudmesh/gomon-client" 130 | "github.com/labstack/echo/v4" 131 | "github.com/labstack/echo/v4/middleware" 132 | "github.com/labstack/gommon/log" 133 | ) 134 | 135 | func main() { 136 | log.Info("starting server") 137 | 138 | e := echo.New() 139 | e.Use(middleware.Logger()) 140 | e.Use(middleware.Recover()) 141 | 142 | t, err := client.NewEcho("./views/*html") 143 | if err != nil { 144 | log.Fatal(err) 145 | } 146 | defer t.Close() 147 | 148 | go func() { 149 | err := t.ListenAndServe() 150 | if err != nil { 151 | log.Error(err) 152 | } 153 | }() 154 | 155 | e.Renderer = t 156 | 157 | e.GET("/", func(c echo.Context) error { 158 | return c.Render(http.StatusOK, "index.html", "World") 159 | }) 160 | 161 | e.Logger.Fatal(e.Start(":8080")) 162 | } 163 | ``` 164 | 165 | At the moment on a generic reloader and Labstack Echo are supported. Please raise an issue if you would like other support added for other frameworks. 166 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | tasks: 3 | client/local: 4 | cmds: 5 | - go mod edit -replace github.com/jdudmesh/gomon-client=../gomon-client 6 | client/git: 7 | cmds: 8 | - go mod edit -dropreplace github.com/jdudmesh/gomon-client 9 | generate/templ: 10 | cmds: 11 | - templ generate 12 | install: 13 | cmds: 14 | - go install 15 | deps: 16 | - generate/templ -------------------------------------------------------------------------------- /client-bundle/dist/main.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}:root,[data-theme]{background-color:hsl(var(--b1) / var(--tw-bg-opacity, 1));color:hsl(var(--bc) / var(--tw-text-opacity, 1))}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--pf: 259 94% 44%;--sf: 314 100% 40%;--af: 174 75% 39%;--nf: 214 20% 14%;--in: 198 93% 60%;--su: 158 64% 52%;--wa: 43 96% 56%;--er: 0 91% 71%;--inc: 198 100% 12%;--suc: 158 100% 10%;--wac: 43 100% 11%;--erc: 0 100% 14%;--rounded-box: 1rem;--rounded-btn: .5rem;--rounded-badge: 1.9rem;--animation-btn: .25s;--animation-input: .2s;--btn-text-case: uppercase;--btn-focus-scale: .95;--border-btn: 1px;--tab-border: 1px;--tab-radius: .5rem;--p: 259 94% 51%;--pc: 259 96% 91%;--s: 314 100% 47%;--sc: 314 100% 91%;--a: 174 75% 46%;--ac: 174 75% 11%;--n: 214 20% 21%;--nc: 212 19% 87%;--b1: 0 0% 100%;--b2: 0 0% 95%;--b3: 180 2% 90%;--bc: 215 28% 17%}@media (prefers-color-scheme: dark){:root{color-scheme:dark;--pf: 262 80% 43%;--sf: 316 70% 43%;--af: 175 70% 34%;--in: 198 93% 60%;--su: 158 64% 52%;--wa: 43 96% 56%;--er: 0 91% 71%;--inc: 198 100% 12%;--suc: 158 100% 10%;--wac: 43 100% 11%;--erc: 0 100% 14%;--rounded-box: 1rem;--rounded-btn: .5rem;--rounded-badge: 1.9rem;--animation-btn: .25s;--animation-input: .2s;--btn-text-case: uppercase;--btn-focus-scale: .95;--border-btn: 1px;--tab-border: 1px;--tab-radius: .5rem;--p: 262 80% 50%;--pc: 0 0% 100%;--s: 316 70% 50%;--sc: 0 0% 100%;--a: 175 70% 41%;--ac: 0 0% 100%;--n: 213 18% 20%;--nf: 212 17% 17%;--nc: 220 13% 69%;--b1: 212 18% 14%;--b2: 213 18% 12%;--b3: 213 18% 10%;--bc: 220 13% 69%}}[data-theme=light]{color-scheme:light;--pf: 259 94% 44%;--sf: 314 100% 40%;--af: 174 75% 39%;--nf: 214 20% 14%;--in: 198 93% 60%;--su: 158 64% 52%;--wa: 43 96% 56%;--er: 0 91% 71%;--inc: 198 100% 12%;--suc: 158 100% 10%;--wac: 43 100% 11%;--erc: 0 100% 14%;--rounded-box: 1rem;--rounded-btn: .5rem;--rounded-badge: 1.9rem;--animation-btn: .25s;--animation-input: .2s;--btn-text-case: uppercase;--btn-focus-scale: .95;--border-btn: 1px;--tab-border: 1px;--tab-radius: .5rem;--p: 259 94% 51%;--pc: 259 96% 91%;--s: 314 100% 47%;--sc: 314 100% 91%;--a: 174 75% 46%;--ac: 174 75% 11%;--n: 214 20% 21%;--nc: 212 19% 87%;--b1: 0 0% 100%;--b2: 0 0% 95%;--b3: 180 2% 90%;--bc: 215 28% 17%}[data-theme=dark]{color-scheme:dark;--pf: 262 80% 43%;--sf: 316 70% 43%;--af: 175 70% 34%;--in: 198 93% 60%;--su: 158 64% 52%;--wa: 43 96% 56%;--er: 0 91% 71%;--inc: 198 100% 12%;--suc: 158 100% 10%;--wac: 43 100% 11%;--erc: 0 100% 14%;--rounded-box: 1rem;--rounded-btn: .5rem;--rounded-badge: 1.9rem;--animation-btn: .25s;--animation-input: .2s;--btn-text-case: uppercase;--btn-focus-scale: .95;--border-btn: 1px;--tab-border: 1px;--tab-radius: .5rem;--p: 262 80% 50%;--pc: 0 0% 100%;--s: 316 70% 50%;--sc: 0 0% 100%;--a: 175 70% 41%;--ac: 0 0% 100%;--n: 213 18% 20%;--nf: 212 17% 17%;--nc: 220 13% 69%;--b1: 212 18% 14%;--b2: 213 18% 12%;--b3: 213 18% 10%;--bc: 220 13% 69%}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.alert{display:grid;width:100%;grid-auto-flow:row;align-content:flex-start;align-items:center;justify-items:center;gap:1rem;text-align:center;border-width:1px;--tw-border-opacity: 1;border-color:hsl(var(--b2) / var(--tw-border-opacity));padding:1rem;--tw-text-opacity: 1;color:hsl(var(--bc) / var(--tw-text-opacity));border-radius:var(--rounded-box, 1rem);--alert-bg: hsl(var(--b2));--alert-bg-mix: hsl(var(--b1));background-color:var(--alert-bg)}@media (min-width: 640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:left}}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.btn{display:inline-flex;flex-shrink:0;cursor:pointer;-webkit-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-color:transparent;border-color:hsl(var(--b2) / var(--tw-border-opacity));text-align:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;border-radius:var(--rounded-btn, .5rem);height:3rem;padding-left:1rem;padding-right:1rem;min-height:3rem;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;border-width:var(--border-btn, 1px);animation:button-pop var(--animation-btn, .25s) ease-out;text-transform:var(--btn-text-case, uppercase);--tw-border-opacity: 1;--tw-bg-opacity: 1;background-color:hsl(var(--b2) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--bc) / var(--tw-text-opacity));outline-color:hsl(var(--bc) / 1)}.btn-disabled,.btn[disabled],.btn:disabled{pointer-events:none}.btn-group>input[type=radio].btn{-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn-group>input[type=radio].btn:before{content:attr(data-title)}.btn:is(input[type=checkbox]),.btn:is(input[type=radio]){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content: attr(aria-label);content:var(--tw-content)}@media (hover: hover){.btn:hover{--tw-border-opacity: 1;border-color:hsl(var(--b3) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--b3) / var(--tw-bg-opacity))}.btn-primary:hover{--tw-border-opacity: 1;border-color:hsl(var(--pf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--pf) / var(--tw-bg-opacity))}.btn-secondary:hover{--tw-border-opacity: 1;border-color:hsl(var(--sf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--sf) / var(--tw-bg-opacity))}.btn.glass:hover{--glass-opacity: 25%;--glass-border-opacity: 15%}.btn-ghost:hover{--tw-border-opacity: 0;background-color:hsl(var(--bc) / var(--tw-bg-opacity));--tw-bg-opacity: .2}.btn-outline.btn-primary:hover{--tw-border-opacity: 1;border-color:hsl(var(--pf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--pf) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity))}.btn-outline.btn-secondary:hover{--tw-border-opacity: 1;border-color:hsl(var(--sf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--sf) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--sc) / var(--tw-text-opacity))}.btn-disabled:hover,.btn[disabled]:hover,.btn:disabled:hover{--tw-border-opacity: 0;background-color:hsl(var(--n) / var(--tw-bg-opacity));--tw-bg-opacity: .2;color:hsl(var(--bc) / var(--tw-text-opacity));--tw-text-opacity: .2}.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{--tw-border-opacity: 1;border-color:hsl(var(--pf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--pf) / var(--tw-bg-opacity))}}.input{flex-shrink:1;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-width:1px;border-color:hsl(var(--bc) / var(--tw-border-opacity));--tw-border-opacity: 0;--tw-bg-opacity: 1;background-color:hsl(var(--b1) / var(--tw-bg-opacity));border-radius:var(--rounded-btn, .5rem)}.input-group>.input{isolation:isolate}.input-group>*,.input-group>.input,.input-group>.textarea,.input-group>.select{border-radius:0}.link{cursor:pointer;text-decoration-line:underline}.range{height:1.5rem;width:100%;cursor:pointer;-moz-appearance:none;appearance:none;-webkit-appearance:none;--range-shdw: var(--bc);overflow:hidden;background-color:transparent;border-radius:var(--rounded-box, 1rem)}.range:focus{outline:none}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;min-height:3rem;border-width:1px;border-color:hsl(var(--bc) / var(--tw-border-opacity));--tw-border-opacity: 0;--tw-bg-opacity: 1;background-color:hsl(var(--b1) / var(--tw-bg-opacity));border-radius:var(--rounded-btn, .5rem);background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.steps{display:inline-grid;grid-auto-flow:column;overflow:hidden;overflow-x:auto;counter-reset:step;grid-auto-columns:1fr}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;place-items:center;text-align:center;min-width:4rem}.swap{position:relative;display:inline-grid;-webkit-user-select:none;user-select:none;place-content:center;cursor:pointer}.swap>*{grid-column-start:1;grid-row-start:1;transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity}.swap input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on{opacity:0}.swap input:checked~.swap-off,.swap-active .swap-off,.swap input:indeterminate~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap-active .swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1}.textarea{flex-shrink:1;min-height:3rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-width:1px;border-color:hsl(var(--bc) / var(--tw-border-opacity));--tw-border-opacity: 0;--tw-bg-opacity: 1;background-color:hsl(var(--b1) / var(--tw-bg-opacity));border-radius:var(--rounded-btn, .5rem)}.toast{position:fixed;display:flex;min-width:fit-content;flex-direction:column;white-space:nowrap;gap:.5rem;padding:1rem}.alert-error{border-color:hsl(var(--er) / .2);--tw-text-opacity: 1;color:hsl(var(--erc) / var(--tw-text-opacity));--alert-bg: hsl(var(--er));--alert-bg-mix: hsl(var(--b1))}.btn:active:hover,.btn:active:focus{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale, .97))}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-border-opacity: 1;border-color:hsl(var(--p) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--p) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity));outline-color:hsl(var(--p) / 1)}.btn-primary.btn-active{--tw-border-opacity: 1;border-color:hsl(var(--pf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--pf) / var(--tw-bg-opacity))}.btn-secondary{--tw-border-opacity: 1;border-color:hsl(var(--s) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--s) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--sc) / var(--tw-text-opacity));outline-color:hsl(var(--s) / 1)}.btn-secondary.btn-active{--tw-border-opacity: 1;border-color:hsl(var(--sf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--sf) / var(--tw-bg-opacity))}.btn.glass{--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity: 25%;--glass-border-opacity: 15%}.btn-ghost{border-width:1px;border-color:transparent;background-color:transparent;color:currentColor;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{--tw-border-opacity: 0;background-color:hsl(var(--bc) / var(--tw-bg-opacity));--tw-bg-opacity: .2}.btn-outline.btn-primary{--tw-text-opacity: 1;color:hsl(var(--p) / var(--tw-text-opacity))}.btn-outline.btn-primary.btn-active{--tw-border-opacity: 1;border-color:hsl(var(--pf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--pf) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity))}.btn-outline.btn-secondary{--tw-text-opacity: 1;color:hsl(var(--s) / var(--tw-text-opacity))}.btn-outline.btn-secondary.btn-active{--tw-border-opacity: 1;border-color:hsl(var(--sf) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--sf) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--sc) / var(--tw-text-opacity))}.btn.btn-disabled,.btn[disabled],.btn:disabled{--tw-border-opacity: 0;background-color:hsl(var(--n) / var(--tw-bg-opacity));--tw-bg-opacity: .2;color:hsl(var(--bc) / var(--tw-text-opacity));--tw-text-opacity: .2}.btn-group>input[type=radio]:checked.btn,.btn-group>.btn-active{--tw-border-opacity: 1;border-color:hsl(var(--p) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--p) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity))}.btn-group>input[type=radio]:checked.btn:focus-visible,.btn-group>.btn-active:focus-visible{outline-style:solid;outline-width:2px;outline-color:hsl(var(--p) / 1)}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity: 1;border-color:hsl(var(--p) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--p) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:hsl(var(--p) / 1)}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale, .98))}40%{transform:scale(1.02)}to{transform:scale(1)}}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{--tw-border-opacity: .2}.input:focus,.input:focus-within{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:hsl(var(--bc) / .2)}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity: 1;border-color:hsl(var(--b2) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--b2) / var(--tw-bg-opacity));--tw-text-opacity: .2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:hsl(var(--bc) / var(--tw-placeholder-opacity));--tw-placeholder-opacity: .2}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity: 1;background-color:hsl(var(--b2) / var(--tw-bg-opacity));padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;left:.5rem;top:50%;aspect-ratio:1 / 1;height:.75rem;--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:2px;border-color:currentColor;opacity:.6}.mockup-browser .mockup-browser-toolbar .input:after{content:"";position:absolute;left:1.25rem;top:50%;height:.5rem;--tw-translate-y: 25%;--tw-rotate: -45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:1px;border-color:currentColor;opacity:.6}@keyframes modal-pop{0%{opacity:0}}@keyframes progress-loading{50%{background-position-x:-115%}}@keyframes radiomark{0%{box-shadow:0 0 0 12px hsl(var(--b1)) inset,0 0 0 12px hsl(var(--b1)) inset}50%{box-shadow:0 0 0 3px hsl(var(--b1)) inset,0 0 0 3px hsl(var(--b1)) inset}to{box-shadow:0 0 0 4px hsl(var(--b1)) inset,0 0 0 4px hsl(var(--b1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow: 0 0 0 6px hsl(var(--b1)) inset, 0 0 0 2rem hsl(var(--range-shdw)) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow: 0 0 0 6px hsl(var(--b1)) inset, 0 0 0 2rem hsl(var(--range-shdw)) inset}.range::-webkit-slider-runnable-track{height:.5rem;width:100%;background-color:hsl(var(--bc) / .1);border-radius:var(--rounded-box, 1rem)}.range::-moz-range-track{height:.5rem;width:100%;background-color:hsl(var(--bc) / .1);border-radius:var(--rounded-box, 1rem)}.range::-webkit-slider-thumb{position:relative;height:1.5rem;width:1.5rem;border-style:none;--tw-bg-opacity: 1;background-color:hsl(var(--b1) / var(--tw-bg-opacity));border-radius:var(--rounded-box, 1rem);-moz-appearance:none;appearance:none;-webkit-appearance:none;top:50%;color:hsl(var(--range-shdw));transform:translateY(-50%);--filler-size: 100rem;--filler-offset: .6rem;box-shadow:0 0 0 3px hsl(var(--range-shdw)) inset,var(--focus-shadow, 0 0),calc(var(--filler-size) * -1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{position:relative;height:1.5rem;width:1.5rem;border-style:none;--tw-bg-opacity: 1;background-color:hsl(var(--b1) / var(--tw-bg-opacity));border-radius:var(--rounded-box, 1rem);top:50%;color:hsl(var(--range-shdw));--filler-size: 100rem;--filler-offset: .5rem;box-shadow:0 0 0 3px hsl(var(--range-shdw)) inset,var(--focus-shadow, 0 0),calc(var(--filler-size) * -1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered{--tw-border-opacity: .2}.select:focus{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:hsl(var(--bc) / .2)}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity: 1;border-color:hsl(var(--b2) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--b2) / var(--tw-bg-opacity));--tw-text-opacity: .2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:hsl(var(--bc) / var(--tw-placeholder-opacity));--tw-placeholder-opacity: .2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:calc(0% + 12px) calc(1px + 50%),calc(0% + 16px) calc(1px + 50%)}.steps .step:before{top:0;grid-column-start:1;grid-row-start:1;height:.5rem;width:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity: 1;background-color:hsl(var(--b3) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--bc) / var(--tw-text-opacity));content:"";margin-left:-100%}.steps .step:after{content:counter(step);counter-increment:step;z-index:1;position:relative;grid-column-start:1;grid-row-start:1;display:grid;height:2rem;width:2rem;place-items:center;place-self:center;border-radius:9999px;--tw-bg-opacity: 1;background-color:hsl(var(--b3) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--bc) / var(--tw-text-opacity))}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity: 1;background-color:hsl(var(--n) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--nc) / var(--tw-text-opacity))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity: 1;background-color:hsl(var(--p) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--pc) / var(--tw-text-opacity))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity: 1;background-color:hsl(var(--s) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--sc) / var(--tw-text-opacity))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity: 1;background-color:hsl(var(--a) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--ac) / var(--tw-text-opacity))}.steps .step-info+.step-info:before{--tw-bg-opacity: 1;background-color:hsl(var(--in) / var(--tw-bg-opacity))}.steps .step-info:after{--tw-bg-opacity: 1;background-color:hsl(var(--in) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--inc) / var(--tw-text-opacity))}.steps .step-success+.step-success:before{--tw-bg-opacity: 1;background-color:hsl(var(--su) / var(--tw-bg-opacity))}.steps .step-success:after{--tw-bg-opacity: 1;background-color:hsl(var(--su) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--suc) / var(--tw-text-opacity))}.steps .step-warning+.step-warning:before{--tw-bg-opacity: 1;background-color:hsl(var(--wa) / var(--tw-bg-opacity))}.steps .step-warning:after{--tw-bg-opacity: 1;background-color:hsl(var(--wa) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--wac) / var(--tw-text-opacity))}.steps .step-error+.step-error:before{--tw-bg-opacity: 1;background-color:hsl(var(--er) / var(--tw-bg-opacity))}.steps .step-error:after{--tw-bg-opacity: 1;background-color:hsl(var(--er) / var(--tw-bg-opacity));--tw-text-opacity: 1;color:hsl(var(--erc) / var(--tw-text-opacity))}.textarea:focus{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:hsl(var(--bc) / .2)}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity: 1;border-color:hsl(var(--b2) / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:hsl(var(--b2) / var(--tw-bg-opacity));--tw-text-opacity: .2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:hsl(var(--bc) / var(--tw-placeholder-opacity));--tw-placeholder-opacity: .2}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}.btn-sm{height:2rem;padding-left:.75rem;padding-right:.75rem;min-height:2rem;font-size:.875rem}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.input-sm{height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem;line-height:2rem}.select-sm{height:2rem;padding-left:.75rem;padding-right:2rem;font-size:.875rem;line-height:2rem;min-height:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}:where(.toast){bottom:0;left:auto;right:0;top:auto;--tw-translate-x: 0px;--tw-translate-y: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){left:0;right:auto;--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){left:50%;right:50%;--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){left:auto;right:0;--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.tooltip{position:relative;display:inline-block;--tooltip-offset: calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{position:absolute;pointer-events:none;z-index:1;content:var(--tw-content);--tw-content: attr(data-tip)}.tooltip:before,.tooltip-top:before{transform:translate(-50%);top:auto;left:50%;right:auto;bottom:var(--tooltip-offset)}.tooltip-bottom:before{transform:translate(-50%);top:var(--tooltip-offset);left:50%;right:auto;bottom:auto}.btn-group .btn:not(:first-child):not(:last-child){border-radius:0}.btn-group .btn:first-child:not(:last-child){margin-left:-1px;margin-top:-0px;border-top-left-radius:var(--rounded-btn, .5rem);border-top-right-radius:0;border-bottom-left-radius:var(--rounded-btn, .5rem);border-bottom-right-radius:0}.btn-group .btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:var(--rounded-btn, .5rem);border-bottom-left-radius:0;border-bottom-right-radius:var(--rounded-btn, .5rem)}.btn-group-horizontal .btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-horizontal .btn:first-child:not(:last-child){margin-left:-1px;margin-top:-0px;border-top-left-radius:var(--rounded-btn, .5rem);border-top-right-radius:0;border-bottom-left-radius:var(--rounded-btn, .5rem);border-bottom-right-radius:0}.btn-group-horizontal .btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:var(--rounded-btn, .5rem);border-bottom-left-radius:0;border-bottom-right-radius:var(--rounded-btn, .5rem)}.btn-group-vertical .btn:first-child:not(:last-child){margin-left:-0px;margin-top:-1px;border-top-left-radius:var(--rounded-btn, .5rem);border-top-right-radius:var(--rounded-btn, .5rem);border-bottom-left-radius:0;border-bottom-right-radius:0}.btn-group-vertical .btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:var(--rounded-btn, .5rem);border-bottom-right-radius:var(--rounded-btn, .5rem)}.tooltip{position:relative;display:inline-block;text-align:center;--tooltip-tail: .1875rem;--tooltip-color: hsl(var(--n));--tooltip-text-color: hsl(var(--nc));--tooltip-tail-offset: calc(100% + .0625rem - var(--tooltip-tail))}.tooltip:before,.tooltip:after{opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-delay:.1s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{position:absolute;content:"";border-style:solid;border-width:var(--tooltip-tail, 0);width:0;height:0;display:block}.tooltip:before{max-width:20rem;border-radius:.25rem;padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;background-color:var(--tooltip-color);color:var(--tooltip-text-color);width:max-content}.tooltip.tooltip-open:before,.tooltip.tooltip-open:after,.tooltip:hover:before,.tooltip:hover:after{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:before,.tooltip:not([data-tip]):hover:after{visibility:hidden;opacity:0}.tooltip:after,.tooltip-top:after{transform:translate(-50%);border-color:var(--tooltip-color) transparent transparent transparent;top:auto;left:50%;right:auto;bottom:var(--tooltip-tail-offset)}.tooltip-bottom:after{transform:translate(-50%);border-color:transparent transparent var(--tooltip-color) transparent;top:var(--tooltip-tail-offset);left:50%;right:auto;bottom:auto}.m-4{margin:1rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mr-4{margin-right:1rem}.block{display:block}.flex{display:flex}.h-4{height:1rem}.h-5\/6{height:83.333333%}.h-6{height:1.5rem}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5\/6{width:83.333333%}.w-6{width:1.5rem}.w-96{width:24rem}.w-full{width:100%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.cursor-pointer{cursor:pointer}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.overflow-y-scroll{overflow-y:scroll}.break-all{word-break:break-all}.border-0{border-width:0px}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity))}.p-4{padding:1rem}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity))}:is(.dark .dark\:bg-green-700){--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity))} 2 | -------------------------------------------------------------------------------- /client-bundle/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gomon console 5 | 6 | 7 | 22 | 23 | 24 | 130 |
131 |
138 |
139 |
140 |
141 |
142 | 150 | 155 | 156 | Not connected 157 |
158 |
159 | 160 |
161 |
162 |

Zoom

163 | 182 |
183 |
184 | 190 |
191 |
192 |
193 | 194 | 195 | -------------------------------------------------------------------------------- /client-bundle/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* @font-face { 6 | font-family: "Montserrat"; 7 | src: url("../fonts/Montserrat-Regular.ttf"); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | @font-face { 13 | font-family: "Montserrat"; 14 | src: url("../fonts/Montserrat-Bold.ttf"); 15 | font-weight: bold; 16 | font-style: normal; 17 | } 18 | 19 | @font-face { 20 | font-family: "Montserrat"; 21 | src: url("../fonts/Montserrat-Italic.ttf"); 22 | font-weight: normal; 23 | font-style: italic; 24 | } 25 | 26 | @font-face { 27 | font-family: "Montserrat"; 28 | src: url("../fonts/Montserrat-BoldItalic.ttf"); 29 | font-weight: bold; 30 | font-style: italic; 31 | } 32 | 33 | @font-face { 34 | font-family: "Montserrat"; 35 | src: url("../fonts/Montserrat-Medium.ttf"); 36 | font-weight: 500; 37 | font-style: normal; 38 | } 39 | 40 | @font-face { 41 | font-family: "Montserrat"; 42 | src: url("../fonts/Montserrat-MediumItalic.ttf"); 43 | font-weight: 500; 44 | font-style: italic; 45 | } 46 | 47 | @font-face { 48 | font-family: "Montserrat"; 49 | src: url("../fonts/Montserrat-SemiBold.ttf"); 50 | font-weight: 600; 51 | font-style: normal; 52 | } 53 | 54 | @font-face { 55 | font-family: "Montserrat"; 56 | src: url("../fonts/Montserrat-SemiBoldItalic.ttf"); 57 | font-weight: 600; 58 | font-style: italic; 59 | } 60 | 61 | @font-face { 62 | font-family: "Montserrat"; 63 | src: url("../fonts/Montserrat-Light.ttf"); 64 | font-weight: 300; 65 | font-style: normal; 66 | } 67 | 68 | @font-face { 69 | font-family: "Montserrat"; 70 | src: url("../fonts/Montserrat-LightItalic.ttf"); 71 | font-weight: 300; 72 | font-style: italic; 73 | } 74 | 75 | @font-face { 76 | font-family: "Montserrat"; 77 | src: url("../fonts/Montserrat-ExtraLight.ttf"); 78 | font-weight: 200; 79 | font-style: normal; 80 | } 81 | 82 | @font-face { 83 | font-family: "Montserrat"; 84 | src: url("../fonts/Montserrat-ExtraLightItalic.ttf"); 85 | font-weight: 200; 86 | font-style: italic; 87 | } 88 | 89 | @font-face { 90 | font-family: "Montserrat"; 91 | src: url("../fonts/Montserrat-Thin.ttf"); 92 | font-weight: 100; 93 | font-style: normal; 94 | } 95 | 96 | @font-face { 97 | font-family: "Montserrat"; 98 | src: url("../fonts/Montserrat-ThinItalic.ttf"); 99 | font-weight: 100; 100 | font-style: italic; 101 | } 102 | 103 | @layer base { 104 | html { 105 | font-family: "Montserrat"; 106 | } 107 | } */ 108 | -------------------------------------------------------------------------------- /client-bundle/main.ts: -------------------------------------------------------------------------------- 1 | import "./main.css"; 2 | 3 | import Alpine from "alpinejs"; 4 | import htmx from "htmx.org"; 5 | 6 | interface SSEEvent { 7 | id: string; 8 | dt: string; 9 | target: string; 10 | swap: string; 11 | markup: string; 12 | } 13 | 14 | export type SwapType = 15 | | "innerHTML" 16 | | "outerHTML" 17 | | "beforebegin" 18 | | "afterbegin" 19 | | "beforeend" 20 | | "afterend" 21 | | "delete" 22 | | "none"; 23 | 24 | declare global { 25 | interface Window { 26 | Alpine: typeof Alpine; 27 | htmx: typeof htmx; 28 | } 29 | } 30 | 31 | window.Alpine = Alpine; 32 | window.htmx = htmx; 33 | 34 | Alpine.data("search", () => ({ 35 | searchText: "", 36 | runId: "all", 37 | isShowingSearchResults: false, 38 | isShowingConnectionError: false, 39 | eventSource: new EventSource("/sse?stream=events", { 40 | withCredentials: false 41 | }), 42 | eventQueue: [] as MessageEvent[], 43 | toastTimeout: null as number | null, 44 | zoomContent: "", 45 | init: function () { 46 | console.log("init"); 47 | this.$watch("searchText", (val) => { 48 | this.onSearchTextChanged(val); 49 | }); 50 | this.$watch("runId", (val) => { 51 | this.onRunIdChanged(val); 52 | }); 53 | this.$watch("isShowingSearchResults", (val) => { 54 | this.onIsShowingSearchResults(val); 55 | }); 56 | 57 | this.eventSource.onmessage = (ev) => { 58 | this.handleEventSourceMessage(ev); 59 | }; 60 | this.eventSource.onerror = () => { 61 | this.handleEventSourceError(); 62 | }; 63 | }, 64 | onSearchTextChanged: function (value: string) { 65 | if (value.length === 0) { 66 | this.isShowingSearchResults = false; 67 | return; 68 | } 69 | this.isShowingSearchResults = true; 70 | this.onClickSearch(); 71 | }, 72 | onRunIdChanged: function (value: string) { 73 | if (value === "all") { 74 | this.isShowingSearchResults = false; 75 | this.searchText = ""; 76 | return; 77 | } 78 | this.isShowingSearchResults = true; 79 | if (this.searchText.length > 0) { 80 | this.onClickSearch(); 81 | } 82 | }, 83 | onIsShowingSearchResults: function (value: boolean) { 84 | console.log("isShowingSearchResults changed", value); 85 | if (!value) { 86 | while (this.eventQueue.length > 0) { 87 | const ev = this.eventQueue.shift()!; 88 | this.processEvent(ev); 89 | } 90 | } 91 | }, 92 | onSearchTextKeyDown: function (ev: KeyboardEvent) { 93 | if (ev.key === "Enter") { 94 | this.onClickSearch(); 95 | } 96 | }, 97 | onClickSearch: function () { 98 | const targetEl = document.querySelector("#log-output-inner") as HTMLElement; 99 | if (!targetEl) { 100 | throw new Error("Target element not found"); 101 | } 102 | const event = new CustomEvent("custom:search", { 103 | detail: { key: "value" } 104 | }); 105 | targetEl.dispatchEvent(event); 106 | }, 107 | handleEventSourceMessage: function (ev: MessageEvent) { 108 | if (this.isShowingSearchResults) { 109 | this.eventQueue.push(ev); 110 | return; 111 | } 112 | this.processEvent(ev); 113 | }, 114 | handleEventSourceError: function () { 115 | console.log("sse error"); 116 | this.isShowingConnectionError = true; 117 | if (this.toastTimeout) { 118 | clearTimeout(this.toastTimeout); 119 | } 120 | this.toastTimeout = setTimeout(() => { 121 | this.isShowingConnectionError = false; 122 | }, 5000); 123 | }, 124 | processEvent: function (ev: MessageEvent) { 125 | const msg = JSON.parse(ev.data) as SSEEvent; 126 | swap(msg); 127 | }, 128 | onZoomEntry: function (ev: MouseEvent) { 129 | const targetEl = ev.target as HTMLElement; 130 | const textContent = targetEl 131 | .closest(".log-entry") 132 | ?.querySelector(".log-text")?.textContent; 133 | try { 134 | const data = JSON.parse(textContent || ""); 135 | this.zoomContent = JSON.stringify(data, null, 2); 136 | } catch (e) { 137 | this.zoomContent = textContent || ""; 138 | } 139 | const dialog = document.getElementById("zoom-dialog") as HTMLDialogElement; 140 | dialog.showModal(); 141 | } 142 | })); 143 | 144 | Alpine.start(); 145 | 146 | function swap(msg: SSEEvent) { 147 | const targetEl = document.querySelector(msg.target) as HTMLElement; 148 | if (!targetEl) { 149 | throw new Error(`Target element not found: ${msg.target}/${msg.id}`); 150 | } 151 | 152 | const f = msg.swap.split(" "); 153 | const swapType = f[0] as SwapType; 154 | const scrollExpr = f.length > 1 ? f[1] : ""; 155 | 156 | const range = document.createRange(); 157 | range.selectNode(targetEl); 158 | const documentFragment = range.createContextualFragment(msg.markup); 159 | 160 | switch (swapType) { 161 | case "innerHTML": 162 | while (targetEl.firstChild) { 163 | targetEl.removeChild(targetEl.firstChild); 164 | } 165 | targetEl.appendChild(documentFragment); 166 | break; 167 | case "outerHTML": 168 | targetEl.parentNode?.replaceChild(documentFragment, targetEl); 169 | break; 170 | case "beforebegin": 171 | targetEl.parentNode?.insertBefore(documentFragment, targetEl); 172 | break; 173 | case "afterbegin": 174 | targetEl.insertBefore(documentFragment, targetEl.firstChild); 175 | break; 176 | case "beforeend": 177 | targetEl.appendChild(documentFragment); 178 | break; 179 | case "afterend": 180 | targetEl.parentNode?.insertBefore(documentFragment, targetEl.nextSibling); 181 | break; 182 | default: 183 | break; 184 | } 185 | 186 | if (scrollExpr) { 187 | const f = scrollExpr.split(":"); 188 | const scrollType = f[0]; 189 | const scrollTarget = f[1]; 190 | switch (scrollType) { 191 | case "scroll": 192 | switch (scrollTarget) { 193 | case "view": 194 | (documentFragment.firstChild as HTMLElement)?.scrollIntoView(); 195 | break; 196 | case "lastchild": 197 | (targetEl.lastChild as HTMLElement)?.scrollIntoView({ 198 | block: "end", 199 | behavior: "instant" 200 | }); 201 | break; 202 | case "nextsibling": 203 | (targetEl.nextSibling as HTMLElement)?.scrollIntoView({ 204 | block: "end", 205 | behavior: "instant" 206 | }); 207 | break; 208 | } 209 | break; 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /client-bundle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kilo-js", 3 | "type": "module", 4 | "files": [ 5 | "dist" 6 | ], 7 | "main": "./dist/kilo.cjs", 8 | "module": "./dist/kilo.js", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/kilo.js", 12 | "require": "./dist/kilo.umd.cjs" 13 | } 14 | }, 15 | "scripts": { 16 | "dev": "vite", 17 | "build:watch": "vite build --watch", 18 | "build": "vite build", 19 | "lint": "eslint . --ext .ts", 20 | "lint:fix": "eslint . --ext .ts --fix", 21 | "prettier": "prettier --write src/" 22 | }, 23 | "dependencies": { 24 | "alpinejs": "^3.13.7", 25 | "htmx.org": "^1.9.11" 26 | }, 27 | "devDependencies": { 28 | "@types/alpinejs": "^3.13.10", 29 | "@typescript-eslint/eslint-plugin": "^6.5.0", 30 | "@typescript-eslint/parser": "^6.5.0", 31 | "daisyui": "^3.7.3", 32 | "eslint": "^8.48.0", 33 | "eslint-config-prettier": "^9.0.0", 34 | "eslint-plugin-prettier": "^5.0.0", 35 | "postcss": "^8.4.29", 36 | "postcss-loader": "^7.3.3", 37 | "postcss-preset-env": "^9.1.3", 38 | "prettier": "^3.0.3", 39 | "style-loader": "^3.3.3", 40 | "tailwindcss": "^3.3.3", 41 | "typescript": "^5.2.2", 42 | "vite": "^5.0.2" 43 | } 44 | } -------------------------------------------------------------------------------- /client-bundle/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | module.exports = { 3 | plugins: [tailwindcss], 4 | }; -------------------------------------------------------------------------------- /client-bundle/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.ts", "*.ts", "./*.html", "../**/*.templ"], 4 | darkMode: "class", 5 | theme: { 6 | extend: {} 7 | }, 8 | plugins: [require("daisyui")] 9 | }; 10 | -------------------------------------------------------------------------------- /client-bundle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "strict": true, 5 | "lib": [ 6 | "ESNext", 7 | "DOM", 8 | "dom.iterable" 9 | ], 10 | "moduleResolution": "node", 11 | "module": "ES2015", 12 | "target": "ES6", 13 | "sourceMap": true, 14 | "esModuleInterop": true, 15 | "allowJs": true, 16 | "checkJs": false, 17 | "experimentalDecorators": true, 18 | "resolveJsonModule": true, 19 | }, 20 | "include": [ 21 | "*.ts", 22 | "lib/**/*.ts", 23 | ] 24 | } -------------------------------------------------------------------------------- /client-bundle/vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { defineConfig } from "vite" 3 | import { resolve } from "path" 4 | import { readFileSync, writeFileSync } from "fs" 5 | 6 | function fixBackticks(content) { 7 | return content.replace(/`/g, "\` + \"\`\" + \`") 8 | } 9 | 10 | export default defineConfig({ 11 | build: { 12 | sourcemap: "inline", 13 | outDir: "dist", 14 | rollupOptions: { 15 | input: "main.ts", 16 | output: { 17 | entryFileNames: "main.js", 18 | assetFileNames: "[name].[ext]", 19 | } 20 | } 21 | }, 22 | plugins:[ 23 | { 24 | name: "copy-client-bundle", 25 | closeBundle: async () => { 26 | const indexContent = fixBackticks(readFileSync(resolve(__dirname, "index.html"), "utf-8")) 27 | const scriptContent = fixBackticks(readFileSync(resolve(__dirname, "dist/main.js"), "utf-8")) 28 | const stylesheetContent = fixBackticks(readFileSync(resolve(__dirname, "dist/main.css"), "utf-8")) 29 | const bundleContent = ` 30 | // Code generated by vite from client-bundle DO NOT EDIT. 31 | package webui 32 | 33 | var index = []byte(\`${indexContent}\`) 34 | 35 | var script = []byte(\`${scriptContent}\`) 36 | 37 | var stylesheet = []byte(\`${stylesheetContent}\`) 38 | ` 39 | writeFileSync(resolve(__dirname, "../internal/webui/static.go"), bundleContent) 40 | } 41 | } 42 | ] 43 | }) -------------------------------------------------------------------------------- /example/gomon.config.yml: -------------------------------------------------------------------------------- 1 | entrypoint: ./cmd/server/main.go 2 | entrypointArgs: ["--somearg", "somevalue"] 3 | 4 | excludePaths: ["vendor", "client"] 5 | 6 | hardReload: 7 | - "*.go" 8 | - "go.mod" 9 | 10 | softReload: 11 | - "*.html" 12 | - "*.css" 13 | - "*.js" 14 | - "*.tmpl" 15 | 16 | generated: 17 | "*.templ": 18 | - "task generate/templ" 19 | - "__soft_reload" 20 | 21 | envFiles: 22 | - ".env" 23 | - ".env.local" 24 | reloadOnUnhandled: true 25 | proxy: 26 | enabled: true 27 | port: 4000 28 | downstream: 29 | host: localhost:8081 30 | timeout: 5 31 | ui: 32 | enabled: true -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jdudmesh/gomon 2 | 3 | go 1.21.6 4 | 5 | toolchain go1.22.0 6 | 7 | require ( 8 | github.com/a-h/templ v0.2.648 9 | github.com/bwmarrin/snowflake v0.3.0 10 | github.com/fsnotify/fsnotify v1.7.0 11 | github.com/jdudmesh/gomon-ipc v0.1.1 12 | github.com/jmoiron/sqlx v1.3.5 13 | github.com/mattn/go-sqlite3 v1.14.22 14 | github.com/r3labs/sse/v2 v2.10.0 15 | github.com/sirupsen/logrus v1.9.3 16 | gopkg.in/cenkalti/backoff.v1 v1.1.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | ) 19 | 20 | require ( 21 | github.com/google/uuid v1.6.0 // indirect 22 | golang.org/x/net v0.21.0 // indirect 23 | golang.org/x/sys v0.17.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.2.648 h1:A1ggHGIE7AONOHrFaDTM8SrqgqHL6fWgWCijQ21Zy9I= 2 | github.com/a-h/templ v0.2.648/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8= 3 | github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= 4 | github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 9 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 10 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 11 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 15 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 16 | github.com/jdudmesh/gomon-ipc v0.1.1 h1:8tn4VC2NIg68R1YQWTpcyYKS8Qy06yRhbPwqEEYwfA4= 17 | github.com/jdudmesh/gomon-ipc v0.1.1/go.mod h1:INgfPXgKrSumNtUA1v2Ao0jWTz2Wie9lgvgLLWzpM5w= 18 | github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 19 | github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 20 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 21 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 22 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 23 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 24 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= 28 | github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= 29 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 30 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 31 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 32 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 33 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 34 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 35 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 36 | golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 38 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 39 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 40 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 42 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 43 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 44 | gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= 45 | gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 50 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 51 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/jdudmesh/gomon/internal/config" 13 | "github.com/jdudmesh/gomon/internal/console" 14 | "github.com/jdudmesh/gomon/internal/notification" 15 | "github.com/jdudmesh/gomon/internal/process" 16 | "github.com/jdudmesh/gomon/internal/proxy" 17 | "github.com/jdudmesh/gomon/internal/utils" 18 | "github.com/jdudmesh/gomon/internal/watcher" 19 | "github.com/jdudmesh/gomon/internal/webui" 20 | log "github.com/sirupsen/logrus" 21 | "gopkg.in/cenkalti/backoff.v1" 22 | ) 23 | 24 | type App struct { 25 | proxyOnly bool 26 | sigint chan os.Signal 27 | hardRestart chan string 28 | softRestart chan string 29 | oobTask chan string 30 | childProcess process.AtomicChildProcess 31 | db Database 32 | watcher Watcher 33 | proxy WebProxy 34 | notifier Notifier 35 | consoleWriter Console 36 | webui UI 37 | } 38 | 39 | type Closeable interface { 40 | Close() error 41 | } 42 | 43 | type Startable interface { 44 | Start() error 45 | } 46 | 47 | type Database interface { 48 | Closeable 49 | notification.EventConsumer 50 | webui.Database 51 | } 52 | 53 | type Watcher interface { 54 | Closeable 55 | Watch(notification.NotificationCallback) error 56 | } 57 | 58 | type WebProxy interface { 59 | Closeable 60 | Startable 61 | notification.EventConsumer 62 | Enabled() bool 63 | } 64 | 65 | type Notifier interface { 66 | Closeable 67 | Startable 68 | notification.EventConsumer 69 | SendSoftRestart(hint string) error 70 | } 71 | 72 | type Console interface { 73 | Closeable 74 | Startable 75 | notification.EventConsumer 76 | process.ConsoleOutput 77 | } 78 | 79 | type UI interface { 80 | Closeable 81 | Startable 82 | notification.EventConsumer 83 | Enabled() bool 84 | } 85 | 86 | func New(cfg config.Config) (*App, error) { 87 | var err error 88 | 89 | app := &App{ 90 | proxyOnly: cfg.ProxyOnly, 91 | sigint: make(chan os.Signal, 1), 92 | hardRestart: make(chan string), 93 | softRestart: make(chan string), 94 | oobTask: make(chan string), 95 | childProcess: process.AtomicChildProcess{}, 96 | } 97 | 98 | app.db, err = utils.NewDatabase(cfg) 99 | if err != nil { 100 | log.Fatalf("creating database: %v", err) 101 | } 102 | 103 | app.proxy, err = proxy.New(cfg) 104 | if err != nil { 105 | return nil, fmt.Errorf("creating proxy: %v", err) 106 | } 107 | 108 | app.watcher, err = watcher.New(cfg) 109 | if err != nil { 110 | return nil, fmt.Errorf("creating monitor: %w", err) 111 | } 112 | 113 | app.notifier, err = notification.NewNotifier(app.Notify) 114 | if err != nil { 115 | return nil, fmt.Errorf("creating notifier: %v", err) 116 | } 117 | 118 | app.consoleWriter, err = console.New(cfg, app.Notify) 119 | if err != nil { 120 | return nil, fmt.Errorf("creating console: %v", err) 121 | } 122 | 123 | app.webui, err = webui.New(cfg, app.db, func(n notification.Notification) error { 124 | switch n.Type { 125 | case notification.NotificationTypeHardRestartRequested: 126 | app.hardRestart <- "webui" 127 | case notification.NotificationTypeSoftRestartRequested: 128 | app.softRestart <- "webui" 129 | case notification.NotificationTypeShutdownRequested: 130 | app.sigint <- syscall.SIGTERM 131 | } 132 | return app.Notify(n) 133 | }) 134 | if err != nil { 135 | return nil, fmt.Errorf("creating console: %v", err) 136 | } 137 | 138 | return app, nil 139 | } 140 | 141 | func (a *App) Close() { 142 | proc := a.childProcess.Load() 143 | if proc != nil { 144 | proc.Stop() 145 | } 146 | 147 | if a.db != nil { 148 | a.db.Close() 149 | } 150 | if a.proxy != nil { 151 | a.proxy.Close() 152 | } 153 | if a.watcher != nil { 154 | a.watcher.Close() 155 | } 156 | if a.notifier != nil { 157 | a.notifier.Close() 158 | } 159 | if a.consoleWriter != nil { 160 | a.consoleWriter.Close() 161 | } 162 | if a.webui != nil { 163 | a.webui.Close() 164 | } 165 | } 166 | 167 | func (a *App) MonitorFileChanges(ctx context.Context) error { 168 | err := a.watcher.Watch(func(n notification.Notification) error { 169 | switch n.Type { 170 | case notification.NotificationTypeHardRestartRequested: 171 | a.hardRestart <- n.Message 172 | case notification.NotificationTypeSoftRestartRequested: 173 | a.softRestart <- n.Message 174 | case notification.NotificationTypeOOBTaskRequested: 175 | a.oobTask <- n.Message 176 | } 177 | return a.Notify(n) 178 | }) 179 | 180 | if err != nil { 181 | log.Errorf("running monitor: %v", err) 182 | } 183 | 184 | return err 185 | } 186 | 187 | func (a *App) RunProxy() error { 188 | if a.proxy.Enabled() { 189 | return a.proxy.Start() 190 | } 191 | return nil 192 | } 193 | 194 | func (a *App) RunNotifer() error { 195 | return a.notifier.Start() 196 | } 197 | 198 | func (a *App) RunConsole() error { 199 | return a.consoleWriter.Start() 200 | } 201 | 202 | func (a *App) RunWebUI() error { 203 | if a.webui.Enabled() { 204 | return a.webui.Start() 205 | } 206 | return nil 207 | } 208 | 209 | func (a *App) RunChildProcess(cfg config.Config) error { 210 | proc, err := process.NewChildProcess(cfg) 211 | if err != nil { 212 | log.Fatalf("creating child process: %v", err) 213 | } 214 | 215 | a.childProcess.Store(proc) 216 | 217 | backoffPolicy := backoff.NewExponentialBackOff() 218 | backoffPolicy.InitialInterval = 500 * time.Millisecond 219 | backoffPolicy.MaxInterval = 5000 * time.Millisecond 220 | backoffPolicy.MaxElapsedTime = 60 * time.Second 221 | 222 | err = backoff.Retry(func() error { 223 | return proc.Start(a.consoleWriter, a.Notify) 224 | }, backoffPolicy) 225 | 226 | if err != nil { 227 | log.Errorf("failed retrying child process: %v", err) 228 | return err 229 | } 230 | 231 | return nil 232 | } 233 | 234 | func (a *App) ProcessRestartEvents(ctx context.Context) error { 235 | for { 236 | select { 237 | case hint := <-a.hardRestart: 238 | if !a.proxyOnly { 239 | log.Info("hard restart: " + hint) 240 | proc := a.childProcess.Load() 241 | if proc != nil { 242 | proc.Stop() 243 | } 244 | } 245 | case hint := <-a.softRestart: 246 | log.Info("soft restart: " + hint) 247 | err := a.notifier.SendSoftRestart(hint) 248 | if err != nil { 249 | log.Warnf("notifying child process: %v", err) 250 | } 251 | case task := <-a.oobTask: 252 | if !a.proxyOnly { 253 | log.Info("out of band task: " + task) 254 | proc := a.childProcess.Load() 255 | if proc != nil { 256 | proc.ExecuteOOBTask(task, a.Notify) 257 | } 258 | } 259 | case <-ctx.Done(): 260 | return nil 261 | } 262 | } 263 | } 264 | 265 | func (a *App) ProcessSignals() error { 266 | signal.Notify(a.sigint, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) 267 | for s := range a.sigint { 268 | switch s { 269 | case syscall.SIGHUP: 270 | log.Info("received signal, restarting") 271 | a.softRestart <- "sighup" 272 | case syscall.SIGUSR1: 273 | log.Info("received signal, hard restarting") 274 | a.hardRestart <- "sigusr1" 275 | case syscall.SIGINT, syscall.SIGTERM: 276 | log.Info("received term signal, exiting") 277 | return errors.New("shutdown requested") 278 | } 279 | } 280 | return nil 281 | } 282 | 283 | func (a *App) Notify(n notification.Notification) error { 284 | a.db.Notify(n) 285 | a.consoleWriter.Notify(n) 286 | a.proxy.Notify(n) 287 | a.webui.Notify(n) 288 | a.notifier.Notify(n) 289 | return nil 290 | } 291 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | 24 | log "github.com/sirupsen/logrus" 25 | "gopkg.in/yaml.v3" 26 | ) 27 | 28 | const DefaultConfigFileName = "gomon.config.yml" 29 | 30 | type Config struct { 31 | RootDirectory string `yaml:"rootDirectory"` 32 | Command []string `yaml:"command"` 33 | Entrypoint string `yaml:"entrypoint"` 34 | EntrypointArgs []string `yaml:"entrypointArgs"` 35 | EnvFiles []string `yaml:"envFiles"` 36 | ExcludePaths []string `yaml:"excludePaths"` 37 | HardReload []string `yaml:"hardReload"` 38 | SoftReload []string `yaml:"softReload"` 39 | Generated map[string][]string `yaml:"generated"` 40 | Prestart []string `yaml:"prestart"` 41 | ProxyOnly bool `yaml:"proxyOnly"` 42 | Proxy struct { 43 | Enabled bool `yaml:"enabled"` 44 | Port int `yaml:"port"` 45 | Downstream struct { 46 | Host string `yaml:"host"` 47 | Timeout int `yaml:"timeout"` 48 | } `yaml:"downstream"` 49 | } `yaml:"proxy"` 50 | UI struct { 51 | Enabled bool `yaml:"enabled"` 52 | Port int `yaml:"port"` 53 | } `yaml:"ui"` 54 | } 55 | 56 | var defaultConfig = Config{ 57 | HardReload: []string{"*.go", "go.mod", "go.sum"}, 58 | SoftReload: []string{"*.html", "*.css", "*.js"}, 59 | ExcludePaths: []string{".gomon", "vendor"}, 60 | } 61 | 62 | func New(configPath string) (Config, error) { 63 | if configPath == "" { 64 | return defaultConfig, nil 65 | } 66 | 67 | cfg := Config{} 68 | 69 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 70 | log.Warn("could not find valid config file") 71 | return cfg, nil 72 | } else if err != nil { 73 | return defaultConfig, fmt.Errorf("checking for config file: %w", err) 74 | } 75 | 76 | log.Infof("loading config from %s", configPath) 77 | f, err := os.Open(configPath) 78 | if err != nil { 79 | return defaultConfig, fmt.Errorf("opening config file: %w", err) 80 | } 81 | defer f.Close() 82 | 83 | data, err := io.ReadAll(f) 84 | if err != nil { 85 | return defaultConfig, fmt.Errorf("reading config file: %w", err) 86 | } 87 | 88 | if err := yaml.Unmarshal(data, &cfg); err != nil { 89 | return defaultConfig, fmt.Errorf("unmarhsalling config: %w", err) 90 | } 91 | 92 | if findIndex(cfg.ExcludePaths, ".gomon") < 0 { 93 | cfg.ExcludePaths = append(cfg.ExcludePaths, ".gomon") 94 | } 95 | 96 | return cfg, nil 97 | } 98 | 99 | func findIndex(array []string, target string) int { 100 | for i, value := range array { 101 | if value == target { 102 | return i 103 | } 104 | } 105 | return -1 106 | } 107 | -------------------------------------------------------------------------------- /internal/console/streams.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bufio" 21 | "io" 22 | "os" 23 | "strings" 24 | "sync/atomic" 25 | "time" 26 | 27 | "github.com/jdudmesh/gomon/internal/config" 28 | "github.com/jdudmesh/gomon/internal/notification" 29 | _ "github.com/mattn/go-sqlite3" 30 | log "github.com/sirupsen/logrus" 31 | ) 32 | 33 | type streams struct { 34 | enabled bool 35 | stdoutWriter chan string 36 | stderrWriter chan string 37 | currentRunID atomic.Int64 38 | currentChildProcessID string 39 | callbackFn notification.NotificationCallback 40 | } 41 | 42 | type streamWriter struct { 43 | streamConsumer chan string 44 | } 45 | 46 | func New(cfg config.Config, callbackFn notification.NotificationCallback) (*streams, error) { 47 | stm := &streams{ 48 | enabled: cfg.UI.Enabled, 49 | stdoutWriter: make(chan string), 50 | stderrWriter: make(chan string), 51 | callbackFn: callbackFn, 52 | } 53 | 54 | return stm, nil 55 | } 56 | 57 | func (s *streams) Start() error { 58 | for { 59 | select { 60 | case line := <-s.stdoutWriter: 61 | if !s.enabled { 62 | os.Stdout.WriteString(line) 63 | continue 64 | } 65 | err := s.write(notification.NotificationTypeStdOut, line, s.callbackFn) 66 | if err != nil { 67 | log.Errorf("writing stdout: %v", err) 68 | } 69 | case line := <-s.stderrWriter: 70 | if !s.enabled { 71 | os.Stderr.WriteString(line) 72 | continue 73 | } 74 | err := s.write(notification.NotificationTypeStdErr, line, s.callbackFn) 75 | if err != nil { 76 | log.Errorf("writing stderr: %v", err) 77 | } 78 | } 79 | } 80 | } 81 | 82 | func (s *streams) Close() error { 83 | log.Info("closing console streams") 84 | close(s.stdoutWriter) 85 | close(s.stderrWriter) 86 | return nil 87 | } 88 | 89 | func (s *streams) Stdout() io.Writer { 90 | return &streamWriter{streamConsumer: s.stdoutWriter} 91 | } 92 | 93 | func (s *streams) Stderr() io.Writer { 94 | return &streamWriter{streamConsumer: s.stderrWriter} 95 | } 96 | 97 | func (s *streams) write(logType notification.NotificationType, logData string, callbackFn notification.NotificationCallback) error { 98 | eventDate := time.Now() 99 | 100 | scanner := bufio.NewScanner(strings.NewReader(logData)) 101 | for scanner.Scan() { 102 | callbackFn(notification.Notification{ 103 | ID: notification.NextID(), 104 | Date: eventDate, 105 | ChildProccessID: s.currentChildProcessID, 106 | Type: logType, 107 | Message: scanner.Text(), 108 | }) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (s *streams) Notify(n notification.Notification) error { 115 | if n.Type == notification.NotificationTypeStartup { 116 | s.currentChildProcessID = n.ChildProccessID 117 | } 118 | return nil 119 | } 120 | 121 | func (w *streamWriter) Write(p []byte) (int, error) { 122 | w.streamConsumer <- string(p) 123 | return len(p), nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/bwmarrin/snowflake" 8 | ) 9 | 10 | type NotificationCallback func(n Notification) error 11 | 12 | type NotificationType int 13 | 14 | const ( 15 | NotificationTypeSystemError NotificationType = iota 16 | NotificationTypeSoftRestartRequested 17 | NotificationTypeHardRestartRequested 18 | NotificationTypeOOBTaskRequested 19 | NotificationTypeShutdownRequested 20 | NotificationTypeSystemShutdown 21 | NotificationTypeStartup 22 | NotificationTypeHardRestart 23 | NotificationTypeSoftRestart 24 | NotificationTypeShutdown 25 | NotificationTypeLogEvent 26 | NotificationTypeStdOut 27 | NotificationTypeStdErr 28 | NotificationTypeOOBTaskStartup 29 | NotificationTypeOOBTaskStdOut 30 | NotificationTypeOOBTaskStdErr 31 | NotificationTypeIPC 32 | ) 33 | 34 | type Notification struct { 35 | ID string `json:"id" db:"id"` // snowflake 36 | Date time.Time `json:"createdAt" db:"created_at"` 37 | ChildProccessID string `json:"childProcessId" db:"child_process_id"` // snowflake 38 | Type NotificationType `json:"type" db:"event_type"` 39 | Message string `json:"message" db:"event_data"` 40 | } 41 | 42 | type EventConsumer interface { 43 | Notify(n Notification) error 44 | } 45 | 46 | var ( 47 | generator *snowflake.Node 48 | createGeneratorOnce = sync.Once{} 49 | ) 50 | 51 | func NextID() string { 52 | createGeneratorOnce.Do(func() { 53 | generator, _ = snowflake.NewNode(1) 54 | }) 55 | return generator.Generate().Base32() 56 | } 57 | -------------------------------------------------------------------------------- /internal/notification/notifier.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "time" 24 | 25 | ipc "github.com/jdudmesh/gomon-ipc" 26 | log "github.com/sirupsen/logrus" 27 | ) 28 | 29 | const SoftRestartMessage = "__soft_reload" 30 | const HardRestartMessage = "__hard_restart" 31 | 32 | type Notifier struct { 33 | ipcServer ipc.Connection 34 | callbackFn NotificationCallback 35 | childProcessID string 36 | } 37 | 38 | func NewNotifier(callbackFn NotificationCallback) (*Notifier, error) { 39 | n := &Notifier{ 40 | callbackFn: callbackFn, 41 | } 42 | ipcServer, err := ipc.NewConnection(ipc.ServerConnection, ipc.WithReadHandler(n.handleInboundMessage)) 43 | if err != nil { 44 | return nil, fmt.Errorf("creating IPC server: %w", err) 45 | } 46 | n.ipcServer = ipcServer 47 | return n, nil 48 | } 49 | 50 | func (n *Notifier) Start() error { 51 | ctx, cancelFn := context.WithCancel(context.Background()) 52 | defer cancelFn() 53 | 54 | err := n.ipcServer.ListenAndServe(ctx, func(state ipc.ConnectionState) error { 55 | switch state { 56 | case ipc.Connected: 57 | n.callbackFn(Notification{ 58 | ID: NextID(), 59 | Date: time.Now(), 60 | ChildProccessID: n.childProcessID, 61 | Type: NotificationTypeIPC, 62 | Message: "child process connected", 63 | }) 64 | case ipc.Disconnected: 65 | n.callbackFn(Notification{ 66 | ID: NextID(), 67 | Date: time.Now(), 68 | ChildProccessID: n.childProcessID, 69 | Type: NotificationTypeIPC, 70 | Message: "child process disconnected", 71 | }) 72 | } 73 | return nil 74 | }) 75 | if err != nil { 76 | return fmt.Errorf("starting IPC server: %w", err) 77 | } 78 | 79 | return err 80 | } 81 | 82 | func (n *Notifier) Close() error { 83 | log.Info("closing IPC server") 84 | return n.ipcServer.Close() 85 | } 86 | 87 | func (n *Notifier) Notify(notif Notification) error { 88 | if notif.Type != NotificationTypeStartup { 89 | return nil 90 | } 91 | 92 | n.childProcessID = notif.ChildProccessID 93 | 94 | return nil 95 | } 96 | 97 | func (n *Notifier) SendSoftRestart(hint string) error { 98 | if !n.ipcServer.IsConnected() { 99 | return errors.New("IPC server is not connected") 100 | } 101 | 102 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Second) 103 | defer cancelFn() 104 | 105 | err := n.ipcServer.Write(ctx, []byte(hint)) 106 | if err != nil { 107 | return fmt.Errorf("writing to IPC server: %w", err) 108 | } 109 | return nil 110 | } 111 | 112 | func (n *Notifier) handleInboundMessage(data []byte) error { 113 | msg := string(data) 114 | if len(msg) == 0 { 115 | return nil 116 | } 117 | switch msg { 118 | case HardRestartMessage: 119 | n.callbackFn(Notification{ 120 | ID: NextID(), 121 | Date: time.Now(), 122 | ChildProccessID: n.childProcessID, 123 | Type: NotificationTypeHardRestart, 124 | Message: "hard restart completed", 125 | }) 126 | case SoftRestartMessage: 127 | n.callbackFn(Notification{ 128 | ID: NextID(), 129 | Date: time.Now(), 130 | ChildProccessID: n.childProcessID, 131 | Type: NotificationTypeSoftRestart, 132 | Message: "soft restart completed", 133 | }) 134 | } 135 | 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /internal/process/dummy.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "sync" 21 | 22 | "github.com/jdudmesh/gomon/internal/notification" 23 | ) 24 | 25 | type dummyProcess struct { 26 | childOuterRunWait sync.WaitGroup 27 | } 28 | 29 | func NewDummy() *dummyProcess { 30 | return &dummyProcess{ 31 | childOuterRunWait: sync.WaitGroup{}, 32 | } 33 | } 34 | 35 | func (d *dummyProcess) AddEventConsumer(sink notification.EventConsumer) {} 36 | 37 | func (d *dummyProcess) Start() error { 38 | d.childOuterRunWait.Add(1) 39 | d.childOuterRunWait.Wait() 40 | return nil 41 | } 42 | 43 | func (d *dummyProcess) Close() error { 44 | d.childOuterRunWait.Done() 45 | return nil 46 | } 47 | 48 | func (d *dummyProcess) HardRestart(string) error { 49 | return nil 50 | } 51 | 52 | func (d *dummyProcess) SoftRestart(string) error { 53 | return nil 54 | } 55 | 56 | func (d *dummyProcess) ExecuteOOBTask(string) error { 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/process/oob.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bytes" 21 | "fmt" 22 | "os/exec" 23 | "strings" 24 | "syscall" 25 | "time" 26 | 27 | "github.com/jdudmesh/gomon/internal/notification" 28 | log "github.com/sirupsen/logrus" 29 | ) 30 | 31 | type outOfBandTask struct { 32 | rootDirectory string 33 | task string 34 | envVars []string 35 | } 36 | 37 | func NewOutOfBandTask(rootDirectory string, task string, envVars []string) *outOfBandTask { 38 | return &outOfBandTask{ 39 | rootDirectory: rootDirectory, 40 | task: task, 41 | envVars: envVars, 42 | } 43 | } 44 | 45 | func (o *outOfBandTask) Run(childProcessID string, callbackFn notification.NotificationCallback) error { 46 | log.Infof("running task: %s", o.task) 47 | 48 | stdoutBuf := &bytes.Buffer{} 49 | stderrBuf := &bytes.Buffer{} 50 | 51 | callbackFn(notification.Notification{ 52 | ID: notification.NextID(), 53 | ChildProccessID: childProcessID, 54 | Date: time.Now(), 55 | Type: notification.NotificationTypeOOBTaskStartup, 56 | Message: "running task: " + o.task, 57 | }) 58 | 59 | args := strings.Split(o.task, " ") 60 | cmd := exec.Command(args[0], args[1:]...) 61 | cmd.Dir = o.rootDirectory 62 | cmd.Stdout = stdoutBuf 63 | cmd.Stderr = stderrBuf 64 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 65 | cmd.Env = o.envVars 66 | 67 | err := cmd.Start() 68 | if err != nil { 69 | return fmt.Errorf("starting task: %w", err) 70 | } 71 | 72 | err = cmd.Wait() 73 | 74 | if stdoutBuf.Len() > 0 { 75 | callbackFn(notification.Notification{ 76 | ID: notification.NextID(), 77 | ChildProccessID: childProcessID, 78 | Date: time.Now(), 79 | Type: notification.NotificationTypeOOBTaskStdOut, 80 | Message: string(stdoutBuf.Bytes()), 81 | }) 82 | } 83 | 84 | if stderrBuf.Len() > 0 { 85 | callbackFn(notification.Notification{ 86 | ID: notification.NextID(), 87 | ChildProccessID: childProcessID, 88 | Date: time.Now(), 89 | Type: notification.NotificationTypeOOBTaskStdErr, 90 | Message: string(stderrBuf.Bytes()), 91 | }) 92 | } 93 | 94 | if err != nil { 95 | return fmt.Errorf("running oob task: %w", err) 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/process/process.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bufio" 21 | "context" 22 | "errors" 23 | "fmt" 24 | "io" 25 | "os" 26 | "os/exec" 27 | "strings" 28 | "sync" 29 | "sync/atomic" 30 | "syscall" 31 | "time" 32 | 33 | "github.com/jdudmesh/gomon/internal/config" 34 | "github.com/jdudmesh/gomon/internal/notification" 35 | "github.com/jdudmesh/gomon/internal/utils" 36 | log "github.com/sirupsen/logrus" 37 | ) 38 | 39 | // type ChildProcess interface { 40 | // HardRestart(string) error 41 | // SoftRestart(string) error 42 | // ExecuteOOBTask(string) error 43 | // Start() error 44 | // Close() error 45 | // AddEventConsumer(sink notification.EventConsumer) 46 | // } 47 | 48 | const ( 49 | ForceHardRestart = "__hard_reload" 50 | ForceSoftRestart = "__soft_reload" 51 | ) 52 | 53 | type ConsoleOutput interface { 54 | Stdout() io.Writer 55 | Stderr() io.Writer 56 | } 57 | 58 | type processState int64 59 | 60 | const ( 61 | processStateStopped processState = iota 62 | processStateStarting 63 | processStateStarted 64 | processStateStopping 65 | processStateClosing 66 | processStateClosed 67 | ) 68 | 69 | const ipcStatusDisconnected = "Disconnected" 70 | const initialBackoff = 50 * time.Millisecond 71 | const maxBackoff = 5 * time.Second 72 | 73 | type AtomicChildProcess struct { 74 | value atomic.Value 75 | } 76 | 77 | func (a *AtomicChildProcess) Load() *childProcess { 78 | return a.value.Load().(*childProcess) 79 | } 80 | 81 | func (a *AtomicChildProcess) Store(p *childProcess) { 82 | a.value.Store(p) 83 | } 84 | 85 | type ProcessState int 86 | 87 | const ( 88 | ProcessStateStopped ProcessState = iota 89 | ProcessStateStarting 90 | ProcessStateStarted 91 | ProcessStateStopping 92 | ) 93 | 94 | type childProcess struct { 95 | rootDirectory string 96 | command []string 97 | entrypoint string 98 | envVars []string 99 | entrypointArgs []string 100 | prestart []string 101 | state *utils.State[ProcessState] 102 | childLock sync.Mutex 103 | closeLock sync.Mutex 104 | termChild chan struct{} 105 | killChild chan struct{} 106 | killTimeout time.Duration 107 | childProcessID string 108 | } 109 | 110 | func NewChildProcess(cfg config.Config) (*childProcess, error) { 111 | proc := &childProcess{ 112 | rootDirectory: cfg.RootDirectory, 113 | command: cfg.Command, 114 | entrypoint: cfg.Entrypoint, 115 | envVars: os.Environ(), 116 | entrypointArgs: cfg.EntrypointArgs, 117 | prestart: cfg.Prestart, 118 | state: utils.NewState[ProcessState](ProcessStateStopped), 119 | childLock: sync.Mutex{}, 120 | closeLock: sync.Mutex{}, 121 | termChild: make(chan struct{}), 122 | killChild: make(chan struct{}), 123 | killTimeout: 5 * time.Second, 124 | } 125 | 126 | if len(proc.command) == 0 { 127 | proc.command = []string{"go", "run"} 128 | if proc.entrypoint == "" { 129 | return nil, errors.New("an entrypoint is required") 130 | } 131 | } 132 | 133 | for _, file := range cfg.EnvFiles { 134 | err := proc.loadEnvFile(file) 135 | if err != nil { 136 | return nil, fmt.Errorf("loading env file: %w", err) 137 | } 138 | } 139 | 140 | return proc, nil 141 | } 142 | 143 | func (c *childProcess) Start(console ConsoleOutput, callbackFn notification.NotificationCallback) error { 144 | c.childLock.Lock() 145 | defer c.childLock.Unlock() 146 | 147 | if c.state.Get() != ProcessStateStopped { 148 | return errors.New("process is already running") 149 | } 150 | 151 | c.childProcessID = notification.NextID() 152 | 153 | callbackFn(notification.Notification{ 154 | ID: notification.NextID(), 155 | ChildProccessID: c.childProcessID, 156 | Date: time.Now(), 157 | Type: notification.NotificationTypeStartup, 158 | Message: "process started", 159 | }) 160 | 161 | // run prestart tasks 162 | for _, task := range c.prestart { 163 | err := c.ExecuteOOBTask(task, callbackFn) 164 | if err != nil { 165 | return fmt.Errorf("running prestart task: %w", err) 166 | } 167 | } 168 | 169 | c.state.Set(ProcessStateStarting) 170 | 171 | childCtx, cancelChildCtx := context.WithCancel(context.Background()) 172 | defer cancelChildCtx() 173 | 174 | args := c.command[1:] 175 | if len(c.entrypoint) > 0 { 176 | args = append(args, c.entrypoint) 177 | if len(c.entrypointArgs) > 0 { 178 | args = append(args, c.entrypointArgs...) 179 | } 180 | } 181 | 182 | // create and start the child process 183 | cmd := exec.CommandContext(childCtx, c.command[0], args...) 184 | cmd.Dir = c.rootDirectory 185 | cmd.Stdout = console.Stdout() 186 | cmd.Stderr = console.Stderr() 187 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 188 | cmd.Env = c.envVars 189 | 190 | err := cmd.Start() 191 | if err != nil { 192 | log.Errorf("spawning child process: %+v", err) 193 | return err 194 | } 195 | 196 | c.state.Set(ProcessStateStarted) 197 | 198 | // wait for the child process to exit, putting the exit code into the exitWait channel 199 | // allows us to wait for multiple triggers (signals or process exit) 200 | exitWait := make(chan int) 201 | go func() { 202 | err = cmd.Wait() 203 | if err != nil && !(err.Error() != "signal: terminated" || err.Error() != "signal: killed") { 204 | log.Warnf("child process exited abnormally: %+v", err) 205 | } 206 | 207 | s := cmd.ProcessState.ExitCode() 208 | if s > 0 { 209 | log.Warnf("child process exited with non-zero status: %d", cmd.ProcessState.ExitCode()) 210 | } 211 | exitWait <- s 212 | }() 213 | 214 | // this loop waits for the child process to exit (using the exitWait channel), or for a signal to stop it 215 | var exitCode int 216 | event_loop: 217 | for { 218 | select { 219 | case <-c.termChild: 220 | // graceful shutdown (Windows (non-Posix) clients will not receive this signal) 221 | log.Info("stopping child process: terminate requested") 222 | // confusingly, the syscall.Kill function sends a TERMINATE signal 223 | err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) 224 | if err != nil { 225 | return err 226 | } 227 | case <-c.killChild: 228 | // hard shutdown 229 | log.Info("stopping child process: close requested") 230 | cancelChildCtx() 231 | case exitCode = <-exitWait: 232 | log.Infof("child process exited with status: %d", exitCode) 233 | break event_loop 234 | } 235 | } 236 | 237 | c.state.Set(ProcessStateStopped) 238 | 239 | callbackFn(notification.Notification{ 240 | ID: notification.NextID(), 241 | ChildProccessID: c.childProcessID, 242 | Date: time.Now(), 243 | Type: notification.NotificationTypeShutdown, 244 | Message: fmt.Sprintf("process stopped: exit code %d", exitCode), 245 | }) 246 | 247 | if exitCode > 0 { 248 | return fmt.Errorf("child process exited with status: %d", exitCode) 249 | } 250 | 251 | return nil 252 | } 253 | 254 | func (c *childProcess) loadEnvFile(filename string) error { 255 | if _, err := os.Stat(filename); os.IsNotExist(err) { 256 | log.Warnf("env file %s does not exist", filename) 257 | return nil 258 | } 259 | 260 | file, err := os.Open(filename) 261 | if err != nil { 262 | return err 263 | } 264 | defer file.Close() 265 | 266 | scanner := bufio.NewScanner(file) 267 | for scanner.Scan() { 268 | line := strings.TrimSpace(scanner.Text()) 269 | if strings.HasPrefix(line, "#") || len(line) == 0 { 270 | continue 271 | } 272 | c.envVars = append(c.envVars, line) 273 | } 274 | 275 | if err := scanner.Err(); err != nil { 276 | return err 277 | } 278 | 279 | return nil 280 | } 281 | 282 | func (c *childProcess) ExecuteOOBTask(task string, callbackFn notification.NotificationCallback) error { 283 | oobTask := NewOutOfBandTask(c.rootDirectory, task, c.envVars) 284 | err := oobTask.Run(c.childProcessID, callbackFn) 285 | return err 286 | } 287 | -------------------------------------------------------------------------------- /internal/process/process_posix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package process 5 | 6 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 7 | // Copyright (C) 2023 John Dudmesh 8 | 9 | // This program is free software: you can redistribute it and/or modify 10 | // it under the terms of the GNU General Public License as published by 11 | // the Free Software Foundation, either version 3 of the License, or 12 | // (at your option) any later version. 13 | 14 | // This program is distributed in the hope that it will be useful, 15 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | // GNU General Public License for more details. 18 | 19 | // You should have received a copy of the GNU General Public License 20 | // along with this program. If not, see . 21 | 22 | import ( 23 | "errors" 24 | "time" 25 | 26 | log "github.com/sirupsen/logrus" 27 | ) 28 | 29 | func (c *childProcess) Stop() error { 30 | c.closeLock.Lock() 31 | defer c.closeLock.Unlock() 32 | 33 | if c.state.Get() != ProcessStateStarted { 34 | return errors.New("process is not running") 35 | } 36 | 37 | c.state.Set(ProcessStateStopping) 38 | 39 | isChildClosed := make(chan struct{}) 40 | go func() { 41 | // wait for the child process to close by trying capture lock, will have been locked in the Start method 42 | c.childLock.Lock() 43 | defer c.childLock.Unlock() 44 | // signal that the child process has closed 45 | isChildClosed <- struct{}{} 46 | }() 47 | 48 | // send the signal to the child process to close 49 | c.termChild <- struct{}{} 50 | 51 | // wait for the child process to close or timeout 52 | select { 53 | case <-isChildClosed: 54 | log.Info("child process closed") 55 | case <-time.After(c.killTimeout): 56 | c.killChild <- struct{}{} 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/process/process_test.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "io" 21 | "os" 22 | "testing" 23 | "time" 24 | 25 | "github.com/jdudmesh/gomon/internal/config" 26 | "github.com/jdudmesh/gomon/internal/notification" 27 | ) 28 | 29 | type testConsole struct{} 30 | 31 | func (t *testConsole) Stdout() io.Writer { 32 | return os.Stdout 33 | } 34 | 35 | func (t *testConsole) Stderr() io.Writer { 36 | return os.Stderr 37 | } 38 | 39 | func TestChildProcess(t *testing.T) { 40 | proc, err := NewChildProcess(config.Config{ 41 | RootDirectory: "/bin", 42 | Command: []string{"sleep", "300"}, 43 | }) 44 | 45 | if err != nil { 46 | t.Fatalf("error creating child process: %v", err) 47 | } 48 | 49 | var stopError error 50 | go func() { 51 | <-time.After(5 * time.Second) 52 | t.Log("stopping child process") 53 | stopError = proc.Stop() 54 | }() 55 | 56 | err = proc.Start(&testConsole{}, func(n notification.Notification) error { 57 | t.Logf("notification: %v", n) 58 | return nil 59 | }) 60 | 61 | if err != nil { 62 | t.Fatalf("error starting child process: %v", err) 63 | } 64 | 65 | if stopError != nil { 66 | t.Fatalf("error stopping child process: %v", err) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /internal/process/process_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package process 5 | 6 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 7 | // Copyright (C) 2023 John Dudmesh 8 | 9 | // This program is free software: you can redistribute it and/or modify 10 | // it under the terms of the GNU General Public License as published by 11 | // the Free Software Foundation, either version 3 of the License, or 12 | // (at your option) any later version. 13 | 14 | // This program is distributed in the hope that it will be useful, 15 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | // GNU General Public License for more details. 18 | 19 | // You should have received a copy of the GNU General Public License 20 | // along with this program. If not, see . 21 | 22 | import ( 23 | "errors" 24 | ) 25 | 26 | func (c *childProcess2) Stop() error { 27 | c.closeLock.Lock() 28 | defer c.closeLock.Unlock() 29 | 30 | if c.state.Get() != ProcessStateStarted { 31 | return errors.New("process is not running") 32 | } 33 | 34 | c.state.Set(ProcessStateStopping) 35 | c.killChild <- struct{}{} 36 | 37 | c.childLock.Lock() 38 | defer c.childLock.Unlock() 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "errors" 23 | "fmt" 24 | "io" 25 | "net/http" 26 | "net/http/httputil" 27 | "net/url" 28 | "strings" 29 | "sync" 30 | "time" 31 | 32 | "github.com/jdudmesh/gomon/internal/config" 33 | "github.com/jdudmesh/gomon/internal/notification" 34 | "github.com/r3labs/sse/v2" 35 | log "github.com/sirupsen/logrus" 36 | ) 37 | 38 | const gomonInjectCode = ` 39 | ` 47 | 48 | const headTag = `` 49 | 50 | type webProxy struct { 51 | isEnabled bool 52 | port int 53 | downstreamHost string 54 | downstreamTimeout time.Duration 55 | httpServer *http.Server 56 | sseServer *sse.Server 57 | sseServerLock sync.Mutex 58 | injectCode string 59 | } 60 | 61 | func New(cfg config.Config) (*webProxy, error) { 62 | proxy := &webProxy{ 63 | isEnabled: cfg.Proxy.Enabled, 64 | port: cfg.Proxy.Port, 65 | downstreamHost: cfg.Proxy.Downstream.Host, 66 | downstreamTimeout: time.Duration(cfg.Proxy.Downstream.Timeout) * time.Second, 67 | sseServerLock: sync.Mutex{}, 68 | } 69 | 70 | err := proxy.initProxy() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return proxy, nil 76 | } 77 | func (p *webProxy) Enabled() bool { 78 | return p.isEnabled 79 | } 80 | 81 | func (p *webProxy) initProxy() error { 82 | if !p.isEnabled && p.port == 0 { 83 | return nil 84 | } 85 | 86 | if p.port == 0 { 87 | p.port = 4000 88 | p.isEnabled = true 89 | } 90 | 91 | if p.downstreamHost == "" { 92 | return errors.New("downstream host:port is required") 93 | } 94 | 95 | if !strings.HasPrefix(p.downstreamHost, "http") { 96 | p.downstreamHost = "http://" + p.downstreamHost 97 | } 98 | 99 | if p.downstreamTimeout == 0 { 100 | p.downstreamTimeout = 5 101 | } 102 | 103 | p.injectCode = gomonInjectCode 104 | 105 | p.sseServer = sse.New() 106 | p.sseServer.AutoReplay = false 107 | p.sseServer.CreateStream("hmr") 108 | 109 | mux := http.NewServeMux() 110 | mux.HandleFunc("/__gomon__/reload", p.handleReload) 111 | mux.HandleFunc("/__gomon__/events", p.sseServer.ServeHTTP) 112 | 113 | downstreamURL, err := url.Parse(p.downstreamHost) 114 | if err != nil { 115 | return fmt.Errorf("downstream host: %v", err) 116 | } 117 | 118 | proxy := httputil.NewSingleHostReverseProxy(downstreamURL) 119 | proxy.ModifyResponse = p.proxyRequest 120 | 121 | mux.HandleFunc("/", proxy.ServeHTTP) 122 | 123 | p.httpServer = &http.Server{ 124 | Addr: fmt.Sprintf(":%d", p.port), 125 | Handler: mux, 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (p *webProxy) Start() error { 132 | log.Infof("proxy server running on http://localhost:%d", p.port) 133 | err := p.httpServer.ListenAndServe() 134 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 135 | panic(fmt.Sprintf("proxy server shut down unexpectedly: %v", err)) 136 | } 137 | return nil 138 | } 139 | 140 | func (p *webProxy) Close() error { 141 | log.Info("closing web proxy") 142 | if p.sseServer != nil { 143 | p.sseServer.Close() 144 | } 145 | 146 | if p.httpServer != nil { 147 | return p.httpServer.Shutdown(context.Background()) 148 | } 149 | 150 | return nil 151 | } 152 | 153 | func (p *webProxy) Notify(n notification.Notification) error { 154 | if !p.isEnabled { 155 | return nil 156 | } 157 | 158 | p.sseServerLock.Lock() 159 | defer p.sseServerLock.Unlock() 160 | 161 | switch n.Type { 162 | case notification.NotificationTypeHardRestart, notification.NotificationTypeSoftRestart, notification.NotificationTypeIPC: 163 | log.Infof("notifying browser: %s", n.Message) 164 | p.sseServer.Publish("hmr", &sse.Event{ 165 | Data: []byte(n.Message), 166 | }) 167 | } 168 | return nil 169 | } 170 | 171 | func (p *webProxy) handleReload(res http.ResponseWriter, req *http.Request) { 172 | log.Infof("reloading proxy") 173 | res.WriteHeader(http.StatusOK) 174 | } 175 | 176 | func (p *webProxy) proxyRequest(res *http.Response) error { 177 | isHtml := strings.HasPrefix(res.Header.Get("Content-Type"), "text/html") 178 | if !isHtml { 179 | return nil 180 | } 181 | 182 | outBuf := bytes.Buffer{} 183 | inBuf, err := io.ReadAll(res.Body) 184 | if err != nil { 185 | log.Errorf("reading request body: %v", err) 186 | return err 187 | } 188 | 189 | ix := 0 190 | match := false 191 | for { 192 | if ix >= len(inBuf) { 193 | break 194 | } 195 | 196 | if inBuf[ix] == '<' { 197 | // check if we have a match 198 | match = true 199 | for jx := 0; jx < len(headTag); jx++ { 200 | if ix+jx >= len(inBuf) || inBuf[ix+jx] != headTag[jx] { 201 | match = false 202 | break 203 | } 204 | } 205 | 206 | if match { 207 | cutPos := ix + len(headTag) 208 | // we have a match, inject the code 209 | _, err = outBuf.Write(inBuf[:cutPos]) 210 | if err != nil { 211 | log.Errorf("writing response: %v", err) 212 | return err 213 | } 214 | _, err = outBuf.Write([]byte(p.injectCode)) 215 | if err != nil { 216 | log.Errorf("writing response: %v", err) 217 | return err 218 | } 219 | _, err = outBuf.Write(inBuf[cutPos:]) 220 | if err != nil { 221 | log.Errorf("writing response: %v", err) 222 | return err 223 | } 224 | break 225 | } 226 | } 227 | 228 | ix++ 229 | } 230 | 231 | if !match { 232 | _, err = outBuf.Write(inBuf) 233 | if err != nil { 234 | log.Errorf("writing response: %v", err) 235 | return err 236 | } 237 | } 238 | 239 | res.Body = io.NopCloser(&outBuf) 240 | res.Header["Content-Length"] = []string{fmt.Sprint(outBuf.Len())} 241 | 242 | return nil 243 | } 244 | -------------------------------------------------------------------------------- /internal/utils/database.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "path" 23 | 24 | "github.com/jdudmesh/gomon/internal/config" 25 | "github.com/jdudmesh/gomon/internal/notification" 26 | "github.com/jmoiron/sqlx" 27 | ) 28 | 29 | type Database struct { 30 | db *sqlx.DB 31 | } 32 | 33 | func NewDatabase(config config.Config) (*Database, error) { 34 | dataPath := path.Join(config.RootDirectory, "./.gomon") 35 | _, err := os.Stat(dataPath) 36 | if err != nil { 37 | if os.IsNotExist(err) { 38 | err = os.Mkdir(dataPath, 0755) 39 | if err != nil { 40 | return nil, fmt.Errorf("creating .gomon directory: %w", err) 41 | } 42 | } else { 43 | return nil, fmt.Errorf("checking for .gomon directory: %w", err) 44 | } 45 | } 46 | 47 | db, err := sqlx.Connect("sqlite3", path.Join(dataPath, "./gomon.db")) 48 | if err != nil { 49 | return nil, fmt.Errorf("connecting to sqlite: %w", err) 50 | } 51 | 52 | _, err = db.Exec(schema) 53 | if err != nil { 54 | return nil, fmt.Errorf("creating db schema: %w", err) 55 | } 56 | 57 | return &Database{db: db}, nil 58 | } 59 | 60 | var schema = ` 61 | CREATE TABLE IF NOT EXISTS notifs ( 62 | id TEXT PRIMARY KEY, 63 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 64 | child_process_id TEXT NOT NULL, 65 | event_type TEXT NOT NULL, 66 | event_data TEXT NOT NULL 67 | ); 68 | CREATE INDEX IF NOT EXISTS notifications_child_process_id ON notifs(child_process_id); 69 | CREATE INDEX IF NOT EXISTS notifications_event_type ON notifs(event_type); 70 | ` 71 | 72 | func (d *Database) Close() error { 73 | return d.db.Close() 74 | } 75 | 76 | func (d *Database) Notify(n notification.Notification) error { 77 | _, err := d.db.NamedExec(` 78 | INSERT INTO notifs (id, created_at, child_process_id, event_type, event_data) 79 | VALUES (:id, :created_at, :child_process_id, :event_type, :event_data) 80 | `, n) 81 | return err 82 | } 83 | 84 | func (d *Database) FindRuns() ([]*notification.Notification, error) { 85 | runs := []*notification.Notification{} 86 | err := d.db.Select(&runs, "SELECT * FROM notifs WHERE event_type = ? ORDER BY created_at DESC LIMIT 100;", notification.NotificationTypeStartup) 87 | if err != nil { 88 | return nil, fmt.Errorf("getting runs: %w", err) 89 | } 90 | 91 | return runs, nil 92 | } 93 | 94 | func (d *Database) FindNotifications(runID, stm, filter string) ([][]*notification.Notification, error) { 95 | var err error 96 | notifs := [][]*notification.Notification{} 97 | 98 | if runID == "" { 99 | err = d.db.Get(&runID, "SELECT child_process_id FROM notifs WHERE event_type = ? ORDER BY created_at DESC LIMIT 1;", notification.NotificationTypeStartup) 100 | if err != nil { 101 | return nil, fmt.Errorf("getting last run id: %w", err) 102 | } 103 | } 104 | 105 | if runID != "" { 106 | params := map[string]interface{}{} 107 | sql := "SELECT * FROM notifs WHERE " 108 | if runID == "all" { 109 | sql += "1 = 1 " // dummy clause 110 | } else { 111 | params["child_process_id"] = runID 112 | sql += "child_process_id = :child_process_id " 113 | } 114 | if !(stm == "" || stm == "all") { 115 | sql += " AND event_type = :event_type " 116 | params["event_type"] = stm 117 | } 118 | if filter != "" { 119 | sql += " AND event_data LIKE :event_data " 120 | params["event_data"] = "%" + filter + "%" 121 | } 122 | sql += " ORDER BY child_process_id ASC, created_at ASC limit 1000;" 123 | 124 | res, err := d.db.NamedQuery(sql, params) 125 | if err != nil { 126 | return nil, fmt.Errorf("querying notifications: %w", err) 127 | } 128 | defer res.Close() 129 | 130 | var lastRunID string = "" 131 | for res.Next() { 132 | ev := new(notification.Notification) 133 | err = res.StructScan(ev) 134 | if err != nil { 135 | return nil, fmt.Errorf("scanning notification: %w", err) 136 | } 137 | if ev.ChildProccessID != "" { 138 | if lastRunID != ev.ChildProccessID { 139 | notifs = append(notifs, []*notification.Notification{}) 140 | lastRunID = ev.ChildProccessID 141 | } 142 | notifs[len(notifs)-1] = append(notifs[len(notifs)-1], ev) 143 | } 144 | } 145 | } 146 | 147 | return notifs, nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/utils/state.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "sync/atomic" 21 | ) 22 | 23 | type State[T ~int] struct { 24 | innerState atomic.Int64 25 | } 26 | 27 | func NewState[T ~int](initialState T) *State[T] { 28 | s := &State[T]{ 29 | innerState: atomic.Int64{}, 30 | } 31 | s.innerState.Store(int64(initialState)) 32 | return s 33 | } 34 | 35 | func (s *State[T]) Get() T { 36 | return T(s.innerState.Load()) 37 | } 38 | 39 | func (s *State[T]) Set(newState T) { 40 | s.innerState.Store(int64(newState)) 41 | } 42 | -------------------------------------------------------------------------------- /internal/watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "path" 23 | "path/filepath" 24 | "strings" 25 | "time" 26 | 27 | "github.com/fsnotify/fsnotify" 28 | "github.com/jdudmesh/gomon/internal/config" 29 | "github.com/jdudmesh/gomon/internal/notification" 30 | "github.com/jdudmesh/gomon/internal/process" 31 | log "github.com/sirupsen/logrus" 32 | ) 33 | 34 | type HotReloaderOption func(*filesystemWatcher) error 35 | 36 | type filesystemWatcher struct { 37 | rootDirectory string 38 | hardReload []string 39 | softReload []string 40 | envFiles []string 41 | generated map[string][]string 42 | excludePaths []string 43 | watcher *fsnotify.Watcher 44 | } 45 | 46 | func New(cfg config.Config, opts ...HotReloaderOption) (*filesystemWatcher, error) { 47 | reloader := &filesystemWatcher{ 48 | rootDirectory: cfg.RootDirectory, 49 | hardReload: cfg.HardReload, 50 | softReload: cfg.SoftReload, 51 | envFiles: cfg.EnvFiles, 52 | generated: cfg.Generated, 53 | excludePaths: []string{".git", ".vscode", ".idea"}, 54 | } 55 | 56 | reloader.excludePaths = append(reloader.excludePaths, cfg.ExcludePaths...) 57 | 58 | for _, opt := range opts { 59 | err := opt(reloader) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return reloader, nil 66 | } 67 | 68 | func (w *filesystemWatcher) Close() error { 69 | if w.watcher != nil { 70 | log.Info("closing file watcher") 71 | err := w.watcher.Close() 72 | if err != nil { 73 | return fmt.Errorf("closing watcher: %w", err) 74 | } 75 | } 76 | return nil 77 | } 78 | 79 | func (w *filesystemWatcher) Watch(callbackFn notification.NotificationCallback) error { 80 | var err error 81 | log.Infof("starting gomon with root directory: %s", w.rootDirectory) 82 | 83 | w.watcher, err = fsnotify.NewWatcher() 84 | if err != nil { 85 | return fmt.Errorf("watcher: %+v", err) 86 | } 87 | 88 | err = w.init() 89 | if err != nil { 90 | return fmt.Errorf("adding watcher for root path: %w", err) 91 | } 92 | 93 | for { 94 | select { 95 | case event, ok := <-w.watcher.Events: 96 | if !ok { 97 | break 98 | } 99 | if event.Has(fsnotify.Write) { 100 | w.processFileChange(event, callbackFn) 101 | } 102 | case err, ok := <-w.watcher.Errors: 103 | if !ok { 104 | break 105 | } 106 | log.Errorf("watcher: %+v", err) 107 | } 108 | } 109 | } 110 | 111 | func (w *filesystemWatcher) processFileChange(event fsnotify.Event, callbackFn notification.NotificationCallback) { 112 | filePath, _ := filepath.Abs(event.Name) 113 | relPath, err := filepath.Rel(w.rootDirectory, filePath) 114 | if err != nil { 115 | log.Errorf("failed to get relative path for %s: %+v", filePath, err) 116 | relPath = filePath 117 | } 118 | 119 | for _, exclude := range w.excludePaths { 120 | if strings.HasPrefix(relPath, exclude) { 121 | log.Debugf("excluded file: %s", relPath) 122 | return 123 | } 124 | } 125 | 126 | for _, hard := range w.hardReload { 127 | if match, _ := filepath.Match(hard, filepath.Base(filePath)); match { 128 | callbackFn(notification.Notification{ 129 | ID: notification.NextID(), 130 | ChildProccessID: "", 131 | Date: time.Now(), 132 | Type: notification.NotificationTypeHardRestartRequested, 133 | Message: relPath, 134 | }) 135 | return 136 | } 137 | } 138 | 139 | for _, soft := range w.softReload { 140 | if match, _ := filepath.Match(soft, filepath.Base(filePath)); match { 141 | callbackFn(notification.Notification{ 142 | ID: notification.NextID(), 143 | ChildProccessID: "", 144 | Date: time.Now(), 145 | Type: notification.NotificationTypeSoftRestartRequested, 146 | Message: relPath, 147 | }) 148 | return 149 | } 150 | } 151 | 152 | for patt, generated := range w.generated { 153 | if match, _ := filepath.Match(patt, filepath.Base(filePath)); match { 154 | log.Infof("generated file source: %s", relPath) 155 | for _, task := range generated { 156 | switch task { 157 | case process.ForceHardRestart: 158 | callbackFn(notification.Notification{ 159 | ID: notification.NextID(), 160 | ChildProccessID: "", 161 | Date: time.Now(), 162 | Type: notification.NotificationTypeHardRestartRequested, 163 | Message: relPath, 164 | }) 165 | case process.ForceSoftRestart: 166 | callbackFn(notification.Notification{ 167 | ID: notification.NextID(), 168 | ChildProccessID: "", 169 | Date: time.Now(), 170 | Type: notification.NotificationTypeSoftRestartRequested, 171 | Message: relPath, 172 | }) 173 | default: 174 | callbackFn(notification.Notification{ 175 | ID: notification.NextID(), 176 | ChildProccessID: "", 177 | Date: time.Now(), 178 | Type: notification.NotificationTypeOOBTaskRequested, 179 | Message: task, 180 | }) 181 | } 182 | } 183 | } 184 | return 185 | } 186 | 187 | if w.envFiles != nil { 188 | f := filepath.Base(filePath) 189 | for _, envFile := range w.envFiles { 190 | if f == envFile { 191 | log.Infof("modified env file: %s", relPath) 192 | callbackFn(notification.Notification{ 193 | ID: notification.NextID(), 194 | ChildProccessID: "", 195 | Date: time.Now(), 196 | Type: notification.NotificationTypeHardRestartRequested, 197 | Message: relPath, 198 | }) 199 | return 200 | } 201 | } 202 | } 203 | 204 | log.Infof("unhandled modified file: %s", relPath) 205 | } 206 | 207 | func (w *filesystemWatcher) init() error { 208 | return filepath.Walk(w.rootDirectory, func(srcPath string, f os.FileInfo, err error) error { 209 | if err != nil { 210 | return err 211 | } 212 | if f.IsDir() { 213 | isExcluded := false 214 | for _, exclude := range w.excludePaths { 215 | p := path.Join(w.rootDirectory, exclude) 216 | if srcPath == p { 217 | isExcluded = true 218 | break 219 | } 220 | } 221 | if isExcluded { 222 | return filepath.SkipDir 223 | } 224 | return w.watcher.Add(srcPath) 225 | } 226 | return nil 227 | }) 228 | } 229 | -------------------------------------------------------------------------------- /internal/webui/index.templ: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "github.com/jdudmesh/gomon/internal/notification" 21 | "strconv" 22 | ) 23 | 24 | var colourMap = map[notification.NotificationType]string{ 25 | notification.NotificationTypeStartup: "text-blue-400", 26 | notification.NotificationTypeShutdown: "text-blue-400", 27 | notification.NotificationTypeHardRestart: "text-blue-400", 28 | notification.NotificationTypeSoftRestart: "text-blue-400", 29 | notification.NotificationTypeIPC: "text-blue-400", 30 | notification.NotificationTypeStdOut: "text-green-400", 31 | notification.NotificationTypeStdErr: "text-red-400", 32 | notification.NotificationTypeOOBTaskStartup: "text-yellow-400", 33 | notification.NotificationTypeOOBTaskStdOut: "text-yellow-400", 34 | notification.NotificationTypeOOBTaskStdErr: "text-orange-400", 35 | } 36 | 37 | templ SearchNoResults() { 38 |
no events found
39 | } 40 | 41 | templ SearchSelect(runs []*notification.Notification, currentRun string) { 42 | 58 | } 59 | 60 | templ Event(n *notification.Notification) { 61 | if col, ok := colourMap[n.Type]; ok { 62 |
63 |
{ n.Date.Format("15:04:05.000") }
64 |
65 | { n.Message } 66 |
67 |
68 | if len(n.Message) > 0 { 69 |
70 | 71 | 72 | 73 | 74 |
75 | } 76 |
77 |
78 | } else { 79 |
80 |
{ n.Date.Format("15:04:05.000") }
81 |
82 |
{ n.Message }
83 | if len(n.Message) > 0 { 84 |
85 | 86 | 87 | 88 | 89 |
90 | } 91 |
92 |
93 | } 94 | } 95 | 96 | templ EmptyRun(id string) { 97 |
98 |
99 | } 100 | 101 | templ EventList(notifs [][]*notification.Notification) { 102 | for _, run := range notifs { 103 |
104 |
105 | for _, n := range run { 106 | @Event(n) 107 | } 108 |
109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/webui/index_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ@v0.2.364 DO NOT EDIT. 2 | 3 | package webui 4 | 5 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 6 | 7 | import "github.com/a-h/templ" 8 | import "context" 9 | import "io" 10 | import "bytes" 11 | 12 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 13 | // Copyright (C) 2023 John Dudmesh 14 | 15 | // This program is free software: you can redistribute it and/or modify 16 | // it under the terms of the GNU General Public License as published by 17 | // the Free Software Foundation, either version 3 of the License, or 18 | // (at your option) any later version. 19 | 20 | // This program is distributed in the hope that it will be useful, 21 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | // GNU General Public License for more details. 24 | 25 | // You should have received a copy of the GNU General Public License 26 | // along with this program. If not, see . 27 | 28 | import ( 29 | "github.com/jdudmesh/gomon/internal/notification" 30 | "strconv" 31 | ) 32 | 33 | var colourMap = map[notification.NotificationType]string{ 34 | notification.NotificationTypeStartup: "text-blue-400", 35 | notification.NotificationTypeShutdown: "text-blue-400", 36 | notification.NotificationTypeHardRestart: "text-blue-400", 37 | notification.NotificationTypeSoftRestart: "text-blue-400", 38 | notification.NotificationTypeIPC: "text-blue-400", 39 | notification.NotificationTypeStdOut: "text-green-400", 40 | notification.NotificationTypeStdErr: "text-red-400", 41 | notification.NotificationTypeOOBTaskStartup: "text-yellow-400", 42 | notification.NotificationTypeOOBTaskStdOut: "text-yellow-400", 43 | notification.NotificationTypeOOBTaskStdErr: "text-orange-400", 44 | } 45 | 46 | func SearchNoResults() templ.Component { 47 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 48 | templBuffer, templIsBuffer := w.(*bytes.Buffer) 49 | if !templIsBuffer { 50 | templBuffer = templ.GetBuffer() 51 | defer templ.ReleaseBuffer(templBuffer) 52 | } 53 | ctx = templ.InitializeContext(ctx) 54 | var_1 := templ.GetChildren(ctx) 55 | if var_1 == nil { 56 | var_1 = templ.NopComponent 57 | } 58 | ctx = templ.ClearChildren(ctx) 59 | _, err = templBuffer.WriteString("
") 60 | if err != nil { 61 | return err 62 | } 63 | var_2 := `no events found` 64 | _, err = templBuffer.WriteString(var_2) 65 | if err != nil { 66 | return err 67 | } 68 | _, err = templBuffer.WriteString("
") 69 | if err != nil { 70 | return err 71 | } 72 | if !templIsBuffer { 73 | _, err = templBuffer.WriteTo(w) 74 | } 75 | return err 76 | }) 77 | } 78 | 79 | func SearchSelect(runs []*notification.Notification, currentRun string) templ.Component { 80 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 81 | templBuffer, templIsBuffer := w.(*bytes.Buffer) 82 | if !templIsBuffer { 83 | templBuffer = templ.GetBuffer() 84 | defer templ.ReleaseBuffer(templBuffer) 85 | } 86 | ctx = templ.InitializeContext(ctx) 87 | var_3 := templ.GetChildren(ctx) 88 | if var_3 == nil { 89 | var_3 = templ.NopComponent 90 | } 91 | ctx = templ.ClearChildren(ctx) 92 | _, err = templBuffer.WriteString("") 141 | if err != nil { 142 | return err 143 | } 144 | if !templIsBuffer { 145 | _, err = templBuffer.WriteTo(w) 146 | } 147 | return err 148 | }) 149 | } 150 | 151 | func Event(n *notification.Notification) templ.Component { 152 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 153 | templBuffer, templIsBuffer := w.(*bytes.Buffer) 154 | if !templIsBuffer { 155 | templBuffer = templ.GetBuffer() 156 | defer templ.ReleaseBuffer(templBuffer) 157 | } 158 | ctx = templ.InitializeContext(ctx) 159 | var_6 := templ.GetChildren(ctx) 160 | if var_6 == nil { 161 | var_6 = templ.NopComponent 162 | } 163 | ctx = templ.ClearChildren(ctx) 164 | if col, ok := colourMap[n.Type]; ok { 165 | var var_7 = []any{"log-entry flex flex-row gap-4 items-stretch " + col} 166 | err = templ.RenderCSSItems(ctx, templBuffer, var_7...) 167 | if err != nil { 168 | return err 169 | } 170 | _, err = templBuffer.WriteString("
") 187 | if err != nil { 188 | return err 189 | } 190 | var var_8 string = n.Date.Format("15:04:05.000") 191 | _, err = templBuffer.WriteString(templ.EscapeString(var_8)) 192 | if err != nil { 193 | return err 194 | } 195 | _, err = templBuffer.WriteString("
") 196 | if err != nil { 197 | return err 198 | } 199 | var var_9 string = n.Message 200 | _, err = templBuffer.WriteString(templ.EscapeString(var_9)) 201 | if err != nil { 202 | return err 203 | } 204 | _, err = templBuffer.WriteString("
") 205 | if err != nil { 206 | return err 207 | } 208 | if len(n.Message) > 0 { 209 | _, err = templBuffer.WriteString("
") 210 | if err != nil { 211 | return err 212 | } 213 | } 214 | _, err = templBuffer.WriteString("
") 215 | if err != nil { 216 | return err 217 | } 218 | } else { 219 | _, err = templBuffer.WriteString("
") 228 | if err != nil { 229 | return err 230 | } 231 | var var_10 string = n.Date.Format("15:04:05.000") 232 | _, err = templBuffer.WriteString(templ.EscapeString(var_10)) 233 | if err != nil { 234 | return err 235 | } 236 | _, err = templBuffer.WriteString("
") 237 | if err != nil { 238 | return err 239 | } 240 | var var_11 string = n.Message 241 | _, err = templBuffer.WriteString(templ.EscapeString(var_11)) 242 | if err != nil { 243 | return err 244 | } 245 | _, err = templBuffer.WriteString("
") 246 | if err != nil { 247 | return err 248 | } 249 | if len(n.Message) > 0 { 250 | _, err = templBuffer.WriteString("
") 251 | if err != nil { 252 | return err 253 | } 254 | } 255 | _, err = templBuffer.WriteString("
") 256 | if err != nil { 257 | return err 258 | } 259 | } 260 | if !templIsBuffer { 261 | _, err = templBuffer.WriteTo(w) 262 | } 263 | return err 264 | }) 265 | } 266 | 267 | func EmptyRun(id string) templ.Component { 268 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 269 | templBuffer, templIsBuffer := w.(*bytes.Buffer) 270 | if !templIsBuffer { 271 | templBuffer = templ.GetBuffer() 272 | defer templ.ReleaseBuffer(templBuffer) 273 | } 274 | ctx = templ.InitializeContext(ctx) 275 | var_12 := templ.GetChildren(ctx) 276 | if var_12 == nil { 277 | var_12 = templ.NopComponent 278 | } 279 | ctx = templ.ClearChildren(ctx) 280 | _, err = templBuffer.WriteString("
") 289 | if err != nil { 290 | return err 291 | } 292 | if !templIsBuffer { 293 | _, err = templBuffer.WriteTo(w) 294 | } 295 | return err 296 | }) 297 | } 298 | 299 | func EventList(notifs [][]*notification.Notification) templ.Component { 300 | return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { 301 | templBuffer, templIsBuffer := w.(*bytes.Buffer) 302 | if !templIsBuffer { 303 | templBuffer = templ.GetBuffer() 304 | defer templ.ReleaseBuffer(templBuffer) 305 | } 306 | ctx = templ.InitializeContext(ctx) 307 | var_13 := templ.GetChildren(ctx) 308 | if var_13 == nil { 309 | var_13 = templ.NopComponent 310 | } 311 | ctx = templ.ClearChildren(ctx) 312 | for _, run := range notifs { 313 | _, err = templBuffer.WriteString("
") 322 | if err != nil { 323 | return err 324 | } 325 | for _, n := range run { 326 | err = Event(n).Render(ctx, templBuffer) 327 | if err != nil { 328 | return err 329 | } 330 | } 331 | _, err = templBuffer.WriteString("
") 332 | if err != nil { 333 | return err 334 | } 335 | } 336 | if !templIsBuffer { 337 | _, err = templBuffer.WriteTo(w) 338 | } 339 | return err 340 | }) 341 | } 342 | -------------------------------------------------------------------------------- /internal/webui/server.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "encoding/json" 23 | "errors" 24 | "fmt" 25 | "io" 26 | "net/http" 27 | "sync" 28 | "time" 29 | 30 | "github.com/a-h/templ" 31 | "github.com/jdudmesh/gomon/internal/config" 32 | "github.com/jdudmesh/gomon/internal/notification" 33 | _ "github.com/mattn/go-sqlite3" 34 | "github.com/r3labs/sse/v2" 35 | log "github.com/sirupsen/logrus" 36 | ) 37 | 38 | type SSEEvent struct { 39 | ID string `json:"id"` 40 | Date string `json:"dt"` 41 | Target string `json:"target"` 42 | Markup string `json:"markup"` 43 | Swap string `json:"swap"` 44 | } 45 | 46 | type Database interface { 47 | FindNotifications(runID, stm, filter string) ([][]*notification.Notification, error) 48 | FindRuns() ([]*notification.Notification, error) 49 | } 50 | 51 | type server struct { 52 | isEnabled bool 53 | port int 54 | httpServer *http.Server 55 | sseServer *sse.Server 56 | db Database 57 | callbackFn notification.NotificationCallback 58 | currentChildProcessID string 59 | notificationLock sync.Mutex 60 | } 61 | 62 | func withCORS(next http.Handler) http.Handler { 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | origin := r.Header.Get("Origin") 65 | w.Header().Set("Access-Control-Allow-Origin", origin) 66 | next.ServeHTTP(w, r) 67 | }) 68 | } 69 | 70 | func New(cfg config.Config, db Database, callbackFn notification.NotificationCallback) (*server, error) { 71 | srv := &server{ 72 | isEnabled: cfg.UI.Enabled, 73 | port: cfg.UI.Port, 74 | db: db, 75 | callbackFn: callbackFn, 76 | notificationLock: sync.Mutex{}, 77 | } 78 | 79 | if !srv.isEnabled { 80 | return srv, nil 81 | } 82 | 83 | if srv.port == 0 { 84 | srv.port = 4001 85 | } 86 | 87 | srv.sseServer = sse.New() 88 | srv.sseServer.AutoReplay = false 89 | srv.sseServer.Headers["Access-Control-Allow-Origin"] = "*" 90 | srv.sseServer.CreateStream("events") 91 | 92 | mux := http.NewServeMux() 93 | mux.HandleFunc("/", srv.indexPageHandler) 94 | mux.HandleFunc("/dist/main.js", srv.clientBundleScriptHandler) 95 | mux.HandleFunc("/dist/main.css", srv.clientBundleStylesheetHandler) 96 | mux.Handle("/actions/restart", withCORS(http.HandlerFunc(srv.restartActionHandler))) 97 | mux.Handle("/actions/exit", withCORS(http.HandlerFunc(srv.exitActionHandler))) 98 | mux.Handle("/actions/search", withCORS(http.HandlerFunc(srv.searchActionHandler))) 99 | mux.Handle("/components/search-select", withCORS(http.HandlerFunc(srv.searchSelectComponentHandler))) 100 | mux.Handle("/sse", srv.sseServer) 101 | 102 | srv.httpServer = &http.Server{ 103 | Addr: fmt.Sprintf(":%d", srv.port), 104 | Handler: mux, 105 | } 106 | 107 | return srv, nil 108 | } 109 | 110 | func (c *server) Start() error { 111 | if !c.isEnabled { 112 | return nil 113 | } 114 | 115 | log.Infof("Starting UI server on http://localhost:%d", c.port) 116 | err := c.httpServer.ListenAndServe() 117 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 118 | panic(fmt.Sprintf("ui server shut down unexpectedly: %v", err)) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (c *server) Close() error { 125 | log.Info("closing UI server") 126 | 127 | if c.sseServer != nil { 128 | c.sseServer.Close() 129 | } 130 | 131 | if c.httpServer != nil { 132 | err := c.httpServer.Close() 133 | if err != nil { 134 | return fmt.Errorf("closing http server: %w", err) 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func (c *server) Enabled() bool { 142 | return c.isEnabled 143 | } 144 | 145 | func (c *server) Notify(n notification.Notification) error { 146 | if n.ChildProccessID == "" { 147 | return nil 148 | } 149 | 150 | c.notificationLock.Lock() 151 | defer c.notificationLock.Unlock() 152 | 153 | var err error 154 | 155 | switch n.Type { 156 | case notification.NotificationTypeStartup: 157 | c.currentChildProcessID = n.ChildProccessID 158 | err = c.sendRunEvent(n) 159 | default: 160 | err = c.sendLogEvent(n) 161 | } 162 | if err != nil { 163 | return fmt.Errorf("sending log event: %w", err) 164 | } 165 | return nil 166 | } 167 | 168 | func (c *server) sendLogEvent(n notification.Notification) error { 169 | buffer := bytes.Buffer{} 170 | err := Event(&n).Render(context.Background(), &buffer) 171 | if err != nil { 172 | return fmt.Errorf("rendering event: %w", err) 173 | } 174 | 175 | msg := SSEEvent{ 176 | ID: n.ID, 177 | Date: n.Date.Format(time.RFC3339), 178 | Target: "#" + n.ChildProccessID, 179 | Swap: "beforeend scroll:lastchild", 180 | Markup: buffer.String(), 181 | } 182 | msgBytes, err := json.Marshal(msg) 183 | if err != nil { 184 | return fmt.Errorf("marshalling event: %w", err) 185 | } 186 | 187 | c.sseServer.Publish("events", &sse.Event{ 188 | Data: msgBytes, 189 | }) 190 | 191 | return nil 192 | } 193 | 194 | func (c *server) sendRunEvent(n notification.Notification) error { 195 | buffer := bytes.Buffer{} 196 | err := EmptyRun(n.ChildProccessID).Render(context.Background(), &buffer) 197 | if err != nil { 198 | return fmt.Errorf("rendering event: %w", err) 199 | } 200 | 201 | msg := SSEEvent{ 202 | ID: n.ID, 203 | Date: n.Date.Format(time.RFC3339), 204 | Target: "#log-output-inner", 205 | Swap: "beforeend scroll:lastchild", 206 | Markup: buffer.String(), 207 | } 208 | msgBytes, err := json.Marshal(msg) 209 | if err != nil { 210 | return fmt.Errorf("marshalling event: %w", err) 211 | } 212 | c.sseServer.Publish("events", &sse.Event{ 213 | Data: msgBytes, 214 | }) 215 | 216 | buffer = bytes.Buffer{} 217 | err = c.searchSelectComponent(&buffer) 218 | if err != nil { 219 | log.Errorf("rendering: %v", err) 220 | return fmt.Errorf("rendering event: %w", err) 221 | } 222 | msg = SSEEvent{ 223 | ID: n.ID, 224 | Date: n.Date.Format(time.RFC3339), 225 | Target: "#search-select", 226 | Markup: buffer.String(), 227 | } 228 | msgBytes, err = json.Marshal(msg) 229 | if err != nil { 230 | return fmt.Errorf("marshalling event: %w", err) 231 | } 232 | c.sseServer.Publish("events", &sse.Event{ 233 | Data: msgBytes, 234 | }) 235 | 236 | return nil 237 | } 238 | 239 | func (c *server) restartActionHandler(w http.ResponseWriter, r *http.Request) { 240 | c.callbackFn(notification.Notification{ 241 | ID: notification.NextID(), 242 | Date: time.Now(), 243 | ChildProccessID: c.currentChildProcessID, 244 | Type: notification.NotificationTypeHardRestartRequested, 245 | Message: "webui", 246 | }) 247 | w.WriteHeader(http.StatusOK) 248 | } 249 | 250 | func (c *server) exitActionHandler(w http.ResponseWriter, r *http.Request) { 251 | c.callbackFn(notification.Notification{ 252 | ID: notification.NextID(), 253 | Date: time.Now(), 254 | ChildProccessID: c.currentChildProcessID, 255 | Type: notification.NotificationTypeShutdownRequested, 256 | Message: "webui", 257 | }) 258 | w.WriteHeader(http.StatusOK) 259 | } 260 | 261 | func (c *server) searchActionHandler(w http.ResponseWriter, r *http.Request) { 262 | var err error 263 | runID := r.URL.Query().Get("r") 264 | stm := r.URL.Query().Get("stm") 265 | filter := r.URL.Query().Get("q") 266 | 267 | events, err := c.db.FindNotifications(runID, stm, filter) 268 | if err != nil { 269 | log.Errorf("finding notifications: %v", err) 270 | w.WriteHeader(http.StatusInternalServerError) 271 | return 272 | } 273 | 274 | markup := (templ.Component)(nil) 275 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 276 | if len(events) == 0 { 277 | markup = SearchNoResults() 278 | } else { 279 | markup = EventList(events) 280 | } 281 | 282 | err = markup.Render(r.Context(), w) 283 | if err != nil { 284 | log.Errorf("rendering index: %v", err) 285 | w.WriteHeader(http.StatusInternalServerError) 286 | return 287 | } 288 | } 289 | 290 | func (c *server) searchSelectComponentHandler(w http.ResponseWriter, r *http.Request) { 291 | buf := bytes.Buffer{} 292 | err := c.searchSelectComponent(&buf) 293 | if err != nil { 294 | log.Errorf("rendering: %v", err) 295 | w.WriteHeader(http.StatusInternalServerError) 296 | return 297 | } 298 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 299 | w.Write(buf.Bytes()) 300 | } 301 | 302 | func (c *server) searchSelectComponent(w io.Writer) error { 303 | runs, err := c.db.FindRuns() 304 | if err != nil { 305 | return fmt.Errorf("finding runs: %w", err) 306 | } 307 | 308 | currentRun := "" 309 | if len(runs) > 0 { 310 | currentRun = runs[0].ChildProccessID 311 | } 312 | 313 | markup := SearchSelect(runs, currentRun) 314 | err = markup.Render(context.Background(), w) 315 | if err != nil { 316 | return fmt.Errorf("rendering data: %w", err) 317 | } 318 | 319 | return nil 320 | } 321 | 322 | func (c *server) indexPageHandler(w http.ResponseWriter, r *http.Request) { 323 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 324 | w.Write(index) 325 | } 326 | 327 | func (c *server) clientBundleScriptHandler(w http.ResponseWriter, r *http.Request) { 328 | w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 329 | w.Write(script) 330 | } 331 | 332 | func (c *server) clientBundleStylesheetHandler(w http.ResponseWriter, r *http.Request) { 333 | w.Header().Set("Content-Type", "text/css; charset=utf-8") 334 | w.Write(stylesheet) 335 | } 336 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // gomon is a simple command line tool that watches your files and automatically restarts the application when it detects any changes in the working directory. 4 | // Copyright (C) 2023 John Dudmesh 5 | 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License as published by 8 | // the Free Software Foundation, either version 3 of the License, or 9 | // (at your option) any later version. 10 | 11 | // This program is distributed in the hope that it will be useful, 12 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | // GNU General Public License for more details. 15 | 16 | // You should have received a copy of the GNU General Public License 17 | // along with this program. If not, see . 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "flag" 23 | "fmt" 24 | "os" 25 | "path/filepath" 26 | "strings" 27 | 28 | "github.com/jdudmesh/gomon/internal/app" 29 | "github.com/jdudmesh/gomon/internal/config" 30 | log "github.com/sirupsen/logrus" 31 | ) 32 | 33 | const ( 34 | red = 31 35 | yellow = 33 36 | blue = 36 37 | gray = 37 38 | ) 39 | 40 | func main() { 41 | formatter := new(logFormatter) 42 | log.SetFormatter(formatter) 43 | 44 | cfg, err := loadConfig() 45 | if err != nil { 46 | log.Fatalf("loading config: %v", err) 47 | } 48 | 49 | if cfg.Entrypoint == "" { 50 | log.Fatalf("entrypoint is required") 51 | } 52 | 53 | err = os.Chdir(cfg.RootDirectory) 54 | if err != nil { 55 | log.Fatalf("Cannot set working directory: %v", err) 56 | } 57 | 58 | // create a context that can be used to cancel all the other components 59 | ctx, ctxCancel := context.WithCancel(context.Background()) 60 | defer ctxCancel() 61 | 62 | // create the app, this orchestrates all the other components 63 | app, err := app.New(cfg) 64 | if err != nil { 65 | log.Fatalf("creating app: %v", err) 66 | } 67 | defer app.Close() 68 | 69 | // run the web proxy 70 | go func() { 71 | err = app.RunProxy() 72 | if err != nil { 73 | log.Errorf("starting proxy: %v", err) 74 | ctxCancel() 75 | } 76 | }() 77 | 78 | // run the user interface 79 | go func() { 80 | err := app.RunWebUI() 81 | if err != nil { 82 | log.Errorf("starting web UI: %v", err) 83 | ctxCancel() 84 | } 85 | }() 86 | 87 | // start the console 88 | go func() { 89 | err := app.RunConsole() 90 | if err != nil { 91 | log.Errorf("starting console: %v", err) 92 | ctxCancel() 93 | } 94 | }() 95 | 96 | // start the IPC server 97 | go func() { 98 | err := app.RunNotifer() 99 | if err != nil { 100 | log.Errorf("starting IPC server: %v", err) 101 | ctxCancel() 102 | } 103 | }() 104 | 105 | // start listening for file changes 106 | go func() { 107 | err := app.MonitorFileChanges(ctx) 108 | if err != nil { 109 | ctxCancel() 110 | } 111 | }() 112 | 113 | // monitor and handle signals 114 | go func() { 115 | err := app.ProcessSignals() 116 | if err != nil { 117 | ctxCancel() 118 | } 119 | }() 120 | 121 | // monitor and handle restart events 122 | go app.ProcessRestartEvents(ctx) 123 | 124 | // all components should be up and running by now 125 | pid := os.Getpid() 126 | log.Infof("gomon started with pid %d", pid) 127 | 128 | // this is the main process loop, just keep restarting the child process until the main context is cancelled or an error occurs 129 | if !cfg.ProxyOnly { 130 | go func() { 131 | for ctx.Err() == nil { 132 | err := app.RunChildProcess(cfg) 133 | if err != nil { 134 | ctxCancel() 135 | } 136 | } 137 | }() 138 | } 139 | 140 | <-ctx.Done() 141 | } 142 | 143 | func loadConfig() (config.Config, error) { 144 | var configPath string 145 | var rootDirectory string 146 | var entrypoint string 147 | var entrypointArgs []string 148 | var envFiles string 149 | var proxyOnly bool 150 | 151 | fs := flag.NewFlagSet("gomon flags", flag.ExitOnError) 152 | fs.StringVar(&configPath, "conf", "", "Path to a config file (gomon.config.yml))") 153 | fs.StringVar(&rootDirectory, "dir", "", "The directory to watch") 154 | fs.StringVar(&envFiles, "env", "", "A comma separated list of env files to load") 155 | fs.BoolVar(&proxyOnly, "proxy-only", false, "Only start the proxy, do not start the child process") 156 | err := fs.Parse(os.Args[1:]) 157 | if err != nil { 158 | log.Fatalf("parsing flags: %v", err) 159 | } 160 | 161 | args := strings.Split(fs.Arg(0), " ") 162 | entrypoint = args[0] 163 | entrypointArgs = args[1:] 164 | 165 | if rootDirectory == "" { 166 | curDir, err := os.Getwd() 167 | if err != nil { 168 | log.Fatalf("getting current directory: %v", err) 169 | } 170 | rootDirectory = curDir 171 | } 172 | 173 | if configPath == "" { 174 | nextConfigPath := filepath.Join(rootDirectory, config.DefaultConfigFileName) 175 | if _, err := os.Stat(nextConfigPath); err == nil { 176 | configPath = nextConfigPath 177 | } else if !os.IsNotExist(err) { 178 | log.Fatalf("checking for default config file: %v", err) 179 | } 180 | } 181 | 182 | cfg, err := config.New(configPath) 183 | if err != nil { 184 | log.Fatalf("loading config: %v", err) 185 | } 186 | 187 | if cfg.RootDirectory == "" { 188 | cfg.RootDirectory = rootDirectory 189 | } 190 | 191 | if entrypoint != "" { 192 | cfg.Entrypoint = entrypoint 193 | } 194 | 195 | if len(entrypointArgs) > 0 { 196 | cfg.EntrypointArgs = entrypointArgs 197 | } 198 | 199 | if envFiles != "" { 200 | cfg.EnvFiles = strings.Split(envFiles, ",") 201 | } 202 | 203 | if proxyOnly { 204 | cfg.ProxyOnly = true 205 | } 206 | 207 | return cfg, nil 208 | } 209 | 210 | type logFormatter struct { 211 | } 212 | 213 | func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) { 214 | var levelColor int 215 | switch entry.Level { 216 | case log.DebugLevel, log.TraceLevel: 217 | levelColor = gray 218 | case log.WarnLevel: 219 | levelColor = yellow 220 | case log.ErrorLevel, log.FatalLevel, log.PanicLevel: 221 | levelColor = red 222 | case log.InfoLevel: 223 | levelColor = blue 224 | default: 225 | levelColor = blue 226 | } 227 | 228 | entry.Message = strings.TrimSuffix(entry.Message, "\n") 229 | 230 | b := &bytes.Buffer{} 231 | fmt.Fprintf(b, "\x1b[%dm%s", levelColor, entry.Message) 232 | 233 | b.WriteByte('\n') 234 | return b.Bytes(), nil 235 | 236 | } 237 | -------------------------------------------------------------------------------- /screenshot/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdudmesh/gomon/012876b6f48bccb611accb29efabc221d4f6058d/screenshot/screenshot.png --------------------------------------------------------------------------------