commit 875a60f90f162c99d364c1993d1583d8ea9986f2 Author: librelad Date: Thu May 21 20:37:54 2026 +0100 LibrePortal v0.1.0 — initial release A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bacc21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Claude sandbox + working notes. +# .claude-work was getting mirrored back into SRC by update.sh, creating +# nested copies on every run. Excluding the directory name everywhere belts- +# and-suspenders the fix in update.sh (the missing trailing slash on rsync). +.claude-work/ + +# Living spec authored in the sandbox; persisted at SRC root but not tracked. +/APPS.md + +# Node dependencies — installed via `npm ci` at image build, never vendored. +node_modules/ +npm-debug.log* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..33ac7e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing to LibrePortal + +Thanks for wanting to help — LibrePortal is built in the open, and +contributions are genuinely welcome. + +## Ground rules + +- LibrePortal is **AGPLv3**. By contributing, your work is offered under that + same license (see the DCO below). +- **Match the surrounding code** — keep it simple and readable, and follow + the style of the file you're editing. +- Keep pull requests focused: one change per PR where you can. + +## Developer Certificate of Origin (DCO) + +We use the [DCO](https://developercertificate.org/) instead of a CLA — no +paperwork, just a sign-off certifying you have the right to submit your code. + +Add a `Signed-off-by` line to every commit by committing with `-s`: + +```bash +git commit -s -m "your message" +``` + +That appends: + +``` +Signed-off-by: Your Name +``` + +By signing off, you agree to the DCO: that you wrote the patch (or otherwise +have the right to submit it) and that it may be included under the project's +AGPLv3 license. + +## Bugs & ideas + +Open an issue — clear steps to reproduce and your environment details help a +lot. + +Thanks for helping keep self-hosting free and open. 🕊️ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROMISE.md b/PROMISE.md new file mode 100644 index 0000000..9079c5d --- /dev/null +++ b/PROMISE.md @@ -0,0 +1,46 @@ +# The LibrePortal Promise + +LibrePortal is free software, and it always will be. This is our commitment +to you — in plain language, so you can hold us to it. + +## What "free" means here + +You can **run, study, modify, share, and fully use 100% of LibrePortal — +every feature — for free, forever.** The entire platform is licensed under +the GNU AGPLv3 (see [LICENSE](LICENSE)). There are **no feature paywalls in +the software, no crippled "community edition," and no telemetry** phoning +home. + +If you self-host LibrePortal, you get everything. No asterisks. + +## How we keep the lights on + +Building and maintaining this takes real work, and we want to do it +sustainably — without betraying a word of the above. So we charge only for +things that **aren't the software**: + +- **LibrePortal Cloud** — optional hosted services we run for you: remote + access (no port-forwarding), off-site encrypted backups, a free subdomain + with automatic HTTPS, phone notifications, and more. +- **Managed hosting** — we run LibrePortal for you, if you'd rather not. +- **Support** — priority help for those who want it. + +## The line we will not cross + +**Every paid service has a free, self-hostable equivalent in the open code.** +You pay us for the convenience of not running it yourself — *never* to +*unlock* a capability. Want to run your own relay or point backups at your +own storage? The code to do that is right here, free. + +**Our litmus test:** before anything becomes paid, we ask — *could you do +this yourself with the open code?* If yes, we may also offer a hosted +version. If the only way to get it is to pay us, we don't ship it. + +## What we will never do + +- Paywall a feature of the software you run on your own machine. +- Add tracking or telemetry. +- **Rug-pull.** What is open stays open — we will never relicense released + code out from under the community. + +That's the deal. Thanks for trusting us with your corner of the internet. 🕊️ diff --git a/README.md b/README.md new file mode 100755 index 0000000..8ffc4ab --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# LibrePortal + +**Your own private corner of the internet — free, open, and yours.** + +LibrePortal is a self-hosted platform for running the apps you rely on, on +your own server: one-click installs, a reverse proxy with automatic SSL, +rootless Docker, optional VPN routing, and a clean web dashboard to manage +it all. + +> ⚠️ **v0.1.0 — early days.** Expect rough edges while things settle. + +## Free & open — forever + +The entire platform is **free software under the [GNU AGPLv3](LICENSE)**. +Self-host it and you get **everything** — every feature, no paywalls, no +telemetry. See [our Promise](PROMISE.md) for exactly what that means. + +## What you get + +- 📦 One-click self-hosted apps (Nextcloud, Vaultwarden, Jellyfin, Gitea, …) +- 🔀 Traefik reverse proxy + automatic Let's Encrypt SSL +- 🔒 Rootless Docker, CrowdSec, sane security defaults +- 🛡️ Optional VPN routing (gluetun) for any app +- 🖥️ A web dashboard to install, configure, back up, and monitor everything + +## Quick start + +```bash +git clone https://gitea.scottwebstar.co.uk/Webstar/LibrePortal.git +cd LibrePortal +./init.sh +``` + +## LibrePortal Cloud (optional) + +Self-hosting is free and complete. If you'd rather not run the fiddly parts +yourself, **LibrePortal Cloud** offers them as paid, hosted services — remote +access, off-site backups, notifications, and more. **Every one has a free, +self-hostable equivalent in this repo** — you pay for convenience, never to +unlock. [Our Promise](PROMISE.md) spells out exactly where that line sits. + +## Contributing + +PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). We use a lightweight +DCO sign-off (`git commit -s`), no CLA. + +## License + +[GNU AGPLv3](LICENSE). What's open stays open. diff --git a/configs/backup/.category b/configs/backup/.category new file mode 100644 index 0000000..8cf02aa --- /dev/null +++ b/configs/backup/.category @@ -0,0 +1,5 @@ +TITLE=Backup +DESCRIPTION=Backup schedules, retention, and engine settings +ICON=backup +ORDER=3 +SUBCATEGORY_ORDER=backup_general,backup_retention,backup_advanced diff --git a/configs/backup/backup_advanced b/configs/backup/backup_advanced new file mode 100644 index 0000000..ead67e8 --- /dev/null +++ b/configs/backup/backup_advanced @@ -0,0 +1,8 @@ +# ================================================================================ +# Backup Advanced - **ADVANCED** Engine-level knobs most users won't need to touch +# ================================================================================ +CFG_BACKUP_ENGINE=restic # Default Backup Engine - Fallback engine for new locations (each location can override) [restic:restic|borg:BorgBackup|kopia:Kopia] +CFG_BACKUP_STRATEGY=stop-snapshot-start # Backup Strategy - How containers are quiesced before snapshotting [stop-snapshot-start:Stop → snapshot → start (safe default)|pause-snapshot-unpause:Pause → snapshot → unpause (less downtime)|live:Live — snapshot while running (only with DB dump hooks)] +CFG_BACKUP_VERIFY_AFTER=true # Verify After Backup - Run integrity check after each backup +CFG_BACKUP_VERIFY_DATA_PERCENT=5 # Verify Data Sample % - Percentage of repo data to checksum-verify weekly +CFG_BACKUP_PARALLEL_REPOS=true # Parallel Repos - Push to all enabled locations in parallel diff --git a/configs/backup/backup_general b/configs/backup/backup_general new file mode 100755 index 0000000..285cffd --- /dev/null +++ b/configs/backup/backup_general @@ -0,0 +1,5 @@ +# ================================================================================ +# Backup General - Scheduling +# ================================================================================ +CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups +CFG_BACKUP_CRONTAB_APP_INTERVAL=3 # App Backup Interval - Minutes between app backup checks diff --git a/configs/backup/backup_retention b/configs/backup/backup_retention new file mode 100644 index 0000000..b596800 --- /dev/null +++ b/configs/backup/backup_retention @@ -0,0 +1,13 @@ +# ================================================================================ +# Backup Retention - Default retention policy applied at every forget pass. +# Per-location overrides supported via the Locations edit modal. +# +# Most users should pick a "Backup style" preset on the Schedule page rather +# than editing these directly. Blank values mean "do not enforce this tier". +# ================================================================================ +CFG_BACKUP_KEEP_LAST= # Keep Last N - Always keep this many most-recent snapshots +CFG_BACKUP_KEEP_DAILY=30 # Keep Daily - Keep one snapshot per day for this many days +CFG_BACKUP_KEEP_WEEKLY= # Keep Weekly - Keep one snapshot per week for this many weeks +CFG_BACKUP_KEEP_MONTHLY= # Keep Monthly - Keep one snapshot per month for this many months +CFG_BACKUP_KEEP_YEARLY= # Keep Yearly - Keep one snapshot per year for this many years +CFG_BACKUP_PRUNE_AFTER_FORGET=true # Prune After Forget - Reclaim repo space after forgetting snapshots diff --git a/configs/backup/locations/1/location.config b/configs/backup/locations/1/location.config new file mode 100644 index 0000000..c30ec1b --- /dev/null +++ b/configs/backup/locations/1/location.config @@ -0,0 +1,27 @@ +# Backup location 1 — default local repo seeded with LibrePortal. +# Edit via the Locations page on /backup, or directly here. +CFG_BACKUP_LOC_1_NAME="Local disk" # Location Name - Friendly label shown in the UI +CFG_BACKUP_LOC_1_ENABLED=true # Enabled - Snapshot to this location +CFG_BACKUP_LOC_1_ENGINE=restic # Engine - Backup engine used at this location [restic:restic|borg:BorgBackup|kopia:Kopia] +CFG_BACKUP_LOC_1_PASSWORD=RANDOMIZEDPASSWORD1 # Repository Password - Used to encrypt/decrypt snapshots — back up offline! +CFG_BACKUP_LOC_1_TYPE=local # Type - Backend [local:Local / mounted path|sftp:SFTP|rest:REST|s3:S3|b2:Backblaze B2|gs:Google Cloud Storage|azure:Azure|rclone:rclone] +CFG_BACKUP_LOC_1_PATH_MODE=auto # Path Mode - Where this location stores its data [auto:Automatic (/docker/backups/)|custom:Custom path] +CFG_BACKUP_LOC_1_PATH= # Custom Path - Filesystem path on this server (used when Path Mode = Custom) +CFG_BACKUP_LOC_1_URI= # URI Override - Custom restic URI (leave blank to build from the fields below) +CFG_BACKUP_LOC_1_SSH_USER= # SSH User - For sftp type +CFG_BACKUP_LOC_1_SSH_HOST= # SSH Host - For sftp type +CFG_BACKUP_LOC_1_SSH_PORT=22 # SSH Port - For sftp type +CFG_BACKUP_LOC_1_SSH_PATH= # SSH Remote Path - Path on the remote host where the repo lives +CFG_BACKUP_LOC_1_SSH_AUTH=key # SSH Authentication - [key:SSH key (~/.ssh/id_rsa)|password:Password (via sshpass)] +CFG_BACKUP_LOC_1_SSH_PASS= # SSH Password - Used only when SSH Authentication is set to Password +CFG_BACKUP_LOC_1_S3_ACCESS_KEY= # S3 Access Key - For s3 type +CFG_BACKUP_LOC_1_S3_SECRET_KEY= # S3 Secret Key - For s3 type +CFG_BACKUP_LOC_1_B2_ACCOUNT_ID= # B2 Account ID - For b2 type +CFG_BACKUP_LOC_1_B2_ACCOUNT_KEY= # B2 Account Key - For b2 type +CFG_BACKUP_LOC_1_APPEND_ONLY=false # Append-only - Refuse forget/prune for this location (ransomware-safe) +CFG_BACKUP_LOC_1_CUSTOM_RETENTION=false # Custom Retention - Override the global retention for this location +CFG_BACKUP_LOC_1_KEEP_LAST= # Keep Last - Snapshots to always retain (blank = global) +CFG_BACKUP_LOC_1_KEEP_DAILY= # Keep Daily - Days (blank = global) +CFG_BACKUP_LOC_1_KEEP_WEEKLY= # Keep Weekly - Weeks (blank = global) +CFG_BACKUP_LOC_1_KEEP_MONTHLY= # Keep Monthly - Months (blank = global) +CFG_BACKUP_LOC_1_KEEP_YEARLY= # Keep Yearly - Years (blank = global) diff --git a/configs/features/.category b/configs/features/.category new file mode 100755 index 0000000..e2c1a54 --- /dev/null +++ b/configs/features/.category @@ -0,0 +1,5 @@ +TITLE=Features +DESCRIPTION=Toggle system components and features +ICON=features +ORDER=5 +SUBCATEGORY_ORDER=features_core,features_security,features_terminal diff --git a/configs/features/features_core b/configs/features/features_core new file mode 100755 index 0000000..999f5f2 --- /dev/null +++ b/configs/features/features_core @@ -0,0 +1,18 @@ +# ================================================================================ +# Core Features - Essential LibrePortal functionality and core services +# ================================================================================ +CFG_REQUIREMENT_CONFIG=true # Configuration Management - Enable configuration management system for LibrePortal settings +CFG_REQUIREMENT_COMMAND=true # Command Line Tool - Install the libreportal command line tool for system management +CFG_REQUIREMENT_WEBUI=true # Web Interface - Install and manage the LibrePortal web based management interface +CFG_REQUIREMENT_WEBUI_SERVICE=true # Web Task Service - Install the task management systemd service for the web interface +CFG_REQUIREMENT_DATABASE=true # Database Support - Install and configure database support for application data storage +CFG_REQUIREMENT_PASSWORDS=true # Password Management - Enable password generation and management features +CFG_REQUIREMENT_DOCKER_CE=true # Docker CE - Install Docker Community Edition instead of the default Docker version +CFG_REQUIREMENT_DOCKER_COMPOSE=true # Docker Compose - Install Docker Compose for multi container application management +CFG_REQUIREMENT_DOCKER_NETWORK=true # Docker Network - Create and manage Docker network for container communication +CFG_REQUIREMENT_UFW=true # Firewall Protection - Install and configure the Uncomplicated Firewall for system security +CFG_REQUIREMENT_UFWD=true # Docker Firewall - Install UFW Docker for container aware firewall management which is rooted Docker specific +CFG_REQUIREMENT_SSLCERTS=true # SSL Certificates - Generate and manage SSL certificates for secure HTTPS connections +CFG_REQUIREMENT_CRONTAB=true # Scheduled Tasks - Setup scheduled tasks and automated maintenance jobs +CFG_REQUIREMENT_WHITELIST_PORT_UPDATER=true # Auto Port Management - Automatically update port whitelist when applications are installed or removed +CFG_REQUIREMENT_BCRYPT_SAVE=true # Password Encryption - Encrypt saved passwords using bcrypt for enhanced security diff --git a/configs/features/features_security b/configs/features/features_security new file mode 100755 index 0000000..ba079dd --- /dev/null +++ b/configs/features/features_security @@ -0,0 +1,7 @@ +# ================================================================================ +# Security and Authentication - SSH access and security configuration +# ================================================================================ +CFG_REQUIREMENT_SSHKEY_DOWNLOADER=false # SSH Key Downloader - Enable SSH key download functionality for remote access +CFG_REQUIREMENT_SSH_DISABLE_PASSWORDS=false # SSH Password Disable - Disable password authentication for SSH requiring key based access only +CFG_REQUIREMENT_GLUETUN_FOR_ALL=false # Gluetun For All Apps - Allow routing through Gluetun VPN for every app (default: only curated categories) + diff --git a/configs/features/features_terminal b/configs/features/features_terminal new file mode 100755 index 0000000..c508195 --- /dev/null +++ b/configs/features/features_terminal @@ -0,0 +1,11 @@ +# ================================================================================ +# Terminal Only - Advanced terminal based features and utilities **ADVANCED** +# ================================================================================ +CFG_REQUIREMENT_SUGGEST_INSTALLS=false # Install Suggestions - Enable application suggestions and recommendations during installation +CFG_REQUIREMENT_SUGGEST_METRICS=true # Metrics Suggestions - Offer Prometheus and Grafana during first install (requires Install Suggestions enabled) +CFG_REQUIREMENT_CONTINUE_PROMPT=false # Continue Prompts - Show continue prompts during installation for user confirmation +CFG_REQUIREMENT_CONFIGS_CHECK=true # Config Validation - Validate configuration files on startup for errors and consistency +CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE=true # Auto Config Updates - Automatically update configuration files when system changes are detected +CFG_REQUIREMENT_MISSING_IPS=false # IP Configuration Check - Check for and alert about missing IP configurations +CFG_REQUIREMENT_DOCKER_NETWORK_PRUNE=true # Docker Network Cleanup - Enable automatic cleanup of unused Docker networks +CFG_REQUIREMENT_DOCKER_SWITCHER=true # Docker Switcher - Install Docker version switching utility for managing multiple Docker versions diff --git a/configs/general/.category b/configs/general/.category new file mode 100755 index 0000000..81beef1 --- /dev/null +++ b/configs/general/.category @@ -0,0 +1,5 @@ +TITLE=General +DESCRIPTION=Basic system settings and identification +ICON=general +ORDER=1 +SUBCATEGORY_ORDER=general_basic,general_mail,general_install,general_docker_install,general_terminal,general_libreportal diff --git a/configs/general/general_basic b/configs/general/general_basic new file mode 100755 index 0000000..caf8a3d --- /dev/null +++ b/configs/general/general_basic @@ -0,0 +1,5 @@ +# ================================================================================ +# General - Basic system settings and identification +# ================================================================================ +CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance +CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps diff --git a/configs/general/general_docker_install b/configs/general/general_docker_install new file mode 100755 index 0000000..0c0b615 --- /dev/null +++ b/configs/general/general_docker_install @@ -0,0 +1,7 @@ +# ================================================================================ +# Docker - Container runtime installation and configuration **ADVANCED** +# ================================================================================ +CFG_DOCKER_INSTALL_TYPE=rooted # Docker Installation Type - Security based setup rooted or rootless Docker installation [rooted|rootless] +CFG_DOCKER_INSTALL_USER=dockerinstall # Docker Install User - Username for Docker installation operations +CFG_DOCKER_INSTALL_PASS=RANDOMIZEDPASSWORD2 # Docker Install Password - Password for Docker install user + diff --git a/configs/general/general_install b/configs/general/general_install new file mode 100755 index 0000000..a587914 --- /dev/null +++ b/configs/general/general_install @@ -0,0 +1,9 @@ +# ================================================================================ +# Installation Setup - Local or Git Repository configuration and version control +# ================================================================================ +CFG_INSTALL_MODE=local # Installation Mode - Method used for installation of LibrePortal +CFG_GIT_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration +CFG_GIT_USER=changeme # Git Username - Git username for repository authentication +CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access +CFG_GIT_UPDATES=true # Auto Check Updates - Check for Git repository updates automatically +CFG_GIT_AUTO_UPDATES=true # Auto Apply Updates - Automatically apply Git updates when available diff --git a/configs/general/general_libreportal b/configs/general/general_libreportal new file mode 100755 index 0000000..523a463 --- /dev/null +++ b/configs/general/general_libreportal @@ -0,0 +1,4 @@ +# ================================================================================ +# LibrePortal - Specific LibrePortal configurations **ADVANCED** +# ================================================================================ +CFG_LIBREPORTAL_USER_PASS=changeme # LibrePortal User Password - Password for the LibrePortal system user account diff --git a/configs/general/general_mail b/configs/general/general_mail new file mode 100755 index 0000000..78f344d --- /dev/null +++ b/configs/general/general_mail @@ -0,0 +1,10 @@ +# ================================================================================ +# Mail - Mail Server Settings +# ================================================================================ +CFG_MAIL_ENABLED=false # Mail Enabled - Enable mail server configuration for applications +CFG_MAIL_HOST=mail.domain.com # Mail Server Host - Your mail server hostname +CFG_MAIL_PORT=587 # Mail Server Port - Usually 587 for TLS, 465 for SSL, 25 for none +CFG_MAIL_SECURE=tls # Security - tls, ssl, or none +CFG_MAIL_USERNAME=your-email@domain.com # Mail Username - Your email address for authentication +CFG_MAIL_PASSWORD=your-app-password # Mail Password - Use app password for authentication +CFG_MAIL_FROM=noreply@domain.com # From Email Address - Sender email address diff --git a/configs/general/general_terminal b/configs/general/general_terminal new file mode 100755 index 0000000..6ed31a1 --- /dev/null +++ b/configs/general/general_terminal @@ -0,0 +1,9 @@ +# ================================================================================ +# Terminal - System utilities and advanced settings **ADVANCED** +# ================================================================================ +CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks +CFG_SWAPFILE_SIZE=2G # Swap File Size - Size of swap file for memory management +CFG_GENERATED_PASS_LENGTH=14 # Password Length - Length for auto generated passwords +CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames +CFG_UFW_LOGGING=off # Firewall Logging - UFW firewall logging level [off|low|medium|high|full] +CFG_TEXT_EDITOR=nano # Text Editor - Default text editor for system operations [nano|vim] diff --git a/configs/network/.category b/configs/network/.category new file mode 100755 index 0000000..29ed2fa --- /dev/null +++ b/configs/network/.category @@ -0,0 +1,5 @@ +TITLE=Network +DESCRIPTION=Network configuration and domain management +ICON=network +ORDER=4 +SUBCATEGORY_ORDER=network_domains,network_whitelist,network_dns,network_docker,network_ports,network_headscale diff --git a/configs/network/network_dns b/configs/network/network_dns new file mode 100755 index 0000000..93ad288 --- /dev/null +++ b/configs/network/network_dns @@ -0,0 +1,8 @@ +# ================================================================================ +# DNS - Dynamic Name Server Addresses +# ================================================================================ + +CFG_DNS_SERVER_1=9.9.9.9 # Primary DNS - Primary DNS server for network resolution +CFG_DNS_SERVER_2=9.9.9.11 # Secondary DNS - Secondary DNS server for network resolution + +CFG_REQUIREMENT_DNS_UPDATER=false # DNS Updater - Use AdGuard or Pi-hole as this server's DNS resolver when installed (rewrites /etc/resolv.conf). Off by default. diff --git a/configs/network/network_docker b/configs/network/network_docker new file mode 100755 index 0000000..9eaf31f --- /dev/null +++ b/configs/network/network_docker @@ -0,0 +1,7 @@ +# ================================================================================ +# Docker Network - Network settings for the Docker Network **ADVANCED** +# ================================================================================ + +CFG_NETWORK_NAME=vpn # Network Name - Docker network name for container communication +CFG_NETWORK_SUBNET=10.100.0.0/16 # Network Subnet - Subnet range for Docker network +CFG_NETWORK_MTU=1500 # Network MTU - Maximum transmission unit for network packets diff --git a/configs/network/network_domains b/configs/network/network_domains new file mode 100755 index 0000000..9a87300 --- /dev/null +++ b/configs/network/network_domains @@ -0,0 +1,12 @@ +# ================================================================================ +# Domains - Domain configuration for Traefik web services +# ================================================================================ +CFG_DOMAIN_1= # Domain 1 - Domain slot 1 for a Traefik +CFG_DOMAIN_2= # Domain 2 - Domain slot 2 for a Traefik +CFG_DOMAIN_3= # Domain 3 - Domain slot 3 for a Traefik +CFG_DOMAIN_4= # Domain 4 - Domain slot 4 for a Traefik +CFG_DOMAIN_5= # Domain 5 - Domain slot 5 for a Traefik +CFG_DOMAIN_6= # Domain 6 - Domain slot 6 for a Traefik +CFG_DOMAIN_7= # Domain 7 - Domain slot 7 for a Traefik +CFG_DOMAIN_8= # Domain 8 - Domain slot 8 for a Traefik +CFG_DOMAIN_9= # Domain 9 - Domain slot 9 for a Traefik diff --git a/configs/network/network_headscale b/configs/network/network_headscale new file mode 100755 index 0000000..27659de --- /dev/null +++ b/configs/network/network_headscale @@ -0,0 +1,5 @@ +# ================================================================================ +# Headscale - VPN service configuration **ADVANCED** +# ================================================================================ +CFG_HEADSCALE_HOST= # Headscale Host - Headscale server hostname for VPN services +CFG_HEADSCALE_KEY= # Headscale Key - Authentication key for Headscale server diff --git a/configs/network/network_ports b/configs/network/network_ports new file mode 100755 index 0000000..34b2e30 --- /dev/null +++ b/configs/network/network_ports @@ -0,0 +1,5 @@ +# ================================================================================ +# Ports - Settings for the Network Ports **ADVANCED** +# ================================================================================ + +CFG_PORT_RANGE=3000-9999 # Port allocation range - Range for port allocation Start to End diff --git a/configs/network/network_whitelist b/configs/network/network_whitelist new file mode 100755 index 0000000..2a32a83 --- /dev/null +++ b/configs/network/network_whitelist @@ -0,0 +1,4 @@ +# ================================================================================ +# Whitelist - Allow specific IPs for Specified Treafik Apps +# ================================================================================ +CFG_IPS_WHITELIST=HOSTIPHERE # IP Whitelist - Comma separated list of allowed IP addresses diff --git a/configs/security/.category b/configs/security/.category new file mode 100644 index 0000000..dab1646 --- /dev/null +++ b/configs/security/.category @@ -0,0 +1,5 @@ +TITLE=Security +DESCRIPTION=Intrusion prevention, bouncers, and host firewall configuration +ICON=security +ORDER=5 +SUBCATEGORY_ORDER=security_logins diff --git a/configs/security/security_logins b/configs/security/security_logins new file mode 100755 index 0000000..728d63a --- /dev/null +++ b/configs/security/security_logins @@ -0,0 +1,5 @@ +# ================================================================================ +# Logins - User accounts and authentication credentials +# ================================================================================ +CFG_TRAEFIK_USER=RANDOMIZEDUSERNAME1 # Traefik Username - Username for Traefik Admin Panel login and protected apps +CFG_TRAEFIK_PASS=RANDOMIZEDPASSWORD2 # Traefik Password - Password for Traefik Admin Panel login and protected apps diff --git a/configs/webui/.category b/configs/webui/.category new file mode 100755 index 0000000..30bf00c --- /dev/null +++ b/configs/webui/.category @@ -0,0 +1,5 @@ +TITLE=WebUI +DESCRIPTION=Web interface settings and preferences +ICON=webui +ORDER=2 +SUBCATEGORY_ORDER=webui_logins,webui_logs diff --git a/configs/webui/webui_logins b/configs/webui/webui_logins new file mode 100755 index 0000000..87db237 --- /dev/null +++ b/configs/webui/webui_logins @@ -0,0 +1,5 @@ +# ================================================================================ +# WebUI Logins - Web interface authentication settings +# ================================================================================ +CFG_WEBUI_USERNAME=RANDOMIZEDUSERNAME1 # WebUI Username - Username for web interface login +CFG_WEBUI_PASSWORD=RANDOMIZEDPASSWORD1 # WebUI Password - Password for web interface login diff --git a/configs/webui/webui_logs b/configs/webui/webui_logs new file mode 100644 index 0000000..fa03fcf --- /dev/null +++ b/configs/webui/webui_logs @@ -0,0 +1,6 @@ +# ================================================================================ +# WebUI Logs - Log-streaming behaviour for the Services tab **ADVANCED** +# ================================================================================ +CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES=10 # Idle Timeout - Disconnect a log stream after this much silence. The viewer overlays a Resume button so the user can re-open the stream. 0 disables. +CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES=60 # Max Duration - Hard cap on a single stream. Resume button appears at the cap. 0 disables (not recommended). +CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC=200 # Max Lines per Second - Burst ceiling. Excess lines drop with a per-second notice. diff --git a/containers/adguard/adguard.config b/containers/adguard/adguard.config new file mode 100755 index 0000000..18ab8a8 --- /dev/null +++ b/containers/adguard/adguard.config @@ -0,0 +1,79 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# USER = admin username for the AdGuardHome web UI +# PASSWORD = plain text admin password for the AdGuardHome web UI +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_ADGUARD_APP_NAME=adguard +CFG_ADGUARD_BACKUP=true +CFG_ADGUARD_COMPOSE_FILE=default +CFG_ADGUARD_HEALTHCHECK=true +CFG_ADGUARD_AUTHELIA=false +CFG_ADGUARD_HEADSCALE=false +CFG_ADGUARD_USER=admin +CFG_ADGUARD_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_ADGUARD_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_ADGUARD_CATEGORY="networking" +CFG_ADGUARD_TITLE="AdGuard" +CFG_ADGUARD_DESCRIPTION="DNS based Ad Blocking" +CFG_ADGUARD_LONG_DESCRIPTION="AdGuard Home is a network-wide software for advertisements and tracking blocking that operates as a DNS server and returns the IP address of a local, blackhole DNS server for domains that should be blocked" +CFG_ADGUARD_URL="https://github.com/AdguardTeam/AdGuardHome" +CFG_ADGUARD_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all +# +CFG_ADGUARD_DOMAIN=1 +CFG_ADGUARD_WHITELIST=false +CFG_ADGUARD_HOST_NAME=adguard +CFG_ADGUARD_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_ADGUARD_PORT_1="adguard-service|webui|random:3000|public|tcp|true|true|true|Admin Interface|" +CFG_ADGUARD_PORT_2="adguard-service|dns-tcp|random:53|public|tcp|false|false|false|DNS Server (TCP)|" +CFG_ADGUARD_PORT_3="adguard-service|dns-udp|random:53|public|udp|false|false|false|DNS Server (UDP)|" +CFG_ADGUARD_PORT_4="adguard-service|dns-alt|random:8053|disabled|tcp|false|false|false|Alternative DNS|" +CFG_ADGUARD_PORT_5="adguard-service|dot|random:853|disabled|tcp|false|false|false|DNS-over-TLS|" +CFG_ADGUARD_PORT_6="adguard-exporter|metrics|9617:9617|disabled|tcp|false|false|false|Metrics Exporter (sidecar, docker-network only)|" + + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_ADGUARD_AUTH_PROFILE=single_password +CFG_ADGUARD_ADMIN_USER= +CFG_ADGUARD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD2 diff --git a/containers/adguard/adguard.sh b/containers/adguard/adguard.sh new file mode 100644 index 0000000..9f5f1b8 --- /dev/null +++ b/containers/adguard/adguard.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +# Category : Networking +# Description : AdGuard - DNS based Ad Blocking (c/u/s/r/i): + +installAdguard() +{ + local config_variables="$1" + + if [[ "$adguard" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent adguard; + local app_name=$CFG_ADGUARD_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$adguard" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$adguard" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$adguard" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$adguard" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$adguard" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Completing AdGuardHome initial setup automatically" + echo "" + + # The legacy `$usedport1` variable is no longer populated by the + # current install pipeline; the resolved host port is stored in the + # PORTS_TAG_1 docker-compose tag (format `external:internal`). Pull + # it from there so the curl + URL printout actually point somewhere. + local adguard_compose_file="$containers_dir$app_name/docker-compose.yml" + local adguard_port_pair + adguard_port_pair=$(tagsManagerGetTagContent "$adguard_compose_file" "PORTS_TAG_1") + local adguard_admin_port="${adguard_port_pair%%:*}" + + if [[ -n "$public_ip_v4" && -n "$adguard_admin_port" ]]; then + echo " External : http://$public_ip_v4:$adguard_admin_port/" + fi + if [[ -n "$host_setup" ]]; then + echo " Hostname : http://$host_setup/" + fi + echo "" + + # AdGuardHome ships a setup wizard that normally needs five clicks in a + # browser before the daemon writes its config file. Same wizard is + # exposed as an HTTP API (POST /control/install/configure), so we + # drive it from here and skip the manual interaction. We pre-poll the + # admin endpoint until the container is up, then send the form, then + # let the existing post-install sed edits run against the freshly + # written AdGuardHome.yaml. + local adguard_setup_url="http://127.0.0.1:${adguard_admin_port}" + local adguard_attempts=0 + local adguard_max_attempts=60 + while ((adguard_attempts < adguard_max_attempts)); do + if curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/status" 2>/dev/null \ + || curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/install/get_addresses" 2>/dev/null; then + break + fi + sleep 2 + ((adguard_attempts++)) + done + + if ((adguard_attempts >= adguard_max_attempts)); then + isError "AdGuardHome admin endpoint did not respond on $adguard_setup_url within $((adguard_max_attempts * 2))s — open the URL and complete setup manually, then re-run the installer to apply the post-setup tweaks." + else + local adguard_user="${CFG_ADGUARD_USER:-admin}" + local adguard_pass="${CFG_ADGUARD_PASSWORD:-}" + if [[ -z "$adguard_pass" ]]; then + adguard_pass=$(generateRandomPassword) + updateConfigOption "CFG_ADGUARD_PASSWORD" "$adguard_pass" >/dev/null 2>&1 || true + isNotice "Generated a random AdGuardHome admin password and saved it to CFG_ADGUARD_PASSWORD." + fi + + # Internal container ports are fixed (3000 admin, 53 DNS); host + # mapping is what `usedport1` etc. handle. + local adguard_payload + adguard_payload=$(cat </dev/null 2>&1; then + isSuccessful "AdGuardHome admin setup completed automatically (user: $adguard_user)." + else + # 422/403 here typically means setup was already done on a + # previous install; the post-setup tweaks below are still + # safe to run against the existing yaml. + isNotice "AdGuardHome /control/install/configure rejected the request — assuming it's already configured. If this is a fresh install, complete setup manually at $adguard_setup_url." + fi + # Small breather so AdGuardHome finishes flushing AdGuardHome.yaml + # to disk before the sed edits below touch it. + #sleep 3 + fi + + #result=$(sudo sed -i "s/address: 0.0.0.0:80/address: 0.0.0.0:${usedport2}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 80 to $usedport2 for Admin Panel" + + #result=$(sudo sed -i "s/port: 53/port: ${usedport3}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 53 to $usedport3 for DNS Port" + + #result=$(sudo sed -i "s/port_https: 443/port_https: ${usedport4}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 443 to $usedport4 for DNS Port" + + #result=$(sudo sed -i "s/port_dns_over_tls: 853/port_dns_over_tls: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 853 to $usedport5 for port_dns_over_tls" + + #result=$(sudo sed -i "s/port_dns_over_quic: 853/port_dns_over_quic: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 853 to $usedport5 for port_dns_over_quic" + + # NOTE: We deliberately do *not* force `tls.enabled: true` here. + # That section configures encrypted DNS (DoT/DoH/DoQ) and AdGuardHome + # crash-loops on startup with `[fatal] creating dns server: parsing + # tls key pair: tls: failed to find any PEM data in certificate input` + # if `enabled: true` is set without a real certificate pair pointed + # at by `certificate_path` / `private_key_path`. The admin user can + # opt into encrypted DNS from Settings → Encryption once they've + # provided a cert. + + if [[ $public == "true" ]]; then + result=$(sudo sed -i "s|allow_unencrypted_doh: false|allow_unencrypted_doh: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + checkSuccess "Setting allow_unencrypted_doh to false for Traefik" + fi + + result=$(sudo sed -i "s|anonymize_client_ip: false: false|anonymize_client_ip: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + checkSuccess "Setting anonymize_client_ip to true for privacy reasons" + + # Force the admin web bind back to 0.0.0.0:3000 inside the container. + # The docker-compose mapping is `:3000`, so the container + # MUST listen on 3000 internally for the host port to reach it. After + # the install API call AdGuardHome sometimes ends up bound to 0.0.0.0:80 + # (its build-time default) instead of the port we sent — which is + # exactly what causes "unable to connect" on the host port. + local adguard_yaml="$containers_dir$app_name/conf/AdGuardHome.yaml" + if [[ -f "$adguard_yaml" ]]; then + # New schema (v0.107+): single `address: 0.0.0.0:NN` line under `http:`. + sudo sed -i 's|^\(\s*address:\s*\)0\.0\.0\.0:[0-9]\+|\10.0.0.0:3000|' "$adguard_yaml" + # Old schema fallback: separate `bind_host:` / `bind_port:` keys. + sudo sed -i 's|^\(\s*bind_host:\s*\).*|\10.0.0.0|' "$adguard_yaml" + sudo sed -i 's|^\(\s*bind_port:\s*\)[0-9]\+|\13000|' "$adguard_yaml" + checkSuccess "Pinned AdGuardHome admin bind to 0.0.0.0:3000 (matches the compose port mapping)." + fi + + dockerComposeRestart "$app_name"; + + # Health-check after the restart so the user finds out *here* if + # AdGuardHome didn't come back up cleanly, rather than later when + # they try to open the URL and just see "unable to connect". + # + # Drop `-f` and accept any HTTP status code: now that the admin + # account is configured, `/control/status` returns 401 to an + # unauthenticated request — which is fine, it means the server is + # up and answering. We only care whether the connection succeeded + # at all, not what the response body says. `-w '%{http_code}'` + # gives us a 3-digit code on success and an empty string on a + # connection failure / timeout. + local adguard_health_attempts=0 + while ((adguard_health_attempts < 20)); do + local adguard_health_code + adguard_health_code=$(curl -sS -o /dev/null --max-time 2 \ + -w '%{http_code}' "${adguard_setup_url}/control/status" 2>/dev/null) + if [[ "$adguard_health_code" =~ ^[1-5][0-9][0-9]$ ]]; then + isSuccessful "AdGuardHome admin UI is reachable on $adguard_setup_url (HTTP $adguard_health_code)" + break + fi + sleep 1 + ((adguard_health_attempts++)) + done + if ((adguard_health_attempts >= 20)); then + isError "AdGuardHome admin UI did not respond after restart on $adguard_setup_url. Check the container logs (\`docker logs adguard-service\`) and the conf/AdGuardHome.yaml bind address." + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating the WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + # Same final-summary call shape as wireguard / vaultwarden. Pass the + # admin user/password we just configured so the user sees the + # credentials exactly once, at the end of the install. + menuShowFinalMessages "$app_name" "${CFG_ADGUARD_USER:-admin}" "$CFG_ADGUARD_PASSWORD"; + + menu_number=0 + #sleep 3s + cd + fi + adguard=n +} diff --git a/containers/adguard/adguard.svg b/containers/adguard/adguard.svg new file mode 100755 index 0000000..f6118fc --- /dev/null +++ b/containers/adguard/adguard.svg @@ -0,0 +1 @@ + diff --git a/containers/adguard/docker-compose.yml b/containers/adguard/docker-compose.yml new file mode 100644 index 0000000..10c9bc5 --- /dev/null +++ b/containers/adguard/docker-compose.yml @@ -0,0 +1,61 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + adguard-service: #LIBREPORTAL|SERVICE_TAG_1|adguard-service + container_name: adguard-service + image: adguard/adguardhome + restart: unless-stopped + hostname: adguard + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + - "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3 + - "PORTS_DATA_4" #LIBREPORTAL|PORTS_TAG_4|PORTS_DATA_4 + - "PORTS_DATA_5" #LIBREPORTAL|PORTS_TAG_5|PORTS_DATA_5 + # GLUETUN_OFF_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.adguard-service-webui.entrypoints: web,websecure + traefik.http.routers.adguard-service-webui.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.adguard-service-webui.service: adguard-service-webui + traefik.http.routers.adguard-service-webui.tls: true + traefik.http.routers.adguard-service-webui.tls.certresolver: production + traefik.http.services.adguard-service-webui.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.adguard-service-webui.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1 + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - "./work:/opt/adguardhome/work" + - "./conf:/opt/adguardhome/conf" + - "./tailscale:/usr/local/bin/" + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + # >>> libreportal-monitoring >>> + #adguard-exporter: + # container_name: adguard-exporter + # image: ebrianne/adguard-exporter:latest + # restart: unless-stopped + # environment: + # - ADGUARD_PROTOCOL=http + # - ADGUARD_HOSTNAME=adguard-service:PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + # - ADGUARD_USERNAME=ADGUARD_USER_DATA #LIBREPORTAL|ADGUARD_USER_TAG|ADGUARD_USER_DATA + # - ADGUARD_PASSWORD=ADGUARD_PASSWORD_DATA #LIBREPORTAL|ADGUARD_PASSWORD_TAG|ADGUARD_PASSWORD_DATA + # - INTERVAL=30s + # - LOG_LIMIT=10000 + # - SERVER_PORT=PORT_INTERNAL_DATA_6 #LIBREPORTAL|PORT_INTERNAL_TAG_6|PORT_INTERNAL_DATA_6 + # networks: + # DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # <<< libreportal-monitoring <<< diff --git a/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json b/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json new file mode 100644 index 0000000..e604fa2 --- /dev/null +++ b/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json @@ -0,0 +1,74 @@ +{ + "annotations": { "list": [{ "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_dns_queries", "refId": "A" }], + "title": "DNS Queries", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_blocked_filtering", "refId": "A" }], + "title": "Blocked (Filtering)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_replaced_safebrowsing", "refId": "A" }], + "title": "Replaced (Safe Browsing)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "s" } }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_avg_processing_time", "refId": "A" }], + "title": "Avg Processing Time", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false } } }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 5, + "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "rate(adguard_num_dns_queries[5m])", "legendFormat": "queries/s", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "rate(adguard_num_blocked_filtering[5m])", "legendFormat": "blocked/s", "refId": "B" } + ], + "title": "DNS Activity", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "adguard"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "AdGuard Home", + "uid": "libreportal-adguard", + "version": 1, + "weekStart": "" +} diff --git a/containers/adguard/resources/monitoring/prometheus-scrape.yml b/containers/adguard/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..830a13e --- /dev/null +++ b/containers/adguard/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: adguard + metrics_path: /metrics + static_configs: + - targets: ['adguard-exporter:PORT_INTERNAL_DATA_6'] #LIBREPORTAL|PORT_INTERNAL_TAG_6|PORT_INTERNAL_DATA_6 diff --git a/containers/authelia/authelia.config b/containers/authelia/authelia.config new file mode 100755 index 0000000..bd19af9 --- /dev/null +++ b/containers/authelia/authelia.config @@ -0,0 +1,74 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# THEME = UI theme for Authelia interface +# ADMIN_USERNAME = username for the seeded Authelia admin account +# ADMIN_PASSWORD = password for the seeded Authelia admin account +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_AUTHELIA_APP_NAME=authelia +CFG_AUTHELIA_REQUIRES="domain,traefik" +CFG_AUTHELIA_BACKUP=true +CFG_AUTHELIA_COMPOSE_FILE=default +CFG_AUTHELIA_HEALTHCHECK=true +CFG_AUTHELIA_AUTHELIA=false +CFG_AUTHELIA_HEADSCALE=false +CFG_AUTHELIA_THEME=dark +CFG_AUTHELIA_ADMIN_USERNAME=admin +CFG_AUTHELIA_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_AUTHELIA_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# REQUIRES_SERVICE = name of another LibrePortal app that must be installed before this one can be configured +# +CFG_AUTHELIA_CATEGORY="security" +CFG_AUTHELIA_TITLE="Authelia" +CFG_AUTHELIA_DESCRIPTION="Authentication & SSO" +CFG_AUTHELIA_LONG_DESCRIPTION="Authelia is an open-source authentication and authorization server providing 2-factor authentication and single sign-on for your applications" +CFG_AUTHELIA_URL="https://github.com/authelia/authelia" +CFG_AUTHELIA_ACTIONS="configure|install|restart|shutdown|uninstall" +CFG_AUTHELIA_REQUIRES_SERVICE=traefik +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_AUTHELIA_DOMAIN=1 +CFG_AUTHELIA_WHITELIST=false +CFG_AUTHELIA_HOST_NAME=authelia +CFG_AUTHELIA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_AUTHELIA_PORT_1="authelia-service|webui|random:9091|public|tcp|false|true|true|Web Interface|" diff --git a/containers/authelia/authelia.sh b/containers/authelia/authelia.sh new file mode 100755 index 0000000..5d11e99 --- /dev/null +++ b/containers/authelia/authelia.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# Category : Security +# Description : Authelia - Authentication & SSO (c/u/s/r/i): + +installAuthelia() +{ + local config_variables="$1" + + if [[ "$authelia" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent authelia; + local app_name=$CFG_AUTHELIA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$authelia" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$authelia" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$authelia" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$authelia" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$authelia" == *[iI]* ]]; then + isHeader "Install $app_name" + + # Pre-flight: bail out before touching any compose/config if the + # global prerequisites aren't met. CFG_AUTHELIA_REQUIRES lists + # what's needed (currently "domain,traefik"); the helper prints a + # clear list of what's missing so the user knows what to fix. + if ! appInstallCheckRequirements "$app_name" "$CFG_AUTHELIA_REQUIRES"; then + authelia=n + return 1 + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + local result=$(copyResource "$app_name" "configuration.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying configuration.yml to $containers_dir$app_name/config" + + local result=$(copyResource "$app_name" "users_database.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying users_database.yml to $containers_dir$app_name/config" + + local authelia_config_file="$containers_dir$app_name/config/configuration.yml" + sudo sed -i "s|AUTHELIA_THEME_PLACEHOLDER|$CFG_AUTHELIA_THEME|g" "$authelia_config_file" + sudo sed -i "s|AUTHELIA_DOMAIN_PLACEHOLDER|$domain_full|g" "$authelia_config_file" + sudo sed -i "s|AUTHELIA_HOST_PLACEHOLDER|$host_setup|g" "$authelia_config_file" + checkSuccess "Substituting Authelia configuration values (theme=$CFG_AUTHELIA_THEME domain=$domain_full host=$host_setup)" + + local authelia_secrets_dir="$containers_dir$app_name/secrets" + sudo mkdir -p "$authelia_secrets_dir" + for secret_name in JWT_SECRET SESSION_SECRET STORAGE_ENCRYPTION_KEY; do + local secret_file="$authelia_secrets_dir/$secret_name" + if [[ ! -s "$secret_file" ]]; then + openssl rand -hex 64 | sudo tee "$secret_file" >/dev/null + sudo chmod 600 "$secret_file" + fi + done + sudo chown -R "$docker_install_user":"$docker_install_user" "$authelia_secrets_dir" + checkSuccess "Generated Authelia secrets at $authelia_secrets_dir" + + # Enable Authelia's telemetry/metrics endpoint only when + # CFG_AUTHELIA_MONITORING=true (toggles the libreportal-monitoring + # marker block in configuration.yml). + monitoringToggleAppConfig "$app_name" "config/configuration.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Configuring Authelia admin account" + echo "" + + local authelia_admin_user="${CFG_AUTHELIA_ADMIN_USERNAME:-admin}" + local authelia_admin_pass="${CFG_AUTHELIA_ADMIN_PASSWORD:-authelia}" + local authelia_users_file="$containers_dir$app_name/config/users_database.yml" + local authelia_attempts=0 + while ((authelia_attempts < 30)); do + if sudo docker exec authelia-service authelia --version >/dev/null 2>&1; then + break + fi + sleep 2 + ((authelia_attempts++)) + done + + if ((authelia_attempts >= 30)); then + isNotice "Authelia container did not become responsive in time — admin left at default (admin / authelia)." + else + local authelia_hash + authelia_hash=$(sudo docker exec authelia-service authelia crypto hash generate argon2 --password "$authelia_admin_pass" 2>/dev/null \ + | grep -oE '\$argon2[^[:space:]]+') + if [[ -z "$authelia_hash" ]]; then + isNotice "Could not generate Authelia password hash — admin left at default (admin / authelia)." + else + sudo tee "$authelia_users_file" >/dev/null < diff --git a/containers/authelia/docker-compose.yml b/containers/authelia/docker-compose.yml new file mode 100755 index 0000000..65a0a8d --- /dev/null +++ b/containers/authelia/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + authelia-service: #LIBREPORTAL|SERVICE_TAG_1|authelia-service + container_name: authelia-service + image: docker.io/authelia/authelia:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/JWT_SECRET + - AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET + - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./config:/config + - ./secrets:/secrets + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.authelia-service.entrypoints: web,websecure + traefik.http.routers.authelia-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.authelia-service.tls: true + traefik.http.routers.authelia-service.tls.certresolver: production + traefik.http.services.authelia-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.authelia-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/authelia/resources/configuration.yml b/containers/authelia/resources/configuration.yml new file mode 100644 index 0000000..3510f38 --- /dev/null +++ b/containers/authelia/resources/configuration.yml @@ -0,0 +1,76 @@ +--- +theme: AUTHELIA_THEME_PLACEHOLDER + +default_2fa_method: "" + +server: + address: 'tcp://0.0.0.0:9091/' + buffers: + read: 4096 + write: 4096 + +log: + level: info + +# >>> libreportal-monitoring >>> +#telemetry: +# metrics: +# enabled: true +# address: 'tcp://0.0.0.0:9959/' +# <<< libreportal-monitoring <<< + +totp: + disable: false + issuer: AUTHELIA_DOMAIN_PLACEHOLDER + algorithm: sha1 + digits: 6 + period: 30 + skew: 1 + +authentication_backend: + password_reset: + disable: false + refresh_interval: 5m + file: + path: /config/users_database.yml + watch: false + search: + email: false + case_insensitive: false + password: + algorithm: argon2 + argon2: + variant: argon2id + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +access_control: + default_policy: one_factor + +session: + name: authelia_session + expiration: 1h + inactivity: 5m + remember_me: 1M + cookies: + - name: authelia_session + domain: AUTHELIA_DOMAIN_PLACEHOLDER + authelia_url: https://AUTHELIA_HOST_PLACEHOLDER + default_redirection_url: https://AUTHELIA_DOMAIN_PLACEHOLDER + +regulation: + max_retries: 3 + find_time: 2m + ban_time: 5m + +storage: + local: + path: /config/db.sqlite3 + +notifier: + disable_startup_check: true + filesystem: + filename: /config/notification.txt diff --git a/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json b/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json new file mode 100644 index 0000000..f51463a --- /dev/null +++ b/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json @@ -0,0 +1,1211 @@ +{ + "__elements": {}, + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 8, + "panels": [], + "title": "Requests", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 5, + "x": 0, + "y": 1 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "count(count by (instance) (authelia_request))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authelia Instances", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 5, + "y": 1 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(method, code) (rate(authelia_request{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "false" + }, + "properties": [ + { + "id": "displayName", + "value": "failure" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "true" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 9, + "x": 15, + "y": 1 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "topk(15, authelia_request_duration_sum{instance=~\"$instance\"}) / authelia_request_duration_count{instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Top slow requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 4 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(method, code) (rate(authelia_request{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Http Code", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 4, + "panels": [], + "title": "Authentication", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "failure" + }, + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"true\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"true\", success=\"false\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + }, + { + "id": "displayName", + "value": "banned" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(success, banned) (authelia_authn{instance=~\"$instance\"})", + "format": "time_series", + "fullMetaSearch": true, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "First Factor Authentication", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 11 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(type) (authelia_authn_second_factor{instance=~\"$instance\", success=\"true\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Second Factor Method", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 11 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(code) (rate(authelia_authz{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authz requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "failed" + }, + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"true\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"true\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "banned" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 11, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(success, banned) (rate(authelia_authn{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authn requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 12, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(success, type) (rate(authelia_authn_second_factor{instance=~\"$instance\", banned=\"false\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "[{{type}}] success: {{success}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(banned, type) (rate(authelia_authn_second_factor{instance=~\"$instance\", banned=\"true\"}[$__rate_interval]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "[{{type}}] banned", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Authn second factor requests", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 5, + "panels": [], + "title": "OpenID Connect", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(endpoint, code) (rate(authelia_request_duration_openid_connect_count{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}] on {{endpoint}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "OIDC Requests", + "type": "timeseries" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "label": "datasource", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(authelia_request,instance)", + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": false, + "name": "instance", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(authelia_request,instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Authelia Community Dashboard", + "uid": "ddixu7wrrpuyod", + "version": 25, + "weekStart": "" +} diff --git a/containers/authelia/resources/monitoring/prometheus-scrape.yml b/containers/authelia/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..7d4f549 --- /dev/null +++ b/containers/authelia/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,9 @@ +# Prometheus scrape job for Authelia. Gathered into +# prometheus/scrape.d/authelia.yml by monitoringRefreshPrometheus when +# CFG_AUTHELIA_MONITORING=true. Authelia's telemetry.metrics endpoint listens +# on :9959 inside the container; reachable from the Prometheus container by +# the service name on the shared docker network. +- job_name: authelia + metrics_path: /metrics + static_configs: + - targets: ['authelia-service:9959'] diff --git a/containers/authelia/resources/users_database.yml b/containers/authelia/resources/users_database.yml new file mode 100644 index 0000000..e7c407e --- /dev/null +++ b/containers/authelia/resources/users_database.yml @@ -0,0 +1,11 @@ +--- +users: + admin: + disabled: false + displayname: "Admin User" + # Password: authelia + password: "$argon2id$v=19$m=65536,t=3,p=4$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: admin@example.com + groups: + - admins + - dev diff --git a/containers/bookstack/bookstack.config b/containers/bookstack/bookstack.config new file mode 100644 index 0000000..e577ddf --- /dev/null +++ b/containers/bookstack/bookstack.config @@ -0,0 +1,76 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# ADMIN_EMAIL = email used for the Bookstack admin account +# ADMIN_PASSWORD = password used for the Bookstack admin account +# +CFG_BOOKSTACK_APP_NAME=bookstack +CFG_BOOKSTACK_BACKUP=true +CFG_BOOKSTACK_COMPOSE_FILE=default +CFG_BOOKSTACK_HEALTHCHECK=true +CFG_BOOKSTACK_AUTHELIA=false +CFG_BOOKSTACK_HEADSCALE=false +CFG_BOOKSTACK_ADMIN_EMAIL=admin@example.com +CFG_BOOKSTACK_ADMIN_PASSWORD=RANDOMIZEDPASSWORD3 +# Secrets below feed the compose via #LIBREPORTAL|BOOKSTACK__TAG| tags — +# auto-generated, and (unlike a RANDOMIZED* placeholder in the compose) +# preserved across reinstalls. DB_PASSWORD is shared by the app + db services. +CFG_BOOKSTACK_APP_KEY=RANDOMIZEDAPPKEY1 +CFG_BOOKSTACK_DB_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_BOOKSTACK_DB_ROOT_PASSWORD=RANDOMIZEDPASSWORD2 +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_BOOKSTACK_CATEGORY="knowledge" +CFG_BOOKSTACK_TITLE="Bookstack" +CFG_BOOKSTACK_DESCRIPTION="Wiki/Knowledge Base" +CFG_BOOKSTACK_LONG_DESCRIPTION="BookStack is a simple, self-hosted wiki and documentation platform that provides a pleasant and simple way to organize and store information" +CFG_BOOKSTACK_URL="https://github.com/BookStackApp/BookStack" +CFG_BOOKSTACK_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_BOOKSTACK_DOMAIN=1 +CFG_BOOKSTACK_WHITELIST=false +CFG_BOOKSTACK_HOST_NAME=bookstack +CFG_BOOKSTACK_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_BOOKSTACK_PORT_1="bookstack-service|webui|random:80|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_BOOKSTACK_AUTH_PROFILE=multi_user +CFG_BOOKSTACK_ADMIN_USER= diff --git a/containers/bookstack/bookstack.sh b/containers/bookstack/bookstack.sh new file mode 100755 index 0000000..eafd58e --- /dev/null +++ b/containers/bookstack/bookstack.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# Category : Knowledge Management +# Description : Bookstack - Wiki/Knowledge Base (c/u/s/r/i): + +installBookstack() +{ + local config_variables="$1" + + if [[ "$bookstack" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent bookstack; + local app_name=$CFG_BOOKSTACK_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$bookstack" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$bookstack" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$bookstack" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$bookstack" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$bookstack" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + bookstack_target_email="${CFG_BOOKSTACK_ADMIN_EMAIL:-admin@admin.com}" + bookstack_target_pass="${CFG_BOOKSTACK_ADMIN_PASSWORD:-password}" + + bookstack_compose_file="$containers_dir$app_name/docker-compose.yml" + bookstack_port_pair=$(tagsManagerGetTagContent "$bookstack_compose_file" "PORTS_TAG_1") + bookstack_host_port="${bookstack_port_pair%%:*}" + bookstack_probe_url="http://127.0.0.1:${bookstack_host_port}/login" + + isNotice "Waiting for Bookstack to come online at ${bookstack_probe_url} ..." + isNotice "This may take up to 20 seconds, please wait..." + + bookstack_attempts=0 + bookstack_ready=0 + while ((bookstack_attempts < 60)); do + bookstack_http_code=$(curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "$bookstack_probe_url" 2>/dev/null) + if [[ "$bookstack_http_code" =~ ^(200|302)$ ]]; then + bookstack_ready=1 + break + fi + sleep 2 + ((bookstack_attempts++)) + done + + if ((bookstack_ready == 0)); then + isNotice "Bookstack did not respond on ${bookstack_probe_url} within $((60 * 2))s — admin account left at upstream defaults." + echo "" + isNotice "Bookstack admin login (default):" + echo "" + echo " Email : admin@admin.com" + echo " Password : password" + echo "" + else + isSuccessful "Bookstack is online (HTTP ${bookstack_http_code})." + + bookstack_create_output=$(sudo docker exec \ + -e EZ_BS_NEW_EMAIL="$bookstack_target_email" \ + -e EZ_BS_NEW_PASS="$bookstack_target_pass" \ + bookstack sh -c 'cd /app/www && s6-setuidgid abc php artisan bookstack:create-admin --no-ansi --email="$EZ_BS_NEW_EMAIL" --name=Admin --password="$EZ_BS_NEW_PASS" 2>&1') + bookstack_create_rc=$? + if [[ $bookstack_create_rc -eq 0 ]]; then + isSuccessful "Bookstack admin account created (email: $bookstack_target_email)." + + if [[ "$bookstack_target_email" != "admin@admin.com" ]]; then + sudo docker exec -i bookstack php /app/www/artisan tinker --no-ansi >/dev/null 2>&1 <<'PHP' +$c = class_exists('\BookStack\Users\Models\User') ? '\BookStack\Users\Models\User' : '\BookStack\Auth\User'; +optional($c::where('email', 'admin@admin.com')->first())->delete(); +PHP + isSuccessful "Removed seeded admin@admin.com account." + fi + + echo "" + isNotice "Bookstack admin login:" + echo "" + echo " Email : ${bookstack_target_email}" + echo " Password : ${bookstack_target_pass}" + echo "" + else + isNotice "Bookstack admin auto-create failed (exit $bookstack_create_rc). Output:" + echo "$bookstack_create_output" | sed 's/^/ /' + echo "" + isNotice "Falling back to upstream defaults — update from inside Bookstack." + echo "" + isNotice "Bookstack admin login (default):" + echo "" + echo " Email : admin@admin.com" + echo " Password : password" + echo "" + fi + fi + + menu_number=0 + #sleep 3s + cd + fi + bookstack=n +} diff --git a/containers/bookstack/bookstack.svg b/containers/bookstack/bookstack.svg new file mode 100755 index 0000000..a6ad581 --- /dev/null +++ b/containers/bookstack/bookstack.svg @@ -0,0 +1 @@ + diff --git a/containers/bookstack/docker-compose.yml b/containers/bookstack/docker-compose.yml new file mode 100755 index 0000000..ebeafa5 --- /dev/null +++ b/containers/bookstack/docker-compose.yml @@ -0,0 +1,68 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + bookstack-service: #LIBREPORTAL|SERVICE_TAG_1|bookstack-service + image: lscr.io/linuxserver/bookstack + container_name: bookstack + environment: + - PUID=1000 + - PGID=1000 + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - APP_URL=APP_URL_DATA #LIBREPORTAL|APP_URL_TAG|APP_URL_DATA + - APP_KEY=BOOKSTACK_APP_KEY_DATA #LIBREPORTAL|BOOKSTACK_APP_KEY_TAG|BOOKSTACK_APP_KEY_DATA + - DB_HOST=bookstack_db + - DB_PORT=3306 + - DB_USERNAME=bookstack + - DB_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA + - DB_DATABASE=bookstackapp + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./data:/config + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + restart: unless-stopped + depends_on: + - bookstack_db + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.bookstack-service.entrypoints: web,websecure + traefik.http.routers.bookstack-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.bookstack-service.tls: true + traefik.http.routers.bookstack-service.tls.certresolver: production + traefik.http.services.bookstack-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.bookstack-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + bookstack_db: #LIBREPORTAL|SERVICE_TAG_2|bookstack_db + image: lscr.io/linuxserver/mariadb + container_name: bookstack_db + environment: + - PUID=1000 + - PGID=1000 + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - MYSQL_ROOT_PASSWORD=BOOKSTACK_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_ROOT_PASSWORD_TAG|BOOKSTACK_DB_ROOT_PASSWORD_DATA + - MYSQL_DATABASE=bookstackapp + - MYSQL_USER=bookstack + - MYSQL_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA + volumes: + - ./db:/config + restart: unless-stopped + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/crowdsec/crowdsec.config b/containers/crowdsec/crowdsec.config new file mode 100644 index 0000000..7146041 --- /dev/null +++ b/containers/crowdsec/crowdsec.config @@ -0,0 +1,68 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# HOST_INSTALL = true means apt + systemd install on the host, not Docker +# HOST_PACKAGE = dpkg package name; drives the "installed" badge +# HOST_SERVICE = primary systemd unit; stop/restart actions hit this +# HOST_SERVICES = all units; feeds the Services + Logs tabs +# HOST_LOG_FILES = |,... mapping for the log viewer +# BACKUP = include in backup operations +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed; ships the official CrowdSec Grafana dashboards) +# PROMETHEUS_LISTEN = address CrowdSec's metrics endpoint binds to; must be reachable from the Prometheus container (default: all interfaces, port 6060 — keep the :6060 port) +# +CFG_CROWDSEC_APP_NAME=crowdsec +CFG_CROWDSEC_HOST_INSTALL=true +CFG_CROWDSEC_HOST_PACKAGE=crowdsec +CFG_CROWDSEC_HOST_SERVICE=crowdsec +CFG_CROWDSEC_HOST_SERVICES=crowdsec.service,crowdsec-firewall-bouncer.service +CFG_CROWDSEC_HOST_LOG_FILES="crowdsec.service|/var/log/crowdsec.log,crowdsec-firewall-bouncer.service|/var/log/crowdsec-firewall-bouncer.log" +CFG_CROWDSEC_BACKUP=true +CFG_CROWDSEC_MONITORING=false +CFG_CROWDSEC_PROMETHEUS_LISTEN=0.0.0.0:6060 +# +# ============================================================================= +# BEHAVIOUR +# ============================================================================= +# ENABLED = master switch; false disables services (package stays) +# AUTO_UPDATE = pull hub parser/scenario updates from hub.crowdsec.net +# COMMUNITY_BLOCKLIST = subscribe to the free pooled blocklist (CAPI) +# CONSOLE_ENROLL = enroll this agent with the hosted SaaS at app.crowdsec.net (NOT the local dashboard) +# CONSOLE_TOKEN = enrollment token from app.crowdsec.net (only used when CONSOLE_ENROLL=true) +# BOUNCER = attach the Traefik bouncer middleware to every public route +# +CFG_CROWDSEC_ENABLED=true +CFG_CROWDSEC_AUTO_UPDATE=true +CFG_CROWDSEC_COMMUNITY_BLOCKLIST=true +CFG_CROWDSEC_CONSOLE_ENROLL=false +CFG_CROWDSEC_CONSOLE_TOKEN= +CFG_CROWDSEC_BOUNCER=true +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = grouping in the app grid +# TITLE = display name +# DESCRIPTION = one-liner +# LONG_DESCRIPTION = card body text +# URL = source / docs link +# ACTIONS = available lifecycle verbs +# +CFG_CROWDSEC_CATEGORY="security,recommended" +CFG_CROWDSEC_TITLE="CrowdSec" +CFG_CROWDSEC_DESCRIPTION="Intrusion Prevention" +CFG_CROWDSEC_LONG_DESCRIPTION="CrowdSec is an open-source intrusion prevention system. It detects attacks from log patterns — brute-force, scans, web exploits — and blocks offending IPs at the firewall. Includes community-shared threat intelligence." +CFG_CROWDSEC_URL="https://www.crowdsec.net" +CFG_CROWDSEC_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# ADVANCED +# ============================================================================= +# LAPI_HOST = LAPI bind address; 0.0.0.0 so Traefik can reach via host.docker.internal +# BOUNCER_NAME_TRAEFIK = bouncer name registered with cscli bouncers add +# TRAEFIK_LAPI_KEY = auto-generated by installCrowdsec; use the rotate Tools action to change +# +CFG_CROWDSEC_LAPI_HOST=0.0.0.0:8080 +CFG_CROWDSEC_BOUNCER_NAME_TRAEFIK=traefik-bouncer +CFG_CROWDSEC_TRAEFIK_LAPI_KEY= diff --git a/containers/crowdsec/crowdsec.sh b/containers/crowdsec/crowdsec.sh new file mode 100644 index 0000000..7b0cd38 --- /dev/null +++ b/containers/crowdsec/crowdsec.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# Category : Security +# Description : CrowdSec - Intrusion Prevention (c/u/s/r/i): +# +# Host-installed agent (apt + systemd) — no Docker container. Host install +# logic lives in scripts/install/install_crowdsec.sh (installCrowdsecHost); +# install registration uses the shared hostAppInstall helper +# (scripts/install/host_app.sh). uninstall/stop/restartCrowdsec (below) are the +# host-side hooks dockerUninstallApp / dockerStopApp / dockerRestartApp invoke. + +installCrowdsec() +{ + local config_variables="$1" + + if [[ "$crowdsec" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent crowdsec; + initializeAppVariables "$CFG_CROWDSEC_APP_NAME"; + fi + local app_name=$CFG_CROWDSEC_APP_NAME + + if [[ "$crowdsec" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + # Uninstall / stop / restart are NOT dispatched here — the CLI and menu call + # dockerUninstallApp / dockerStopApp / dockerRestartApp directly. Those run + # the generic docker teardown (a no-op for a host app) and then invoke the + # uninstall/stop/restartCrowdsec hooks (bottom of this file) for the + # host-side work. + + if [[ "$crowdsec" == *[iI]* ]]; then + installCrowdsecHost; + + if command -v cscli >/dev/null 2>&1; then + # Register crowdsec as an installed host app — apps DB row + WebUI regen. + hostAppInstall "$app_name"; + + # Monitoring: gather crowdsec's scrape fragment + Grafana dashboards + # into Prometheus/Grafana. Run unconditionally — the refresh is + # self-correcting (adds when CFG_CROWDSEC_MONITORING=true, removes + # crowdsec's entry when it's been toggled off). No-ops with a notice + # when Prometheus/Grafana aren't installed. + monitoringRefreshAll; + else + isNotice "cscli missing — crowdsec host install did not complete. Skipping registration." + fi + fi +} + +# Host-side uninstall, invoked by dockerUninstallApp's uninstall hook. +# dockerUninstallApp already handles the generic teardown (data dir, DB rows, +# WebUI regen) — this does what the generic path can't: stopping + purging the +# apt packages and detaching the log bind-mounts. +uninstallCrowdsec() +{ + ((menu_number++)) + echo "" + echo "---- $menu_number. Stopping CrowdSec host services." + echo "" + local result=$(sudo systemctl disable --now crowdsec-firewall-bouncer 2>&1) + checkSuccess "Disabling firewall bouncer" + local result=$(sudo systemctl disable --now crowdsec 2>&1) + checkSuccess "Disabling agent" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Removing CrowdSec packages." + echo "" + local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get purge -y -q crowdsec crowdsec-firewall-bouncer-nftables &1) + checkSuccess "Purged packages" + local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -q &1) + checkSuccess "Removed orphaned dependencies" + + crowdsecToggleLibrePortalLogMounts off +} + +# Host-side stop, invoked by dockerStopApp's stop hook. crowdsec ships no +# docker container, so dockerStopApp is a no-op — this stops the host agent + +# bouncer. The package stays installed; only Uninstall removes it. +stopCrowdsec() +{ + isNotice "Stopping CrowdSec host services..." + local result=$(sudo systemctl stop crowdsec-firewall-bouncer 2>&1) + checkSuccess "Stopped firewall bouncer" + local result=$(sudo systemctl stop crowdsec 2>&1) + checkSuccess "Stopped agent" +} + +# Host-side restart, invoked by dockerRestartApp's restart hook. crowdsec +# ships no docker container, so dockerRestartApp is a no-op — this restarts the +# host agent + bouncer. +restartCrowdsec() +{ + isNotice "Restarting CrowdSec host services..." + local result=$(sudo systemctl restart crowdsec 2>&1) + checkSuccess "Restarted agent" + local result=$(sudo systemctl restart crowdsec-firewall-bouncer 2>&1) + checkSuccess "Restarted firewall bouncer" +} diff --git a/containers/crowdsec/crowdsec.svg b/containers/crowdsec/crowdsec.svg new file mode 100644 index 0000000..fd4ffac --- /dev/null +++ b/containers/crowdsec/crowdsec.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json new file mode 100644 index 0000000..a0bd2e8 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json @@ -0,0 +1,1520 @@ +{ + "__elements": [], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "iteration": 1659090580950, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 22, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "process_resident_memory_bytes{instance=\"$instance\"}", + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Process Mem Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 1 + }, + "hiddenSeries": false, + "id": 20, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(process_cpu_seconds_total{instance=\"$instance\"}[$__interval])*100", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Process CPU Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "dateTimeAsIso" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 0, + "y": 12 + }, + "id": 16, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "(process_start_time_seconds{instance=\"$instance\"})*1000", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "title": "Up Since", + "transparent": true, + "type": "stat" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "System", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 1 + }, + "id": 28, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 2 + }, + "id": 32, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(cs_alerts)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Alerts Count", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 2 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "cs_alerts{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{reason}}", + "refId": "A" + } + ], + "title": "Alerts per Scenario", + "type": "timeseries" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Alerts", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 2 + }, + "id": 24, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_journalctlsource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "hide": false, + "interval": "", + "legendFormat": "{{source}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_cloudwatch_stream_hits_total{instance=\"$instance\"}[$__interval])) by (group, stream)", + "hide": false, + "interval": "", + "legendFormat": "{{group}} - {{stream}}", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_syslogsource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "hide": false, + "interval": "", + "legendFormat": "", + "refId": "D" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Acquisition", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 15 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_parser_hits_ok_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Parsed lines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 15 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_parser_hits_ko_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Unparsed lines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 34, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\", source=\"$source\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Parsing time", + "type": "heatmap" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Parsing", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 26, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 4 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(cs_buckets{instance=\"$instance\"}) by (name)", + "interval": "", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Bucket Timeline", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 12 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_created_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets creation", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 29 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_underflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets underflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 37 + }, + "id": 35, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(cs_bucket_pour_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\", source=\"$source\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Bucket pour time", + "type": "heatmap" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Buckets", + "type": "row" + } + ], + "refresh": false, + "schemaVersion": 36, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_info, instance)", + "hide": 0, + "includeAll": false, + "label": "instance", + "multi": false, + "name": "instance", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "Prometheus-instance-Variable-Query" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\"}, type)", + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource_type", + "options": [], + "query": { + "query": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\"}, type)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\"}, source)", + "hide": 0, + "includeAll": false, + "multi": false, + "name": "source", + "options": [], + "query": { + "query": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\"}, source)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Details per instance", + "uid": "6L2GdB47z", + "version": 7, + "weekStart": "" +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json new file mode 100644 index 0000000..c73423e --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json @@ -0,0 +1,811 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1655915159751, + "links": [], + "panels": [ + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 22, + "panels": [ + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "dateTimeAsIso" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 2, + "y": 1 + }, + "id": 2, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "(process_start_time_seconds{instance=\"$instance\"})*1000", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Up since", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "displayName": "", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 7, + "y": 1 + }, + "id": 4, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_resident_memory_bytes{instance=\"$instance\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 3, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Average Mem Usage", + "transparent": true, + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 12, + "y": 1 + }, + "id": 6, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "rate(process_cpu_seconds_total{instance=\"$instance\"}[$__interval])*100", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Average CPU Usage", + "transparent": true, + "type": "gauge" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 17, + "y": 1 + }, + "id": 20, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_max_fds{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Process Max FDs", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 2, + "y": 10 + }, + "id": 11, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_cloudwatch_stream_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_journalctlsource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_syslogsource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total raw lines", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 7, + "y": 10 + }, + "id": 12, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_parser_hits_ok_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total parsed lines", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 12, + "y": 10 + }, + "id": 8, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total Overflows", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 17, + "y": 10 + }, + "id": 18, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_open_fds{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Process Open FDs", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 4, + "y": 19 + }, + "id": 14, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "expr": "sum(increase(cs_parser_hits_ko_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total unparsed lines", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 9, + "y": 19 + }, + "id": 16, + "options": { + "displayMode": "gradient", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "repeat": null, + "repeatDirection": "v", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Top scenarios", + "transparent": true, + "type": "bargauge" + } + ], + "repeat": "instance", + "title": "$instance", + "type": "row" + } + ], + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(cs_info, instance)", + "description": null, + "error": null, + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "Prometheus-instance-Variable-Query" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Insight", + "uid": "e7sWOBVnk", + "version": 8 +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json new file mode 100644 index 0000000..81a33c0 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json @@ -0,0 +1,515 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1655915193937, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 10, + "panels": [], + "title": "Agents", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "displayMode": "gradient", + "orientation": "vertical", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "repeat": "query0", + "repeatDirection": "h", + "targets": [ + { + "exemplar": false, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/watchers/login\", instance=\"$lapi\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Agents Login", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 6, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/watchers/login\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Heartbeat", + "type": "heatmap" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 12, + "panels": [], + "title": "Decisions", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint= \"/v1/decisions\", instance=\"$lapi\", method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions GET (live)", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 13, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint= \"/v1/decisions/stream\", instance=\"$lapi\", method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions GET (stream)", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 8, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=~\"/v1/decisions.*\", instance=\"$lapi\", method=\"DELETE\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions DELETE", + "type": "heatmap" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 15, + "panels": [], + "title": "Alerts", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 17, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/alerts\",instance=\"$lapi\",method=\"POST\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Alerts POST", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 18, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/alerts\",instance=\"$lapi\",method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Alerts GET", + "type": "heatmap" + } + ], + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(cs_info, instance)", + "description": null, + "error": null, + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "lapi", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "LAPI Metrics", + "uid": "ofdKJG37k", + "version": 11 +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json new file mode 100644 index 0000000..bdef3ab --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json @@ -0,0 +1,1321 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 24, + "panels": [], + "title": "Summary", + "type": "row" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#E02F44", + "value": null + }, + { + "color": "#E02F44", + "value": 10 + }, + { + "color": "#299c46", + "value": 10 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 2, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "count(cs_info)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Running Crowdsec", + "transparent": true, + "type": "stat" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 18, + "x": 6, + "y": 1 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_journalctlsource_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_cloudwatch_stream_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_syslogsource_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Acquisitions", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_parser_hits_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_overflowed_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 30, + "panels": [], + "title": "Alerts", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 36, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Decisions Count", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 38, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_alerts)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Alerts Count", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 32, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions) by (reason)", + "interval": "", + "legendFormat": "{{reason}}", + "refId": "A" + } + ], + "title": "Decisions by scenario", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions) by (action)", + "interval": "", + "legendFormat": "{{action}}", + "refId": "A" + } + ], + "title": "Decisions By Type", + "type": "timeseries" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 26, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 4, + "interval": "", + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_node_hits_ok_total[$__interval])) by (name)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers ok", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 3 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sideWidth": null, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_node_hits_ko_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers nok", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Parsers", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 28, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_created_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets created", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 3 + }, + "hiddenSeries": false, + "id": 20, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_overflowed_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 12 + }, + "hiddenSeries": false, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(cs_buckets) by (name)", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets Timeline", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Buckets", + "type": "row" + } + ], + "refresh": false, + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Overview", + "uid": "hjmZdB4nk", + "version": 7 +} diff --git a/containers/crowdsec/resources/monitoring/prometheus-scrape.yml b/containers/crowdsec/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..094d6c4 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,9 @@ +# Prometheus scrape job for the CrowdSec host agent. Gathered into +# prometheus/scrape.d/crowdsec.yml by monitoringRefreshPrometheus when +# CFG_CROWDSEC_MONITORING=true. CrowdSec runs on the host, so the target is +# host.docker.internal (the Prometheus container has the host-gateway alias); +# installCrowdsecHost binds CrowdSec's metrics endpoint to a reachable address. +- job_name: crowdsec + metrics_path: /metrics + static_configs: + - targets: ['host.docker.internal:6060'] diff --git a/containers/dashy/dashy.config b/containers/dashy/dashy.config new file mode 100755 index 0000000..235942b --- /dev/null +++ b/containers/dashy/dashy.config @@ -0,0 +1,85 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_DASHY_APP_NAME=dashy +CFG_DASHY_BACKUP=true +CFG_DASHY_COMPOSE_FILE=default +CFG_DASHY_HEALTHCHECK=true +CFG_DASHY_AUTHELIA=false +CFG_DASHY_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_DASHY_CATEGORY="productivity" +CFG_DASHY_TITLE="Dashy" +CFG_DASHY_DESCRIPTION="Dashboard Tool" +CFG_DASHY_LONG_DESCRIPTION="Dashy is a self-hostable personal dashboard for monitoring your self-hosted services and applications with a beautiful, intuitive interface" +CFG_DASHY_URL="https://github.com/Lissy93/dashy" +CFG_DASHY_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_DASHY_DOMAIN=1 +CFG_DASHY_WHITELIST=false +CFG_DASHY_HOST_NAME=dashy +CFG_DASHY_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_DASHY_PORT_1="dashy-service|webui|random:8080|private|tcp|false|false|true|Dashboard|" +# Comma-separated list of installed app slugs to surface as shortcuts on +# the dashy dashboard. Managed via the Tools tab → "Manage Shortcuts". +# Empty = no app shortcuts (only the static page header survives). +CFG_DASHY_SHORTCUTS= +# +# ============================================================================= +# DASHBOARD CUSTOMIZATION +# ============================================================================= +# THEME = built-in dashy theme name (Nord-Frost, Dracula, Cyberpunk, ...) +# PAGE_TITLE = page header title (blank = "Dashy - LibrePortal - ") +# PAGE_DESCRIPTION = page header subtitle (blank = default welcome line) +# LAYOUT = item layout: auto / horizontal / vertical +# ICON_SIZE = tile icon size: small / medium / large +# OPEN_TARGET = where tile clicks open: newtab / sametab / modal / workspace +# STATUS_CHECK = if true, dashy pings each tile and shows up/down status +# +CFG_DASHY_THEME="Nord-Frost" +CFG_DASHY_PAGE_TITLE= +CFG_DASHY_PAGE_DESCRIPTION= +CFG_DASHY_LAYOUT="auto" +CFG_DASHY_ICON_SIZE="medium" +CFG_DASHY_OPEN_TARGET="newtab" +CFG_DASHY_STATUS_CHECK=false diff --git a/containers/dashy/dashy.sh b/containers/dashy/dashy.sh new file mode 100755 index 0000000..ba9eef8 --- /dev/null +++ b/containers/dashy/dashy.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Category : Miscellaneous +# Description : Dashy - Dashboard Tool (c/t/u/s/r/i): + +installDashy() +{ + local config_variables="$1" + + if [[ "$dashy" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent dashy; + local app_name=$CFG_DASHY_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$dashy" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$dashy" == *[tT]* ]]; then + dashyToolsMenu; + fi + + if [[ "$dashy" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$dashy" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$dashy" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$dashy" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + dashy=n +} diff --git a/containers/dashy/dashy.svg b/containers/dashy/dashy.svg new file mode 100755 index 0000000..ce68744 --- /dev/null +++ b/containers/dashy/dashy.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/dashy/docker-compose.yml b/containers/dashy/docker-compose.yml new file mode 100755 index 0000000..a67aff2 --- /dev/null +++ b/containers/dashy/docker-compose.yml @@ -0,0 +1,45 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + dashy-service: #LIBREPORTAL|SERVICE_TAG_1|dashy-service + image: lissy93/dashy + container_name: dashy + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./etc:/app/user-data + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - NODE_ENV=production + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + restart: unless-stopped + healthcheck: + test: ['CMD', 'node', '/app/services/healthcheck'] + interval: 1m30s + timeout: 10s + retries: 3 + start_period: 40s + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.dashy-service.entrypoints: web,websecure + traefik.http.routers.dashy-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.dashy-service.tls: true + traefik.http.routers.dashy-service.tls.certresolver: production + traefik.http.services.dashy-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.dashy-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/dashy/resources/conf.yml b/containers/dashy/resources/conf.yml new file mode 100755 index 0000000..5554b67 --- /dev/null +++ b/containers/dashy/resources/conf.yml @@ -0,0 +1,232 @@ +--- +# Page meta info, like heading, footer text and nav links +pageInfo: + title: INSTALLNAMEHERE - LibrePortal Dashy + description: Welcome to your LibrePortal Dashy dashboard! + navLinks: + - title: Dashy GitHub + path: http://github.com/Lissy93/dashy + - title: Dashy Documentation + path: http://dashy.to/docs + +# Optional app settings and configuration +appConfig: + theme: Nord-Frost + +# Main content - An array of sections, each containing an array of items +sections: + +#### category system +#- name: System Applications +# icon: fas fa-rocket +# items: + + #### app traefik + #- title: Traefik + # description: For VPN connections + # icon: si-traefikproxy + # url: http://APPADDRESSHERE/ + # statusCheck: false + # target: newtab + + #### app adguard + #- title: Adguard + # description: DNS Server with AdBlocking DNS-over-TLS + # icon: hl-adblock + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app pihole + #- title: Pi-Hole + # description: DNS Server with AdBlocking and Unbound + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app portainer + #- title: Portainer + # description: Docker service management panel + # icon: hl-portainer + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app authelia + #- title: Authelia + # description: Single Sign-On Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app grafana + #- title: Grafana + # description: Metrics Visualizer + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app prometheus + #- title: Prometheus + # description: Metrics Collector + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app headscale + #- title: Headscale + # description: VPN Networking + # icon: si-traefikmesh + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app duplicati + #- title: Duplicati + # description: Docker backup system + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + +#### category privacy +#- name: Privacy Apps +# icon: fas fa-rocket +# items: + + #### app mailcow + #- title: Mail Cow + # description: Mail Server + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app vaultwarden + #- title: VaultWarden + # description: Password Manager + # icon: hl-bitwarden + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app trillium + #- title: Trilium + # description: Note Manager + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app invidious + #- title: Invidious + # description: Alternative YouTube Frontend + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app searxng + #- title: SearXNG + # description: Privacy based Search Engine + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app speedtest + #- title: SpeedTest + # description: Used for testing the speed of your internet + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app firefly + #- title: FireFly iii + # description: Money Budgetting Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app ipinfo + #- title: IP Info + # description: Shows your current IP Address + # icon: hl-docker + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + +#### category user +#- name: User Apps +# icon: fas fa-rocket +# items: + + #### app mattermost + #- title: Mattermost + # description: Project Management & Chat Platform + # icon: hl-mattermost + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app jitsimeet + #- title: Jitsi Meet + # description: Video Conferencing Platform (Zoom alternative) + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app owncloud + #- title: OwnCloud + # description: File & Document Hosting + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app onlyoffice + #- title: OnlyOffice + # description: Document Collaboration Connector + # icon: hl-onlyoffice + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app kimai + #- title: Kimai + # description: Time Management Client Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app killbill + #- title: Kill Bill + # description: Payment Processing Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app rustdesk + #- title: RustDesk + # description: Remote Desktop Server + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app tiledesk + #- title: TileDesk + # description: Live Chat Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab \ No newline at end of file diff --git a/containers/focalboard/docker-compose.yml b/containers/focalboard/docker-compose.yml new file mode 100755 index 0000000..e3c4152 --- /dev/null +++ b/containers/focalboard/docker-compose.yml @@ -0,0 +1,44 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + focalboard-service: #LIBREPORTAL|SERVICE_TAG_1|focalboard-service + image: mattermost/focalboard + container_name: focalboard-service + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./data:/data + environment: + - VIRTUAL_HOST:DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - VIRTUAL_PORT:8000 + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + restart: unless-stopped + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.focalboard-service.entrypoints: web,websecure + traefik.http.routers.focalboard-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.focalboard-service.tls: true + traefik.http.routers.focalboard-service.tls.certresolver: production + traefik.http.services.focalboard-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.focalboard-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + logging: + driver: "json-file" + options: + max-size: "1m" diff --git a/containers/focalboard/focalboard.config b/containers/focalboard/focalboard.config new file mode 100755 index 0000000..21c4388 --- /dev/null +++ b/containers/focalboard/focalboard.config @@ -0,0 +1,68 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_FOCALBOARD_APP_NAME=focalboard +CFG_FOCALBOARD_BACKUP=true +CFG_FOCALBOARD_COMPOSE_FILE=default +CFG_FOCALBOARD_HEALTHCHECK=true +CFG_FOCALBOARD_AUTHELIA=false +CFG_FOCALBOARD_HEADSCALE=false + +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_FOCALBOARD_CATEGORY="productivity" +CFG_FOCALBOARD_TITLE="Focalboard" +CFG_FOCALBOARD_DESCRIPTION="Project Management" +CFG_FOCALBOARD_LONG_DESCRIPTION="Focalboard is an open source, self-hosted alternative to Trello, Notion, and Asana that helps organize projects and tasks" +CFG_FOCALBOARD_URL="https://github.com/mattermost/focalboard" +CFG_FOCALBOARD_ACTIONS="configure|install|restart|shutdown|uninstall" + +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_FOCALBOARD_DOMAIN=1 +CFG_FOCALBOARD_WHITELIST=false +CFG_FOCALBOARD_HOST_NAME=board +CFG_FOCALBOARD_NETWORK=default + +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_FOCALBOARD_PORT_1="focalboard-service|webui|random:8000|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_FOCALBOARD_AUTH_PROFILE=multi_user +CFG_FOCALBOARD_ADMIN_USER= +CFG_FOCALBOARD_ADMIN_EMAIL= +CFG_FOCALBOARD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/focalboard/focalboard.sh b/containers/focalboard/focalboard.sh new file mode 100755 index 0000000..0ce9317 --- /dev/null +++ b/containers/focalboard/focalboard.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Category : Productivity +# Description : Focalboard - Project Management (c/u/s/r/i): + +installFocalboard() +{ + local config_variables="$1" + + if [[ "$focalboard" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent focalboard; + local app_name=$CFG_FOCALBOARD_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$focalboard" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$focalboard" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$focalboard" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$focalboard" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$focalboard" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + focalboard=n +} diff --git a/containers/focalboard/focalboard.svg b/containers/focalboard/focalboard.svg new file mode 100755 index 0000000..b78e7e3 --- /dev/null +++ b/containers/focalboard/focalboard.svg @@ -0,0 +1 @@ + diff --git a/containers/gitea/docker-compose.yml b/containers/gitea/docker-compose.yml new file mode 100755 index 0000000..e6869a3 --- /dev/null +++ b/containers/gitea/docker-compose.yml @@ -0,0 +1,92 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + gitea-service: #LIBREPORTAL|SERVICE_TAG_1|gitea-service + container_name: gitea-service + image: gitea/gitea:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + depends_on: + gitea-cache: + condition: service_healthy + # GLUETUN_OFF_END + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./data/gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + logging: + driver: "json-file" + options: + max-size: "1m" + environment: + - APP_NAME="Gitea" + - USER_UID=1000 + - USER_GID=1000 + - USER=git + - RUN_MODE=prod + - DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SSH_DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - HTTP_PORT=PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + - ROOT_URL=https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SSH_PORT=PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - SSH_LISTEN_PORT=PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - DB_TYPE=sqlite3 + - GITEA__cache__ENABLED=true + - GITEA__cache__ADAPTER=redis + - GITEA__cache__HOST=redis://gitea-cache:6379/0?pool_size=100&idle_timeout=180s + - GITEA__cache__ITEM_TTL=24h + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + # >>> libreportal-monitoring >>> + #- GITEA__metrics__ENABLED=true + #- GITEA__metrics__TOKEN=GITEA_METRICS_TOKEN_DATA #LIBREPORTAL|GITEA_METRICS_TOKEN_TAG|GITEA_METRICS_TOKEN_DATA + # <<< libreportal-monitoring <<< + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + # GLUETUN_OFF_END + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # depends_on: + # gitea-cache: + # condition: service_healthy + # GLUETUN_ON_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.gitea-service.entrypoints: web,websecure + traefik.http.routers.gitea-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.gitea.tls: true + traefik.http.routers.gitea.tls.certresolver: production + traefik.http.services.gitea.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.gitea.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + + gitea-cache: #LIBREPORTAL|SERVICE_TAG_2|gitea-cache + container_name: gitea-cache + image: redis:6-alpine + restart: unless-stopped + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 3s + retries: 30 + logging: + driver: "json-file" + options: + max-size: "1m" + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/gitea/gitea.config b/containers/gitea/gitea.config new file mode 100755 index 0000000..8f9da82 --- /dev/null +++ b/containers/gitea/gitea.config @@ -0,0 +1,73 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# METRICS_TOKEN = bearer token guarding /metrics (rides the public web port); auto-generated, mirrored into the Prometheus scrape config +# +CFG_GITEA_APP_NAME=gitea +CFG_GITEA_BACKUP=true +CFG_GITEA_COMPOSE_FILE=default +CFG_GITEA_HEALTHCHECK=true +CFG_GITEA_AUTHELIA=false +CFG_GITEA_HEADSCALE=false +CFG_GITEA_MONITORING=false +CFG_GITEA_METRICS_TOKEN=RANDOMIZEDPASSWORD1 + +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GITEA_CATEGORY="development" +CFG_GITEA_TITLE="Gitea" +CFG_GITEA_DESCRIPTION="Git Repository Management" +CFG_GITEA_LONG_DESCRIPTION="Gitea is a lightweight, self-hosted Git service written in Go that provides a painless self-hosted Git service with a minimal setup" +CFG_GITEA_URL="https://github.com/go-gitea/gitea" +CFG_GITEA_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_GITEA_DOMAIN=1 +CFG_GITEA_WHITELIST=false +CFG_GITEA_HOST_NAME=gitea +CFG_GITEA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GITEA_PORT_1="gitea-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" +CFG_GITEA_PORT_2="gitea-service|ssh|random:22|private|tcp|false|false|false|Git SSH Access|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_GITEA_AUTH_PROFILE=multi_user +CFG_GITEA_ADMIN_USER= +CFG_GITEA_ADMIN_EMAIL= +CFG_GITEA_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/gitea/gitea.sh b/containers/gitea/gitea.sh new file mode 100755 index 0000000..c47864c --- /dev/null +++ b/containers/gitea/gitea.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# Category : Development & Version Control +# Description : Gitea - Git Repository Management (c/u/s/r/i): + +installGitea() +{ + local config_variables="$1" + + if [[ "$gitea" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent gitea; + local app_name=$CFG_GITEA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$gitea" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$gitea" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$gitea" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$gitea" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$gitea" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + # Enable Gitea's /metrics endpoint only when CFG_GITEA_MONITORING=true + # (toggles the libreportal-monitoring marker block in the compose). + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + # /metrics rides Gitea's public web port, so it's locked behind a + # bearer token. CFG_GITEA_METRICS_TOKEN lives in the .config (filled + # once by the RANDOMIZEDPASSWORD scanner, preserved across reinstalls) + # and reaches the compose via the GITEA_METRICS_TOKEN_TAG tag — mirror + # that same value into the Prometheus scrape fragment so the two agree. + if monitoringAppEnabled "$app_name"; then + if [[ -n "$CFG_GITEA_METRICS_TOKEN" ]]; then + sudo sed -i "s|GITEA_METRICS_TOKEN_PLACEHOLDER|${CFG_GITEA_METRICS_TOKEN}|g" \ + "$containers_dir$app_name/resources/monitoring/prometheus-scrape.yml" + checkSuccess "Synced Gitea /metrics token to the Prometheus scrape config" + else + isNotice "CFG_GITEA_METRICS_TOKEN is empty — Gitea /metrics scrape may 401." + fi + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + # Self-correcting: adds Gitea's scrape target + dashboard to + # Prometheus/Grafana when CFG_GITEA_MONITORING=true, removes them when + # it's off. No-ops with a notice if either app isn't installed. + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + gitea=n +} diff --git a/containers/gitea/gitea.svg b/containers/gitea/gitea.svg new file mode 100755 index 0000000..11c6df8 --- /dev/null +++ b/containers/gitea/gitea.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json b/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json new file mode 100644 index 0000000..3e0f64c --- /dev/null +++ b/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json @@ -0,0 +1,1096 @@ +{ + "annotations": { + "list": [ + { + "$$hashKey": "object:2075", + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Monitor Gitea server", + "editable": true, + "gnetId": 13192, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 31, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_resident_memory_bytes{job=\"gitea\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Resident memory", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 0 + }, + "hiddenSeries": false, + "id": 32, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_cpu_seconds_total{job=\"gitea\"}", + "instant": false, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU time", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 0 + }, + "hiddenSeries": false, + "id": 33, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_open_fds{job=\"gitea\"}", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Open FDS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 6 + }, + "id": 2, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_accesses", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Accesses", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 6 + }, + "id": 18, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_repositories", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Repositories", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 6 + }, + "id": 24, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_users", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Users", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 6 + }, + "id": 5, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_issues", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Issues", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 10 + }, + "id": 11, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_actions", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Actions", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 10 + }, + "id": 10, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_labels", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Labels", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 10 + }, + "id": 21, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_organizations", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Organizations", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 10 + }, + "id": 16, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_teams", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Teams", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_follows", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Follows", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 10 + }, + "id": 20, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_publickeys", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of PublicKeys", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 14 + }, + "id": 17, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_stars", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Stars", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 14 + }, + "id": 14, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_webhooks", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Webhooks", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 14 + }, + "id": 19, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_releases", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Releases", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 14 + }, + "id": 7, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_comments", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Comments", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 14 + }, + "id": 3, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_milestones", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Milestones", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 14 + }, + "id": 25, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_watches", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Watches", + "type": "stat" + } + ], + "refresh": "1m", + "schemaVersion": 22, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Gitea", + "uid": "nNq1Iw5Gz", + "variables": { + "list": [] + }, + "version": 7 +} diff --git a/containers/gitea/resources/monitoring/prometheus-scrape.yml b/containers/gitea/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..e280967 --- /dev/null +++ b/containers/gitea/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,13 @@ +# Prometheus scrape job for Gitea. Gathered into +# prometheus/scrape.d/gitea.yml by monitoringRefreshPrometheus when +# CFG_GITEA_MONITORING=true. Gitea's /metrics rides the main HTTP port (3000), +# which is the public web port — so it's locked behind a bearer token. +# The compose fills GITEA__metrics__TOKEN via the framework's RANDOMIZEDPASSWORD +# mechanism; gitea.sh mirrors that generated value into this fragment so the +# two always match. +- job_name: gitea + metrics_path: /metrics + authorization: + credentials: GITEA_METRICS_TOKEN_PLACEHOLDER + static_configs: + - targets: ['gitea-service:3000'] diff --git a/containers/gluetun/docker-compose.yml b/containers/gluetun/docker-compose.yml new file mode 100644 index 0000000..4e3fd49 --- /dev/null +++ b/containers/gluetun/docker-compose.yml @@ -0,0 +1,55 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + gluetun-service: #LIBREPORTAL|SERVICE_TAG_1|gluetun-service + container_name: gluetun-service + image: qmcgaw/gluetun:latest + restart: unless-stopped + hostname: gluetun + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - VPN_SERVICE_PROVIDER=GLUETUN_VPN_SERVICE_PROVIDER_DATA #LIBREPORTAL|GLUETUN_VPN_SERVICE_PROVIDER_TAG|GLUETUN_VPN_SERVICE_PROVIDER_DATA + - VPN_TYPE=GLUETUN_VPN_TYPE_DATA #LIBREPORTAL|GLUETUN_VPN_TYPE_TAG|GLUETUN_VPN_TYPE_DATA + - SERVER_COUNTRIES=GLUETUN_VPN_COUNTRIES_DATA #LIBREPORTAL|GLUETUN_VPN_COUNTRIES_TAG|GLUETUN_VPN_COUNTRIES_DATA + - OPENVPN_USER=GLUETUN_OPENVPN_USER_DATA #LIBREPORTAL|GLUETUN_OPENVPN_USER_TAG|GLUETUN_OPENVPN_USER_DATA + - OPENVPN_PASSWORD=GLUETUN_OPENVPN_PASSWORD_DATA #LIBREPORTAL|GLUETUN_OPENVPN_PASSWORD_TAG|GLUETUN_OPENVPN_PASSWORD_DATA + - WIREGUARD_PRIVATE_KEY=GLUETUN_WIREGUARD_PRIVATE_KEY_DATA #LIBREPORTAL|GLUETUN_WIREGUARD_PRIVATE_KEY_TAG|GLUETUN_WIREGUARD_PRIVATE_KEY_DATA + - WIREGUARD_ADDRESSES=GLUETUN_WIREGUARD_ADDRESSES_DATA #LIBREPORTAL|GLUETUN_WIREGUARD_ADDRESSES_TAG|GLUETUN_WIREGUARD_ADDRESSES_DATA + - HTTP_CONTROL_SERVER_AUTH_FILE_PATH=/gluetun/auth/config.toml + - HEALTH_TARGET_ADDRESSES=GLUETUN_HEALTH_TARGETS_DATA #LIBREPORTAL|GLUETUN_HEALTH_TARGETS_TAG|GLUETUN_HEALTH_TARGETS_DATA + - HEALTH_ICMP_TARGET_IPS=GLUETUN_HEALTH_ICMP_IPS_DATA #LIBREPORTAL|GLUETUN_HEALTH_ICMP_IPS_TAG|GLUETUN_HEALTH_ICMP_IPS_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./gluetun-data:/gluetun + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_FORWARDED_PORTS_BEGIN + # GLUETUN_FORWARDED_PORTS_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + + # >>> libreportal-monitoring >>> + #gluetun-exporter: + # container_name: gluetun-exporter + # image: damianr1/gluetun-exporter:latest + # restart: unless-stopped + # depends_on: + # - gluetun-service + # environment: + # - GLUETUN_API_URL=http://gluetun-service:8000 + # - LISTEN_ADDRESS=:PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + # networks: + # DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # <<< libreportal-monitoring <<< diff --git a/containers/gluetun/gluetun.config b/containers/gluetun/gluetun.config new file mode 100644 index 0000000..03d060b --- /dev/null +++ b/containers/gluetun/gluetun.config @@ -0,0 +1,96 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_GLUETUN_APP_NAME=gluetun +CFG_GLUETUN_BACKUP=true +CFG_GLUETUN_COMPOSE_FILE=default +CFG_GLUETUN_HEALTHCHECK=true +CFG_GLUETUN_AUTHELIA=false +CFG_GLUETUN_HEADSCALE=false +CFG_GLUETUN_MONITORING=false +# +# ============================================================================= +# APPLICATION CONFIGURATION +# ============================================================================= +# VPN_SERVICE_PROVIDER = VPN provider name (mullvad, nordvpn, protonvpn, surfshark, expressvpn, etc.) +# VPN_TYPE = wireguard or openvpn +# VPN_COUNTRIES = comma-separated country list (e.g. "Switzerland,Sweden") or empty for any +# OPENVPN_USER = OpenVPN account username (only when VPN_TYPE=openvpn) +# OPENVPN_PASSWORD = OpenVPN account password (only when VPN_TYPE=openvpn) +# WIREGUARD_PRIVATE_KEY = WireGuard private key (only when VPN_TYPE=wireguard) +# WIREGUARD_ADDRESSES = WireGuard interface address (e.g. "10.64.0.2/32") +# CONTROL_SERVER_API_KEY = API key for the gluetun HTTP control server, blank to disable auth +# +CFG_GLUETUN_VPN_SERVICE_PROVIDER=mullvad +CFG_GLUETUN_VPN_TYPE=wireguard +CFG_GLUETUN_VPN_COUNTRIES= +CFG_GLUETUN_OPENVPN_USER= +CFG_GLUETUN_OPENVPN_PASSWORD= +CFG_GLUETUN_WIREGUARD_PRIVATE_KEY= +CFG_GLUETUN_WIREGUARD_ADDRESSES= +CFG_GLUETUN_CONTROL_SERVER_API_KEY=RANDOMIZEDPASSWORD1 +# HEALTH_TARGETS = comma-separated host:port list pinged over HTTPS to +# confirm the VPN tunnel is healthy. Defaults are privacy-respecting +# (Mullvad — your VPN provider; EFF — privacy non-profit). Override +# with your own targets if you want to check different sites. +# HEALTH_ICMP_IPS = comma-separated IPv4 list pinged over ICMP for the +# small recurring health check. Default Quad9 (Swiss non-profit DNS, +# no logging). +# +CFG_GLUETUN_HEALTH_TARGETS="mullvad.net:443,eff.org:443" +CFG_GLUETUN_HEALTH_ICMP_IPS="9.9.9.9" +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GLUETUN_CATEGORY="networking,recommended" +CFG_GLUETUN_TITLE="Gluetun" +CFG_GLUETUN_DESCRIPTION="VPN Container Router" +CFG_GLUETUN_LONG_DESCRIPTION="Run all of your containers through a VPN provider. Supports 30+ providers over WireGuard and OpenVPN with a built-in kill-switch, DNS-over-TLS, port forwarding, and an HTTP control server." +CFG_GLUETUN_URL="https://github.com/qdm12/gluetun" +CFG_GLUETUN_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all +# +CFG_GLUETUN_DOMAIN=1 +CFG_GLUETUN_WHITELIST=false +CFG_GLUETUN_HOST_NAME=gluetun +CFG_GLUETUN_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, api, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GLUETUN_PORT_1="gluetun-service|control|random:8000|private|tcp|false|false|false|HTTP Server|" +CFG_GLUETUN_PORT_2="gluetun-exporter|metrics|8090:8090|disabled|tcp|false|false|false|Metrics Exporter (sidecar, docker-network only)|" diff --git a/containers/gluetun/gluetun.sh b/containers/gluetun/gluetun.sh new file mode 100644 index 0000000..bcf88c4 --- /dev/null +++ b/containers/gluetun/gluetun.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# Category : Networking +# Description : Gluetun - VPN client for routing other containers (c/u/s/r/i): + +installGluetun() +{ + local config_variables="$1" + + if [[ "$gluetun" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent gluetun; + local app_name=$CFG_GLUETUN_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$gluetun" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$gluetun" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$gluetun" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$gluetun" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$gluetun" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating the WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing Gluetun provider snapshot." + echo "" + + webuiGenerateGluetunProviders; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Re-attaching gluetun-routed apps (post-recreate)." + echo "" + + # Gluetun was just (re)created — every existing routed app holds a + # stale container ID in its network_mode. Reattach them now so the + # user doesn't have to chase silent netns drift later. + appGluetunRoutedRecreate + + ((menu_number++)) + echo "" + echo "---- $menu_number. Routing existing apps through Gluetun (optional)." + echo "" + + gluetunRouteExistingAppsPrompt; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + + menuShowFinalMessages "$app_name"; + + menu_number=0 + cd + fi + gluetun=n +} diff --git a/containers/gluetun/gluetun.svg b/containers/gluetun/gluetun.svg new file mode 100644 index 0000000..a39521c --- /dev/null +++ b/containers/gluetun/gluetun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json b/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json new file mode 100644 index 0000000..8c1b100 --- /dev/null +++ b/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json @@ -0,0 +1,67 @@ +{ + "annotations": { "list": [{ "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [{ "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } }, "type": "value" }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_up", "refId": "A" }], + "title": "VPN Tunnel", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_public_ip_info", "legendFormat": "{{country}} / {{public_ip}}", "refId": "A" }], + "title": "Public IP / Country", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "s" } }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "time() - gluetun_start_timestamp_seconds", "refId": "A" }], + "title": "Uptime", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false } } }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 4, + "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_up", "legendFormat": "up", "refId": "A" }], + "title": "Tunnel Health Over Time", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "gluetun"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Gluetun", + "uid": "libreportal-gluetun", + "version": 1, + "weekStart": "" +} diff --git a/containers/gluetun/resources/monitoring/prometheus-scrape.yml b/containers/gluetun/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..e5c6192 --- /dev/null +++ b/containers/gluetun/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: gluetun + metrics_path: /metrics + static_configs: + - targets: ['gluetun-exporter:PORT_INTERNAL_DATA_2'] #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 diff --git a/containers/grafana/docker-compose.yml b/containers/grafana/docker-compose.yml new file mode 100755 index 0000000..4329afe --- /dev/null +++ b/containers/grafana/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + grafana-service: #LIBREPORTAL|SERVICE_TAG_1|grafana-service + image: grafana/grafana-enterprise + container_name: grafana-service + restart: unless-stopped + environment: + - GF_SERVER_ROOT_URL:https://DOMAINSUBNAME_DATA/ #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - GF_INSTALL_PLUGINS=grafana-clock-panel + - GF_SERVER_HTTP_PORT:PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./grafana_storage:/var/lib/grafana + - ./provisioning:/etc/grafana/provisioning + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.grafana-service.entrypoints: web,websecure + traefik.http.routers.grafana-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.grafana-service.tls: true + traefik.http.routers.grafana-service.tls.certresolver: production + traefik.http.services.grafana-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.grafana-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/grafana/grafana.config b/containers/grafana/grafana.config new file mode 100755 index 0000000..47226b8 --- /dev/null +++ b/containers/grafana/grafana.config @@ -0,0 +1,67 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_GRAFANA_APP_NAME=grafana +CFG_GRAFANA_REQUIRES="prometheus" +CFG_GRAFANA_BACKUP=true +CFG_GRAFANA_COMPOSE_FILE=default +CFG_GRAFANA_HEALTHCHECK=true +CFG_GRAFANA_AUTHELIA=false +CFG_GRAFANA_HEADSCALE=false +CFG_GRAFANA_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GRAFANA_CATEGORY="monitoring" +CFG_GRAFANA_TITLE="Grafana" +CFG_GRAFANA_DESCRIPTION="Metrics Visualizer" +CFG_GRAFANA_LONG_DESCRIPTION="The open source analytics and monitoring solution for every database with beautiful dashboards and alerting capabilities" +CFG_GRAFANA_URL="https://github.com/grafana/grafana" +CFG_GRAFANA_ACTIONS="configure|install|restart|shutdown|uninstall" +CFG_GRAFANA_REQUIRES_SERVICE=prometheus +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_GRAFANA_DOMAIN=1 +CFG_GRAFANA_WHITELIST=false +CFG_GRAFANA_HOST_NAME=grafana +CFG_GRAFANA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GRAFANA_PORT_1="grafana-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" diff --git a/containers/grafana/grafana.sh b/containers/grafana/grafana.sh new file mode 100755 index 0000000..0be5b1b --- /dev/null +++ b/containers/grafana/grafana.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +# Category : Development & Version Control +# Description : Grafana - Metrics Visualizer (c/u/s/r/i): + +installGrafana() +{ + local config_variables="$1" + + if [[ "$grafana" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent grafana; + local app_name=$CFG_GRAFANA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$grafana" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$grafana" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$grafana" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$grafana" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$grafana" == *[iI]* ]]; then + isHeader "Install $app_name" + + if ! appInstallCheckRequirements "$app_name" "$CFG_GRAFANA_REQUIRES"; then + grafana=n + return 1 + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + # Grafana + if [ -d "${containers_dir}grafana/grafana_storage" ]; then + local result=$(sudo chmod -R 777 "${containers_dir}grafana/grafana_storage") + checkSuccess "Set permissions to grafana_storage folder." + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Provisioning monitoring dashboards from installed apps." + echo "" + + # Re-gather the Prometheus datasource + every monitoring-enabled app's + # dashboards into provisioning/ — so a fresh (or re-)install of Grafana + # picks up the apps that already had CFG__MONITORING=true. + # monitoringRefreshAll also covers Grafana's own scrape target when + # CFG_GRAFANA_MONITORING=true. + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + grafana=n +} diff --git a/containers/grafana/grafana.svg b/containers/grafana/grafana.svg new file mode 100755 index 0000000..54be1e2 --- /dev/null +++ b/containers/grafana/grafana.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json b/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json new file mode 100644 index 0000000..2ff8126 --- /dev/null +++ b/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json @@ -0,0 +1,233 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_dashboard", + "refId": "A" + } + ], + "title": "Total Dashboards", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_orgs", + "refId": "A" + } + ], + "title": "Total Orgs", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_users", + "refId": "A" + } + ], + "title": "Total Users", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "process_resident_memory_bytes{job=\"grafana\"}", + "refId": "A" + } + ], + "title": "Memory (RSS)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(grafana_http_request_duration_seconds_count[5m])", + "legendFormat": "{{method}} {{status_code}}", + "refId": "A" + } + ], + "title": "HTTP Request Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 4 }, + "id": 6, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(grafana_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "histogram_quantile(0.50, sum by (le) (rate(grafana_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50", + "refId": "B" + } + ], + "title": "HTTP Request Latency", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "grafana"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Grafana", + "uid": "libreportal-grafana", + "version": 1, + "weekStart": "" +} diff --git a/containers/grafana/resources/monitoring/prometheus-scrape.yml b/containers/grafana/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..8d1c7b4 --- /dev/null +++ b/containers/grafana/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: grafana + metrics_path: /metrics + static_configs: + - targets: ['grafana-service:3000'] diff --git a/containers/headscale/docker-compose.yml b/containers/headscale/docker-compose.yml new file mode 100755 index 0000000..ac5fc6f --- /dev/null +++ b/containers/headscale/docker-compose.yml @@ -0,0 +1,70 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + headscale-service: #LIBREPORTAL|SERVICE_TAG_1|headscale-service + container_name: headscale-service + image: headscale/headscale:latest + volumes: + - ./config:/etc/headscale/ + - ./data:/var/lib/headscale + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + command: headscale serve + restart: unless-stopped + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + headscale-webui-service: #LIBREPORTAL|SERVICE_TAG_2|headscale-webui-service + image: ghcr.io/ifargle/headscale-webui:latest + container_name: headscale-webui + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - PGID=1000 + - PUID=1000 + - COLOR=blue # Use the base colors (ie, no darken-3, etc) - + - HS_SERVER:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - DOMAIN_NAME:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SCRIPT_NAME=/admin # This is your applications base path (wsgi requires the name "SCRIPT_NAME"). Remove if you are hosing at the root / + - KEY="a-really-long-key-you-create-with-the-command-in-the-comment" # Generate with "openssl rand -base64 32" - used to encrypt your key on disk. + - AUTH_TYPE=Basic # AUTH_TYPE is either Basic or OIDC. Empty for no authentication + - LOG_LEVEL=info # Log level. "DEBUG", "ERROR", "WARNING", or "INFO". Default "INFO" + # ENV for Basic Auth (Used only if AUTH_TYPE is "Basic"). Can be omitted if you aren't using Basic Auth + - BASIC_AUTH_USER=libreportal # Used for basic auth + - BASIC_AUTH_PASS=HEADSCALE_BASIC_AUTH_PASS_DATA #LIBREPORTAL|HEADSCALE_BASIC_AUTH_PASS_TAG|HEADSCALE_BASIC_AUTH_PASS_DATA + # ENV for OIDC (Used only if AUTH_TYPE is "OIDC"). Can be omitted if you aren't using OIDC + #- OIDC_AUTH_URL=https://yourauthserver.com/application/o/headscale/.well-known/openid-configuration # URL for your OIDC issuer's well-known endpoint + #- OIDC_CLIENT_ID=your-auth-server-client-id-info-here # Your OIDC Issuer's Client ID for Headscale-WebUI + #- OIDC_CLIENT_SECRET=your-oidc-auth-server-client-secret-key-will-go-here-and-be-very-long-indeed # Your OIDC Issuer's Secret Key for Headscale-WebUI + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.headscale-webui-service.rule: Host(`admin.DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.services.headscale-webui-service.loadbalancer.server.port: PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + traefik.http.routers.headscale-webui-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ports: + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + volumes: + - ./volume:/data # Headscale-WebUI's storage. Make sure ./volume is readable by UID 1000 (chown 1000:1000 ./volume) + - ./config/:/etc/headscale/:ro # Headscale's config storage location. Used to read your Headscale config. + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/headscale/headscale.config b/containers/headscale/headscale.config new file mode 100755 index 0000000..234c0d2 --- /dev/null +++ b/containers/headscale/headscale.config @@ -0,0 +1,63 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# BASIC_AUTH_PASS = password for the headscale-ui basic auth; auto-generated, fed to the compose via HEADSCALE_BASIC_AUTH_PASS_TAG +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_HEADSCALE_APP_NAME=headscale +CFG_HEADSCALE_BACKUP=true +CFG_HEADSCALE_COMPOSE_FILE=default +CFG_HEADSCALE_HEALTHCHECK=true +CFG_HEADSCALE_BASIC_AUTH_PASS=RANDOMIZEDPASSWORD1 +CFG_HEADSCALE_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_HEADSCALE_CATEGORY="networking" +CFG_HEADSCALE_TITLE="Headscale" +CFG_HEADSCALE_DESCRIPTION="WireGuard VPN Controller" +CFG_HEADSCALE_LONG_DESCRIPTION="Headscale is an open source, self-hosted implementation of the Tailscale control server that works with the Tailscale client" +CFG_HEADSCALE_URL="https://github.com/juanfont/headscale" +CFG_HEADSCALE_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_HEADSCALE_DOMAIN=1 +CFG_HEADSCALE_WHITELIST=false +CFG_HEADSCALE_HOST_NAME=headscale +CFG_HEADSCALE_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_HEADSCALE_PORT_1="headscale-service|api|random:8080|private|tcp|false|false|false|Headscale API Server|" +CFG_HEADSCALE_PORT_2="headscale-webui-service|webui|random:5000|private|tcp|false|true|true|Web UI|" diff --git a/containers/headscale/headscale.sh b/containers/headscale/headscale.sh new file mode 100755 index 0000000..b837c3f --- /dev/null +++ b/containers/headscale/headscale.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# Category : Networking +# Description : Self-hosted WireGuard orchestrator (c/u/s/r/i): + +installHeadscale() +{ + local config_variables="$1" + + if [[ "$headscale" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent headscale; + local app_name=$CFG_HEADSCALE_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$headscale" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$headscale" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$headscale" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$headscale" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$headscale" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name/config) + checkSuccess "Create config folder" + + local result=$(copyResource "$app_name" "config.yaml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying config.yaml to config folder." + + configSetupFileWithData $app_name "config.yaml" "config"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up database records" + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + echo " NOTE - The password to login in defined in the yml install file that was installed" + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + headscale=n +} diff --git a/containers/headscale/headscale.svg b/containers/headscale/headscale.svg new file mode 100755 index 0000000..06f406a --- /dev/null +++ b/containers/headscale/headscale.svg @@ -0,0 +1 @@ + diff --git a/containers/headscale/resources/config.yaml b/containers/headscale/resources/config.yaml new file mode 100755 index 0000000..ce87482 --- /dev/null +++ b/containers/headscale/resources/config.yaml @@ -0,0 +1,327 @@ +--- +# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order: +# +# - `/etc/headscale` +# - `~/.headscale` +# - current working directory + +# The url clients will connect to. +# Typically this will be a domain like: +# +# https://myheadscale.example.com:443 +# +server_url: https://DOMAINSUBNAMEHERE + +# Address to listen to / bind to on the server +# +# For production: +# listen_addr: 0.0.0.0:8080 +listen_addr: 0.0.0.0:PORT1 + +# Address to listen to /metrics, you may want +# to keep this endpoint private to your internal +# network. Bound to the docker network only (no compose port mapping), so +# only sibling containers like Prometheus can reach it. +# +metrics_listen_addr: 0.0.0.0:9090 + +# Address to listen for gRPC. +# gRPC is used for controlling a headscale server +# remotely with the CLI +# Note: Remote access _only_ works if you have +# valid certificates. +# +# For production: +# grpc_listen_addr: 0.0.0.0:50443 +grpc_listen_addr: 127.0.0.1:50443 + +# Allow the gRPC admin interface to run in INSECURE +# mode. This is not recommended as the traffic will +# be unencrypted. Only enable if you know what you +# are doing. +grpc_allow_insecure: false + +# Private key used to encrypt the traffic between headscale +# and Tailscale clients. +# The private key file will be autogenerated if it's missing. +# +private_key_path: /var/lib/headscale/private.key + +# The Noise section includes specific configuration for the +# TS2021 Noise protocol +noise: + # The Noise private key is used to encrypt the + # traffic between headscale and Tailscale clients when + # using the new Noise-based protocol. It must be different + # from the legacy private key. + private_key_path: /var/lib/headscale/noise_private.key + +# List of IP prefixes to allocate tailaddresses from. +# Each prefix consists of either an IPv4 or IPv6 address, +# and the associated prefix length, delimited by a slash. +# It must be within IP ranges supported by the Tailscale +# client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. +# See below: +# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71 +# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33 +# Any other range is NOT supported, and it will cause unexpected issues. +ip_prefixes: + - 100.64.0.0/10 + - fd7a:115c:a1e0::/48 + +# DERP is a relay system that Tailscale uses when a direct +# connection cannot be established. +# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp +# +# headscale needs a list of DERP servers that can be presented +# to the clients. +derp: + server: + # If enabled, runs the embedded DERP server and merges it into the rest of the DERP config + # The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place + enabled: false + + # Region ID to use for the embedded DERP server. + # The local DERP prevails if the region ID collides with other region ID coming from + # the regular DERP config. + region_id: 999 + + # Region code and name are displayed in the Tailscale UI to identify a DERP region + region_code: "headscale" + region_name: "Headscale Embedded DERP" + + # Listens over UDP at the configured address for STUN connections - to help with NAT traversal. + # When the embedded DERP server is enabled stun_listen_addr MUST be defined. + # + # For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/ + stun_listen_addr: "0.0.0.0:3478" + + # List of externally available DERP maps encoded in JSON + urls: + - https://controlplane.tailscale.com/derpmap/default + + # Locally available DERP map files encoded in YAML + # + # This option is mostly interesting for people hosting + # their own DERP servers: + # https://tailscale.com/kb/1118/custom-derp-servers/ + # + # paths: + # - /etc/headscale/derp-example.yaml + paths: [] + + # If enabled, a worker will be set up to periodically + # refresh the given sources and update the derpmap + # will be set up. + auto_update_enabled: true + + # How often should we check for DERP updates? + update_frequency: 24h + +# Disables the automatic check for headscale updates on startup +disable_check_updates: true + +# Time before an inactive ephemeral node is deleted? +ephemeral_node_inactivity_timeout: 30m + +# Period to check for node updates within the tailnet. A value too low will severely affect +# CPU consumption of Headscale. A value too high (over 60s) will cause problems +# for the nodes, as they won't get updates or keep alive messages frequently enough. +# In case of doubts, do not touch the default 10s. +node_update_check_interval: 10s + +# SQLite config +db_type: sqlite3 + +# For production: +db_path: /var/lib/headscale/db.sqlite + +# # Postgres config +# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank. +# db_type: postgres +# db_host: localhost +# db_port: 5432 +# db_name: headscale +# db_user: foo +# db_pass: bar + +# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need +# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1. +# db_ssl: false + +### TLS configuration +# +## Let's encrypt / ACME +# +# headscale supports automatically requesting and setting up +# TLS for a domain with Let's Encrypt. +# +# URL to ACME directory +acme_url: https://acme-v02.api.letsencrypt.org/directory + +# Email to register with ACME provider +acme_email: "" + +# Domain name to request a TLS certificate for: +tls_letsencrypt_hostname: "" + +# Path to store certificates and metadata needed by +# letsencrypt +# For production: +tls_letsencrypt_cache_dir: /var/lib/headscale/cache + +# Type of ACME challenge to use, currently supported types: +# HTTP-01 or TLS-ALPN-01 +# See [docs/tls.md](docs/tls.md) for more information +tls_letsencrypt_challenge_type: HTTP-01 +# When HTTP-01 challenge is chosen, letsencrypt must set up a +# verification endpoint, and it will be listening on: +# :http = port 80 +tls_letsencrypt_listen: ":http" + +## Use already defined certificates: +tls_cert_path: "" +tls_key_path: "" + +log: + # Output formatting for logs: text or json + format: text + level: info + +# Path to a file containg ACL policies. +# ACLs can be defined as YAML or HUJSON. +# https://tailscale.com/kb/1018/acls/ +acl_policy_path: "" + +## DNS +# +# headscale supports Tailscale's DNS configuration and MagicDNS. +# Please have a look to their KB to better understand the concepts: +# +# - https://tailscale.com/kb/1054/dns/ +# - https://tailscale.com/kb/1081/magicdns/ +# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ +# +dns_config: + # Whether to prefer using Headscale provided DNS or use local. + override_local_dns: true + + # List of DNS servers to expose to clients. + nameservers: + - 9.9.9.9 + + # NextDNS (see https://tailscale.com/kb/1218/nextdns/). + # "abc123" is example NextDNS ID, replace with yours. + # + # With metadata sharing: + # nameservers: + # - https://dns.nextdns.io/abc123 + # + # Without metadata sharing: + # nameservers: + # - 2a07:a8c0::ab:c123 + # - 2a07:a8c1::ab:c123 + + # Split DNS (see https://tailscale.com/kb/1054/dns/), + # list of search domains and the DNS to query for each one. + # + # restricted_nameservers: + # foo.bar.com: + # - 1.1.1.1 + # darp.headscale.net: + # - 1.1.1.1 + # - 8.8.8.8 + + # Search domains to inject. + domains: [] + + # Extra DNS records + # so far only A-records are supported (on the tailscale side) + # See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations + # extra_records: + # - name: "grafana.myvpn.example.com" + # type: "A" + # value: "100.64.0.3" + # + # # you can also put it in one line + # - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" } + + # Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + # Only works if there is at least a nameserver defined. + magic_dns: true + + # Defines the base domain to create the hostnames for MagicDNS. + # `base_domain` must be a FQDNs, without the trailing dot. + # The FQDN of the hosts will be + # `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_). + base_domain: DOMAINSUBNAMEHERE + +# Unix socket used for the CLI to connect without authentication +# Note: for production you will want to set this to something like: +unix_socket: /var/run/headscale/headscale.sock +unix_socket_permission: "0770" +# +# headscale supports experimental OpenID connect support, +# it is still being tested and might have some bugs, please +# help us test it. +# OpenID Connect +# oidc: +# only_start_if_oidc_is_available: true +# issuer: "https://your-oidc.issuer.com/path" +# client_id: "your-oidc-client-id" +# client_secret: "your-oidc-client-secret" +# # Alternatively, set `client_secret_path` to read the secret from the file. +# # It resolves environment variables, making integration to systemd's +# # `LoadCredential` straightforward: +# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" +# # client_secret and client_secret_path are mutually exclusive. +# +# # The amount of time from a node is authenticated with OpenID until it +# # expires and needs to reauthenticate. +# # Setting the value to "0" will mean no expiry. +# expiry: 180d +# +# # Use the expiry from the token received from OpenID when the user logged +# # in, this will typically lead to frequent need to reauthenticate and should +# # only been enabled if you know what you are doing. +# # Note: enabling this will cause `oidc.expiry` to be ignored. +# use_expiry_from_token: false +# +# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query +# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". +# +# scope: ["openid", "profile", "email", "custom"] +# extra_params: +# domain_hint: example.com +# +# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the +# # authentication request will be rejected. +# +# allowed_domains: +# - example.com +# # Note: Groups from keycloak have a leading '/' +# allowed_groups: +# - /headscale +# allowed_users: +# - alice@example.com +# +# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. +# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` +# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following +# user: `first-name.last-name.example.com` +# +# strip_email_domain: true + +# Logtail configuration +# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel +# to instruct tailscale nodes to log their activity to a remote server. +logtail: + # Enable logtail for this headscales clients. + # As there is currently no support for overriding the log server in headscale, this is + # disabled by default. Enabling this will make your clients send logs to Tailscale Inc. + enabled: false + +# Enabling this option makes devices prefer a random port for WireGuard traffic over the +# default static port 41641. This option is intended as a workaround for some buggy +# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. +randomize_client_port: false diff --git a/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json b/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json new file mode 100644 index 0000000..982e45f --- /dev/null +++ b/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json @@ -0,0 +1,200 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "sum(headscale_machine_registrations_total)", + "refId": "A" + } + ], + "title": "Total Machine Registrations", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "headscale_machines_active_total", + "refId": "A" + } + ], + "title": "Active Machines", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "process_resident_memory_bytes{job=\"headscale\"}", + "refId": "A" + } + ], + "title": "Memory (RSS)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "go_goroutines{job=\"headscale\"}", + "refId": "A" + } + ], + "title": "Goroutines", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(headscale_node_update_count_total[5m])", + "legendFormat": "node updates/s", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(headscale_grpc_requests_total[5m])", + "legendFormat": "grpc {{method}} /s", + "refId": "B" + } + ], + "title": "Activity", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "headscale"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Headscale", + "uid": "libreportal-headscale", + "version": 1, + "weekStart": "" +} diff --git a/containers/headscale/resources/monitoring/prometheus-scrape.yml b/containers/headscale/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..cb0bd82 --- /dev/null +++ b/containers/headscale/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: headscale + metrics_path: /metrics + static_configs: + - targets: ['headscale-service:9090'] diff --git a/containers/invidious/docker-compose.yml b/containers/invidious/docker-compose.yml new file mode 100644 index 0000000..43e11ab --- /dev/null +++ b/containers/invidious/docker-compose.yml @@ -0,0 +1,76 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + invidious-service: #LIBREPORTAL|SERVICE_TAG_1|invidious-service + container_name: invidious-service + image: quay.io/invidious/invidious:latest + restart: unless-stopped + depends_on: + - invidious-db + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + INVIDIOUS_CONFIG: | + db: + dbname: invidious + user: kemal + password: kemal + host: invidious-db + port: 5432 + check_tables: true + # external_port: + # domain: + # https_only: false + # statistics_enabled: false + hmac_key: "INVIDIOUS_HMAC_KEY_DATA" #LIBREPORTAL|INVIDIOUS_HMAC_KEY_TAG|INVIDIOUS_HMAC_KEY_DATA + healthcheck: + test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 + interval: 30s + timeout: 5s + retries: 2 + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + logging: + options: + max-size: "1G" + max-file: "4" + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.invidious-service.entrypoints: web,websecure + traefik.http.routers.invidious-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.invidious-service.tls: true + traefik.http.routers.invidious-service.tls.certresolver: production + traefik.http.services.invidious-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.invidious-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + invidious-db: #LIBREPORTAL|SERVICE_TAG_2|invidious-db + container_name: invidious-db + image: docker.io/library/postgres:14 + restart: unless-stopped + volumes: + - ./postgresdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: invidious + POSTGRES_USER: kemal + POSTGRES_PASSWORD: kemal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/invidious/invidious.config b/containers/invidious/invidious.config new file mode 100755 index 0000000..8522012 --- /dev/null +++ b/containers/invidious/invidious.config @@ -0,0 +1,71 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_INVIDIOUS_APP_NAME=invidious +CFG_INVIDIOUS_BACKUP=false +CFG_INVIDIOUS_COMPOSE_FILE=default +CFG_INVIDIOUS_HEALTHCHECK=false +CFG_INVIDIOUS_AUTHELIA=false +CFG_INVIDIOUS_HEADSCALE=false +# HMAC_KEY = signs Invidious tokens/links; auto-generated, fed to the compose +# via the INVIDIOUS_HMAC_KEY_TAG tag (preserved across reinstalls) +CFG_INVIDIOUS_HMAC_KEY=RANDOMIZEDPASSWORD1 +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_INVIDIOUS_CATEGORY="media" +CFG_INVIDIOUS_TITLE="Invidious" +CFG_INVIDIOUS_DESCRIPTION="YouTube Frontend" +CFG_INVIDIOUS_LONG_DESCRIPTION="Invidious is an alternative front-end to YouTube that focuses on privacy and providing a distraction-free viewing experience" +CFG_INVIDIOUS_URL="https://github.com/iv-org/invidious" +CFG_INVIDIOUS_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_INVIDIOUS_DOMAIN=1 +CFG_INVIDIOUS_WHITELIST=false +CFG_INVIDIOUS_HOST_NAME=invidious +CFG_INVIDIOUS_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_INVIDIOUS_PORT_1="invidious-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_INVIDIOUS_AUTH_PROFILE=multi_user +CFG_INVIDIOUS_ADMIN_USER= +CFG_INVIDIOUS_ADMIN_EMAIL= +CFG_INVIDIOUS_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/invidious/invidious.sh b/containers/invidious/invidious.sh new file mode 100755 index 0000000..8ead100 --- /dev/null +++ b/containers/invidious/invidious.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Category : Media & Streaming +# Description : Invidious - Privacy-focused YouTube Frontend (c/u/s/r/i/t): + +installInvidious() +{ + local config_variables="$1" + + if [[ "$invidious" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent invidious; + local app_name=$CFG_INVIDIOUS_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$invidious" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$invidious" == *[tT]* ]]; then + invidiousToolsMenu; + fi + + if [[ "$invidious" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$invidious" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$invidious" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$invidious" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + invidious=n +} diff --git a/containers/invidious/invidious.svg b/containers/invidious/invidious.svg new file mode 100755 index 0000000..80e78a4 --- /dev/null +++ b/containers/invidious/invidious.svg @@ -0,0 +1,2 @@ + + diff --git a/containers/ipinfo/docker-compose.yml b/containers/ipinfo/docker-compose.yml new file mode 100755 index 0000000..25c785c --- /dev/null +++ b/containers/ipinfo/docker-compose.yml @@ -0,0 +1,38 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + ipinfo-service: #LIBREPORTAL|SERVICE_TAG_1|ipinfo-service + container_name: ipinfo-service + image: peterdavehello/ipinfo.tw:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.ipinfo-service.entrypoints: web,websecure + traefik.http.routers.ipinfo-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.ipinfo-service.tls: true + traefik.http.routers.ipinfo-service.tls.certresolver: production + traefik.http.services.ipinfo-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.ipinfo-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/ipinfo/ipinfo.config b/containers/ipinfo/ipinfo.config new file mode 100755 index 0000000..9c0be28 --- /dev/null +++ b/containers/ipinfo/ipinfo.config @@ -0,0 +1,62 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_IPINFO_APP_NAME=ipinfo +CFG_IPINFO_BACKUP=false +CFG_IPINFO_COMPOSE_FILE=default +CFG_IPINFO_HEALTHCHECK=true +CFG_IPINFO_AUTHELIA=false +CFG_IPINFO_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_IPINFO_CATEGORY="networking" +CFG_IPINFO_TITLE="IPinfo" +CFG_IPINFO_DESCRIPTION="IP Information" +CFG_IPINFO_LONG_DESCRIPTION="IPinfo is a simple IP address lookup service that provides detailed information about IP addresses including geolocation and ISP data" +CFG_IPINFO_URL="https://github.com/ipinfo/cli" +CFG_IPINFO_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_IPINFO_DOMAIN=1 +CFG_IPINFO_WHITELIST=false +CFG_IPINFO_HOST_NAME=ipinfo +CFG_IPINFO_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_IPINFO_PORT_1="ipinfo-service|webui|random:8080|public|tcp|false|true|true|Web Interface|" diff --git a/containers/ipinfo/ipinfo.sh b/containers/ipinfo/ipinfo.sh new file mode 100755 index 0000000..51d2416 --- /dev/null +++ b/containers/ipinfo/ipinfo.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Category : Networking +# Description : IPinfo - IP Geolocation and Information (c/u/s/r/i): + +installIpinfo() +{ + local config_variables="$1" + + if [[ "$ipinfo" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent ipinfo; + local app_name=$CFG_IPINFO_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$ipinfo" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$ipinfo" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$ipinfo" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$ipinfo" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$ipinfo" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + ipinfo=n +} diff --git a/containers/ipinfo/ipinfo.svg b/containers/ipinfo/ipinfo.svg new file mode 100755 index 0000000..656169c --- /dev/null +++ b/containers/ipinfo/ipinfo.svg @@ -0,0 +1 @@ + diff --git a/containers/jellyfin/docker-compose.yml b/containers/jellyfin/docker-compose.yml new file mode 100755 index 0000000..0827078 --- /dev/null +++ b/containers/jellyfin/docker-compose.yml @@ -0,0 +1,41 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + jellyfin-service: #LIBREPORTAL|SERVICE_TAG_1|jellyfin-service + image: jellyfin/jellyfin + container_name: jellyfin-service + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./config:/config + - ./cache:/cache + - ./media:/media + - ./media2:/media2:ro + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.jellyfin-service.entrypoints: web,websecure + traefik.http.routers.jellyfin-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.jellyfin-service.tls: true + traefik.http.routers.jellyfin-service.tls.certresolver: production + traefik.http.services.jellyfin-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.jellyfin-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + restart: 'unless-stopped' + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/jellyfin/jellyfin.config b/containers/jellyfin/jellyfin.config new file mode 100755 index 0000000..99947cd --- /dev/null +++ b/containers/jellyfin/jellyfin.config @@ -0,0 +1,62 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_JELLYFIN_APP_NAME=jellyfin +CFG_JELLYFIN_BACKUP=true +CFG_JELLYFIN_COMPOSE_FILE=default +CFG_JELLYFIN_HEALTHCHECK=true +CFG_JELLYFIN_AUTHELIA=false +CFG_JELLYFIN_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_JELLYFIN_CATEGORY="media" +CFG_JELLYFIN_TITLE="Jellyfin" +CFG_JELLYFIN_DESCRIPTION="Media Server" +CFG_JELLYFIN_LONG_DESCRIPTION="Jellyfin is a free software media system that puts you in control of managing and streaming your media without any locked subscriptions" +CFG_JELLYFIN_URL="https://github.com/jellyfin/jellyfin" +CFG_JELLYFIN_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_JELLYFIN_DOMAIN=1 +CFG_JELLYFIN_WHITELIST=false +CFG_JELLYFIN_HOST_NAME=jellyfin +CFG_JELLYFIN_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_JELLYFIN_PORT_1="jellyfin-service|webui|random:8096|public|tcp|false|true|true|Media Server|" diff --git a/containers/jellyfin/jellyfin.sh b/containers/jellyfin/jellyfin.sh new file mode 100755 index 0000000..bf65624 --- /dev/null +++ b/containers/jellyfin/jellyfin.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# Category : Media & Streaming +# Description : Jellyfin - Media Server (c/u/s/r/i): + +installJellyfin() +{ + local config_variables="$1" + + if [[ "$jellyfin" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent jellyfin; + local app_name=$CFG_JELLYFIN_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$jellyfin" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$jellyfin" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$jellyfin" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$jellyfin" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$jellyfin" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Pulling a default Jellyfin docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start Jellyfin" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + jellyfin=n +} diff --git a/containers/jellyfin/jellyfin.svg b/containers/jellyfin/jellyfin.svg new file mode 100755 index 0000000..0e56a50 --- /dev/null +++ b/containers/jellyfin/jellyfin.svg @@ -0,0 +1 @@ + diff --git a/containers/jitsimeet/docker-compose.yml b/containers/jitsimeet/docker-compose.yml new file mode 100644 index 0000000..cf05fe5 --- /dev/null +++ b/containers/jitsimeet/docker-compose.yml @@ -0,0 +1,125 @@ + +networks: + meet.jitsi: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + # Frontend + jitsimeet-service: #LIBREPORTAL|SERVICE_TAG_1|jitsimeet-service + container_name: jitsimeet-service + image: jitsi/web:stable + volumes: + - ${CONFIG}/web:/config + - ${CONFIG}/transcripts:/usr/share/jitsi-meet/transcripts + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - DISABLE_HTTPS + - PUBLIC_URL:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - XMPP_DOMAIN:meet.jitsi + - ENABLE_GUESTS + - ENABLE_P2P + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.jitsimeet-service.entrypoints: web,websecure + traefik.http.routers.jitsimeet-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.jitsimeet-service.tls: true + traefik.http.routers.jitsimeet-service.tls.certresolver: production + traefik.http.services.jitsimeet-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.jitsimeet-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + meet.jitsi: + aliases: + - meet.jitsi + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + + # XMPP server + jitsimeet-prosody: #LIBREPORTAL|SERVICE_TAG_2|jitsimeet-prosody + image: jitsi/prosody:stable + container_name: jitsimeet-prosody + expose: + - '5222' + - '5347' + - '5280' + volumes: + - ${CONFIG}/prosody:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - AUTH_TYPE:internal + - ENABLE_GUESTS + - ENABLE_LOBBY + - XMPP_DOMAIN:meet.jitsi + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_MUC_DOMAIN:muc.meet.jitsi + - JICOFO_COMPONENT_SECRET + - JICOFO_AUTH_USER + - JICOFO_AUTH_PASSWORD + - JVB_AUTH_USER + - JVB_AUTH_PASSWORD + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 + meet.jitsi: + aliases: + - jitsimeet-prosody + + # Focus component + jitsimeet-jicofo: #LIBREPORTAL|SERVICE_TAG_3|jitsimeet-jicofo + image: jitsi/jicofo:stable + container_name: jitsimeet-jicofo + volumes: + - ${CONFIG}/jicofo:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - ENABLE_CODEC_VP8 + - ENABLE_CODEC_VP9 + - ENABLE_CODEC_H264 + - JICOFO_COMPONENT_SECRET + - JICOFO_AUTH_USER + - JICOFO_AUTH_PASSWORD + - XMPP_DOMAIN:meet.jitsi + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_MUC_DOMAIN:muc.meet.jitsi + - XMPP_SERVER:jitsimeet-prosody + depends_on: + - jitsimeet-prosody + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3 + meet.jitsi: + + # Video bridge + jitsimeet-jvb: #LIBREPORTAL|SERVICE_TAG_4|jitsimeet-jvb + image: jitsi/jvb:stable + container_name: jitsimeet-jvb + ports: + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + - "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3 + volumes: + - ${CONFIG}/jvb:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN:internal-muc.meet.jitsi + - XMPP_SERVER:jitsimeet-prosody + - JVB_AUTH_USER + - JVB_AUTH_PASSWORD + - JVB_BREWERY_MUC:jvbbrewery + - JVB_PORT:PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - JVB_TCP_PORT:PORT_INTERNAL_DATA_3 #LIBREPORTAL|PORT_INTERNAL_TAG_3|PORT_INTERNAL_DATA_3 + - PUBLIC_URL:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + depends_on: + - jitsimeet-prosody + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_4 #LIBREPORTAL|IP_TAG_4|IP_DATA_4 + meet.jitsi: diff --git a/containers/jitsimeet/jitsimeet.config b/containers/jitsimeet/jitsimeet.config new file mode 100755 index 0000000..497cfb5 --- /dev/null +++ b/containers/jitsimeet/jitsimeet.config @@ -0,0 +1,64 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_JITSIMEET_APP_NAME=jitsimeet +CFG_JITSIMEET_BACKUP=true +CFG_JITSIMEET_COMPOSE_FILE=default +CFG_JITSIMEET_HEALTHCHECK=true +CFG_JITSIMEET_AUTHELIA=false +CFG_JITSIMEET_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_JITSIMEET_CATEGORY="communication" +CFG_JITSIMEET_TITLE="Jitsi Meet" +CFG_JITSIMEET_DESCRIPTION="Video Conferencing" +CFG_JITSIMEET_LONG_DESCRIPTION="Jitsi Meet is an open-source video conferencing solution that provides secure, easy, and high-quality video meetings for everyone" +CFG_JITSIMEET_URL="https://github.com/jitsi/jitsi-meet" +CFG_JITSIMEET_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_JITSIMEET_DOMAIN=1 +CFG_JITSIMEET_WHITELIST=false +CFG_JITSIMEET_HOST_NAME=meet +CFG_JITSIMEET_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_JITSIMEET_PORT_1="jitsimeet-service|webui|random:80|public|tcp|false|true|true|Web Interface|" +CFG_JITSIMEET_PORT_2="jitsimeet-jvb|video-bridge|random:10000|public|udp|false|false|false|Jitsi Video Bridge (UDP)|" +CFG_JITSIMEET_PORT_3="jitsimeet-jvb|video-tcp|random:30300|public|tcp|false|false|false|Jitsi Video Bridge (TCP)|" diff --git a/containers/jitsimeet/jitsimeet.sh b/containers/jitsimeet/jitsimeet.sh new file mode 100755 index 0000000..c1dadc2 --- /dev/null +++ b/containers/jitsimeet/jitsimeet.sh @@ -0,0 +1,202 @@ +#!/bin/bash + +# Category : Communication & Collaboration Tools +# Description : Jitsi Meet - Video Conferencing *UNFINISHED* (c/u/s/r/i): + +installJitsimeet() +{ + local config_variables="$1" + + if [[ "$jitsimeet" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent jitsimeet; + local app_name=$CFG_JITSIMEET_APP_NAME + git_url=$CFG_JITSIMEET_GIT + initializeAppVariables $app_name; + fi + + if [[ "$jitsimeet" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$jitsimeet" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$jitsimeet" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$jitsimeet" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$jitsimeet" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Downloading latest GitHub release" + echo "" + + latest_tag=$(git ls-remote --refs --sort="version:refname" --tags $git_url | cut -d/ -f3- | tail -n1) + echo "The latest tag is: $latest_tag" + + local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name) + checkSuccess "Creating $app_name container installation folder" + local result=$(cd $containers_dir$app_name && sudo rm -rf $containers_dir$app_name/$latest_tag.zip) + checkSuccess "Deleting zip file to prevent conflicts" + local result=$(createTouch $containers_dir$app_name/$latest_tag.txt $docker_install_user && echo 'Installed "$latest_tag" on "$backupDate"!' > $latest_tag.txt) + checkSuccess "Create logging txt file" + + + # Download files and unzip + local result=$(sudo wget -O $containers_dir$app_name/$latest_tag.zip $git_url/archive/refs/tags/$latest_tag.zip) + checkSuccess "Downloading tagged zip file from GitHub" + local result=$(sudo unzip -o $containers_dir$app_name/$latest_tag.zip -d $containers_dir$app_name) + checkSuccess "Unzip downloaded file" + local result=$(sudo mv $containers_dir$app_name/docker-jitsi-meet-$latest_tag/* $containers_dir$app_name) + checkSuccess "Moving all files from zip file to install directory" + local result=$(sudo rm -rf $containers_dir$app_name/$latest_tag.zip && sudo rm -rf $containers_dir$app_name/$latest_tag/) + checkSuccess "Removing downloaded zip file as no longer needed" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up .env file for setup" + echo "" + + dockerSetupEnvFile; + + # Updating custom .env values + local result=$(sudo sed -i "s|CONFIG=~/.jitsi-meet-cfg|CONFIG=$containers_dir$app_name/.jitsi-meet-cfg|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with new install path" + + local result=$(sudo sed -i "s|#PUBLIC_URL=https://meet.example.com|PUBLIC_URL=https://$host_setup|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with Public URL to $host_setup" + + local result=$(sudo sed -i "s|HTTP_PORT=8000|HTTP_PORT=$usedport1|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with HTTP_PORT to $usedport1" + + local result=$(sudo sed -i "s|HTTPS_PORT=8443|HTTPS_PORT=$usedport2|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with HTTP_PORT to $usedport2" + + #local result=$(echo "ENABLE_HTTP_REDIRECT=1" | sudo tee -a "$containers_dir$app_name/.env") + #checkSuccess "Updating .env file with option : ENABLE_HTTP_REDIRECT" + + # Values are missing from the .env by default for some reason + # https://github.com/jitsi/docker-jitsi-meet/commit/12051700562d9826f9e024ad649c4dd9b88f94de#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5 + local result=$(echo "XMPP_DOMAIN=meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : XMPP_DOMAIN" + + local result=$(echo "XMPP_SERVER=xmpp.meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : XMPP_SERVER" + + local result=$(echo "JVB_PORT=$usedport4" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_PORT" + + local result=$(echo "JVB_TCP_MAPPED_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_TCP_MAPPED_PORT" + + local result=$(echo "JVB_TCP_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_TCP_PORT" + + local result=$(cd "$containers_dir$app_name" && sudo ./gen-passwords.sh) + checkSuccess "Running Jitsi Meet gen-passwords.sh script" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adjusting $app_name docker system files for port changes." + echo "" + + #dockerCommandRun "docker exec -it $app_name /bin/bash && cd /" + + #local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_nameweb/default) + #checkSuccess "Updating Docker NGINX default site port 80 to $usedport1" + + #local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_nameweb/default) + #checkSuccess "Updating Docker NGINX default site port 443 to $usedport2" + + local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_name/web/rootfs/defaults/default) + checkSuccess "Updating NGINX default site port 80 to $usedport1" + + local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_name/web/rootfs/defaults/default) + checkSuccess "Updating NGINX default site port 443 to $usedport2" + + #dockerCommandRun "docker cp '$containers_dir$app_name' '$app_name:/etc/nginx/sites-available/default'" + dockerComposeRestart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + jitsimeet=n +} diff --git a/containers/jitsimeet/jitsimeet.svg b/containers/jitsimeet/jitsimeet.svg new file mode 100755 index 0000000..5a3526a --- /dev/null +++ b/containers/jitsimeet/jitsimeet.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/.dockerignore b/containers/libreportal/.dockerignore new file mode 100644 index 0000000..c328db4 --- /dev/null +++ b/containers/libreportal/.dockerignore @@ -0,0 +1,10 @@ +backend/node_modules +backend/npm-debug.log +frontend +libreportal.config +libreportal.sh +libreportal.svg +docker-compose.yml +.git +.gitignore +*.md diff --git a/containers/libreportal/Dockerfile b/containers/libreportal/Dockerfile new file mode 100755 index 0000000..f79c96b --- /dev/null +++ b/containers/libreportal/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY ./backend/package.json ./backend/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +COPY ./backend/server.js ./backend/server.js +COPY ./backend/utils ./backend/utils/ +COPY ./backend/routes ./backend/routes/ + +EXPOSE 1111 + +CMD ["node", "backend/server.js"] diff --git a/containers/libreportal/backend/package-lock.json b/containers/libreportal/backend/package-lock.json new file mode 100755 index 0000000..73125ca --- /dev/null +++ b/containers/libreportal/backend/package-lock.json @@ -0,0 +1,951 @@ +{ + "name": "libreportal-web-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libreportal-web-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.17.1", + "jsonwebtoken": "^9.0.2", + "ssh2": "^0.8.9" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ssh2": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz", + "integrity": "sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw==", + "dependencies": { + "ssh2-streams": "~0.4.10" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/ssh2-streams": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz", + "integrity": "sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ==", + "dependencies": { + "asn1": "~0.2.0", + "bcrypt-pbkdf": "^1.0.2", + "streamsearch": "~0.1.2" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/containers/libreportal/backend/package.json b/containers/libreportal/backend/package.json new file mode 100755 index 0000000..81d76e2 --- /dev/null +++ b/containers/libreportal/backend/package.json @@ -0,0 +1,12 @@ +{ + "name": "libreportal-web-backend", + "version": "1.0.0", + "main": "server.js", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.17.1", + "jsonwebtoken": "^9.0.2", + "ssh2": "^0.8.9" + } +} diff --git a/containers/libreportal/backend/routes/auth-routes.js b/containers/libreportal/backend/routes/auth-routes.js new file mode 100755 index 0000000..14842fa --- /dev/null +++ b/containers/libreportal/backend/routes/auth-routes.js @@ -0,0 +1,92 @@ +const express = require('express'); +const router = express.Router(); +const { generateToken, verifyToken, verifyPassword, getCredentials } = require('../utils/auth.js'); + +const COOKIE_NAME = 'libreportal_token'; +const COOKIE_OPTS = { + httpOnly: true, + sameSite: 'strict', + maxAge: 30 * 24 * 60 * 60 * 1000 +}; + +// Per-IP login rate limit: 10 attempts per 15 minutes. Lockout doubles each subsequent +// trip so a sustained attacker hits exponentially long waits. +const LOGIN_WINDOW_MS = 15 * 60 * 1000; +const LOGIN_MAX_ATTEMPTS = 10; +const loginAttempts = new Map(); // ip -> { count, firstAttempt, lockedUntil } + +function checkLoginRate(ip) { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry) return { allowed: true }; + if (entry.lockedUntil && now < entry.lockedUntil) { + return { allowed: false, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; + } + if (now - entry.firstAttempt > LOGIN_WINDOW_MS) { + loginAttempts.delete(ip); + } + return { allowed: true }; +} + +function recordFailedLogin(ip) { + const now = Date.now(); + const entry = loginAttempts.get(ip) || { count: 0, firstAttempt: now, lockedUntil: 0 }; + entry.count += 1; + if (entry.count >= LOGIN_MAX_ATTEMPTS) { + const prevLockMs = entry.lockedUntil ? entry.lockedUntil - entry.firstAttempt : 0; + const lockMs = Math.max(LOGIN_WINDOW_MS, prevLockMs * 2); + entry.lockedUntil = now + lockMs; + } + loginAttempts.set(ip, entry); +} + +function clearLoginAttempts(ip) { + loginAttempts.delete(ip); +} + +router.post('/login', async (req, res) => { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const rate = checkLoginRate(ip); + if (!rate.allowed) { + res.setHeader('Retry-After', rate.retryAfter); + return res.status(429).json({ error: 'Too many login attempts. Try again later.' }); + } + + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + try { + const creds = getCredentials(); + const usernameMatch = username === creds.username; + const passwordMatch = await verifyPassword(password, creds.passwordHash); + if (!usernameMatch || !passwordMatch) { + recordFailedLogin(ip); + // Constant-time delay to prevent timing attacks + await new Promise(r => setTimeout(r, 500)); + return res.status(401).json({ error: 'Invalid credentials' }); + } + clearLoginAttempts(ip); + const token = generateToken(username); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.json({ success: true, username }); + } catch (error) { + console.error('[Auth] Login error:', error.message); + res.status(500).json({ error: 'Internal error' }); + } +}); + +router.post('/logout', (req, res) => { + res.clearCookie(COOKIE_NAME); + res.json({ success: true }); +}); + +router.get('/status', (req, res) => { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) return res.json({ authenticated: false }); + const payload = verifyToken(token); + if (!payload) return res.json({ authenticated: false }); + res.json({ authenticated: true, username: payload.sub }); +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/routes.js b/containers/libreportal/backend/routes/routes.js new file mode 100755 index 0000000..b0ba12f --- /dev/null +++ b/containers/libreportal/backend/routes/routes.js @@ -0,0 +1,224 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const config = require('../utils/config.js'); +const { requireAuth } = require('../utils/middleware.js'); + +const PATHS = { + FRONTEND_DATA: path.join(__dirname, '../../frontend/data'), + BASE_DIR: path.join(__dirname, '../../frontend/data') +}; + +const themeRoutes = require('./theme.js'); +const themesRoutes = require('./themes.js'); +const authRoutes = require('./auth-routes.js'); +const taskRoutes = require('./task-routes.js'); +const serviceRoutes = require('./service-routes.js'); +const setupRoutes = require('./setup-routes.js'); +const { testConnection } = require('../utils/mail.js'); + +module.exports = { + setup: (app) => { + // Auth routes — public (no requireAuth) + app.use('/api/auth', authRoutes); + // Theme discovery is public so the login overlay can pick the right + // palette before the user logs in. + app.use('/api/themes', themesRoutes); + + // Protected API routes + app.use('/api/theme', requireAuth, themeRoutes); + app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside + app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside + app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside + app.post('/api/test-mail-connection', requireAuth, testConnection); + + app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => { + try { + const account = String(req.body?.accountNumber || '').replace(/\s+/g, ''); + if (!/^\d{16}$/.test(account)) { + return res.status(400).json({ success: false, error: 'Account number must be 16 digits.' }); + } + + const cryptoMod = require('crypto'); + const kp = cryptoMod.generateKeyPairSync('x25519'); + const pkcs8 = kp.privateKey.export({ format: 'der', type: 'pkcs8' }); + const spki = kp.publicKey.export({ format: 'der', type: 'spki' }); + const privateKey = pkcs8.subarray(pkcs8.length - 32).toString('base64'); + const publicKey = spki.subarray(spki.length - 32).toString('base64'); + + const body = new URLSearchParams({ account, pubkey: publicKey }).toString(); + const upstream = await fetch('https://api.mullvad.net/wg/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + const text = (await upstream.text()).trim(); + + if (!upstream.ok || !text) { + return res.status(502).json({ + success: false, + error: text || `Mullvad API returned ${upstream.status}.` + }); + } + if (/account/i.test(text) && /(invalid|not.*found|expired)/i.test(text)) { + return res.status(400).json({ success: false, error: text }); + } + + const ipv4Only = text + .split(',') + .map((s) => s.trim()) + .filter((s) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s)) + .join(','); + + res.json({ + success: true, + privateKey, + publicKey, + addresses: ipv4Only || text + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + app.post('/write-file', requireAuth, async (req, res) => { + try { + const { path: filePath, content } = req.body; + const fsPromises = require('fs').promises; + const pathModule = require('path'); + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + const dir = pathModule.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + await fsPromises.writeFile(fullPath, content, 'utf8'); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + app.get('/read-file', requireAuth, (req, res) => { + try { + const { path: filePath, position } = req.query; + const pathModule = require('path'); + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + const fsPromises = require('fs').promises; + + const handleReadError = (error) => { + if (error.code === 'ENOENT') { + return res.status(404).json({ success: false, error: 'File not found' }); + } + res.status(500).json({ success: false, error: error.message }); + }; + + if (position !== undefined) { + const pos = parseInt(position) || 0; + fsPromises.readFile(fullPath, 'utf8') + .then(data => res.send(data.substring(pos))) + .catch(handleReadError); + } else { + fsPromises.readFile(fullPath, 'utf8') + .then(data => res.send(data)) + .catch(handleReadError); + } + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Batch task loading + app.post('/read-tasks-batch', requireAuth, async (req, res) => { + try { + const { taskIds } = req.body; + const fsPromises = require('fs').promises; + + if (!Array.isArray(taskIds)) { + return res.status(400).json({ success: false, error: 'taskIds must be an array' }); + } + + const loadPromises = taskIds.map(async (taskId) => { + try { + const taskFilePath = path.join(PATHS.FRONTEND_DATA, 'tasks', `${taskId}.json`); + const taskData = await fsPromises.readFile(taskFilePath, 'utf8'); + return JSON.parse(taskData); + } catch { + return null; + } + }); + + const results = await Promise.all(loadPromises); + res.json(results.filter(Boolean)); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Directory listing + app.get('/read-directory', requireAuth, (req, res) => { + try { + const { path: dirPath } = req.query; + const fullPath = path.join(PATHS.FRONTEND_DATA, dirPath); + + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + try { + const stats = fs.statSync(fullPath); + if (!stats.isDirectory()) { + return res.status(400).json({ success: false, error: 'Not a directory' }); + } + } catch { + return res.status(404).json({ success: false, error: 'Directory not found' }); + } + + fs.readdir(fullPath, (err, files) => { + if (err) return res.status(500).json({ success: false, error: err.message }); + res.json(files || []); + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // File delete (task files only) + app.post('/delete-file', requireAuth, async (req, res) => { + try { + const { path: filePath } = req.body; + const pathModule = require('path'); + const fsPromises = require('fs').promises; + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + if (!filePath.startsWith('task_') || !filePath.endsWith('.json')) { + return res.status(403).json({ success: false, error: 'Only task files can be deleted' }); + } + + await fsPromises.unlink(fullPath); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // SPA fallback — must be last + app.get('*', (req, res) => { + res.sendFile(path.join(config.FRONTEND_PATH, 'index.html')); + }); + } +}; diff --git a/containers/libreportal/backend/routes/service-routes.js b/containers/libreportal/backend/routes/service-routes.js new file mode 100644 index 0000000..b51c683 --- /dev/null +++ b/containers/libreportal/backend/routes/service-routes.js @@ -0,0 +1,565 @@ +// Per-app service routes. +// +// The "Services" tab on the app page asks: for app X, what compose +// services are defined, are they running, and how long have they been +// up — plus give me a restart button and a live log tail. +// +// Implementation notes: +// - The libreportal-service container does NOT have the `docker` CLI +// installed; it only has the docker socket bind-mounted. So instead +// of shelling out to `docker`, we talk to the Docker Engine HTTP API +// directly over the unix socket. That means no extra system deps and +// no group-level privilege grants — node only sees what the mounted +// socket lets it see. +// - Restart still goes through the existing task system. The bash task +// processor runs on the host (where `docker` IS available) so its +// `docker compose restart …` command works fine. +// - URLs / port chips for each service are read client-side from the +// existing /data/apps/generated/apps-services.json — no backend +// surface needed for that. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const http = require('http'); +const { spawn } = require('child_process'); +const { requireAuth } = require('../utils/middleware.js'); +const { pokeFifo } = require('../utils/fifo.js'); +const { fileConfig } = require('../utils/config.js'); + +const router = express.Router(); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const CONTAINERS_DIR = '/docker/containers'; +const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json'); + +// ===================================================================== +// Docker socket discovery +// ===================================================================== +// Whichever socket the host bind-mounted into us — that's the one we +// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts +// /run/user//docker.sock. No fallback to a docker group, no sudo, +// no daemon auth tokens — just the unix socket the host already chose +// to expose. +function detectDockerSocket() { + if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock'; + try { + for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const sock = `/run/user/${entry.name}/docker.sock`; + if (fs.existsSync(sock)) return sock; + } + } catch { /* /run/user not readable — that's fine */ } + return null; +} + +const DOCKER_SOCKET = detectDockerSocket(); +console.log( + DOCKER_SOCKET + ? `[services] Docker API socket: ${DOCKER_SOCKET}` + : '[services] WARNING: no docker socket found — services tab will be empty' +); + +// ===================================================================== +// Tiny Docker HTTP API client +// ===================================================================== +// The Docker daemon speaks HTTP/1.1 over a unix socket. Versioning is +// pinned to v1.41 (Docker 20.10+, far older than anything this project +// supports). +const DOCKER_API_VERSION = 'v1.41'; + +function dockerRequest(method, pathname, query) { + return new Promise((resolve, reject) => { + if (!DOCKER_SOCKET) return reject(new Error('No docker socket available')); + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + const req = http.request( + { + socketPath: DOCKER_SOCKET, + method, + path: `/${DOCKER_API_VERSION}${pathname}${qs}`, + headers: { 'Host': 'docker', 'Accept': 'application/json' } + }, + (res) => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(body ? JSON.parse(body) : null); } + catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); } + } else { + reject(new Error(`Docker API ${res.statusCode}: ${body}`)); + } + }); + } + ); + req.on('error', reject); + req.end(); + }); +} + +// Streaming GET — caller gets the raw IncomingMessage so they can pipe +// or parse the multiplexed log frames. +function dockerStream(pathname, query) { + return new Promise((resolve, reject) => { + if (!DOCKER_SOCKET) return reject(new Error('No docker socket available')); + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + const req = http.request( + { + socketPath: DOCKER_SOCKET, + method: 'GET', + path: `/${DOCKER_API_VERSION}${pathname}${qs}`, + headers: { 'Host': 'docker' } + }, + (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ stream: res, req }); + } else { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => reject(new Error( + `Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}` + ))); + } + } + ); + req.on('error', reject); + req.end(); + }); +} + +// Map Docker's verbose state info to a UX-friendly status line. +// running → "Up 2 hours" +// exited → "Exited (0) 5 minutes ago" +// restarting→ "Restarting" +function statusLineFromContainer(c) { + // `Status` from /containers/json is already exactly the human form + // we want ("Up 4 minutes", "Exited (0) 2 hours ago", etc.). + return c.Status || c.State || ''; +} + +// ===================================================================== +// Validation helpers +// ===================================================================== +const SAFE_NAME = /^[a-zA-Z0-9_.-]+$/; +function safeName(name) { return typeof name === 'string' && SAFE_NAME.test(name); } + +// SSE-wrap `tail -F -n ` and emit `log` events line-by-line so +// the frontend renders host logs through the existing viewer with zero +// changes. We use file-based tailing instead of journalctl because the +// libreportal container is Alpine-based and journalctl plumbing into a +// non-systemd container is heavier than the value. CrowdSec writes +// /var/log/crowdsec.log and /var/log/crowdsec-firewall-bouncer.log by +// default — the libreportal compose bind-mounts /var/log:/host/var/log:ro +// so log paths in apps-services.json carry the /host prefix. +// +// -F (not -f): retries on missing files and follows log rotation, so a +// briefly-absent file (e.g., before the agent has started) doesn't kill the +// stream. +// Stream bounds — keep tail from forking forever and a chatty log from +// drowning the SSE channel. All three are user-configurable via +// configs/webui/webui_logs; 0 disables the limit (max-duration is the only +// one where 0 is dangerous — left to the operator's judgement). +function streamLimitsFromConfig() { + const idleMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES); + const maxMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES); + const lps = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC); + return { + idleMs: Number.isFinite(idleMin) && idleMin >= 0 ? idleMin * 60_000 : 10 * 60_000, + maxMs: Number.isFinite(maxMin) && maxMin >= 0 ? maxMin * 60_000 : 60 * 60_000, + maxLps: Number.isFinite(lps) && lps > 0 ? lps : 200 + }; +} + +function streamHostLogFile(unit, logFile, tail, res, send, ping) { + // Whitelist: paths must live under the bind-mounted /host/var/log/ tree + // to prevent a malformed apps-services.json from reading anywhere on + // disk. apps-services.json itself is generator-produced, but defence in + // depth. + if (typeof logFile !== 'string' || !logFile.startsWith('/host/var/log/') || logFile.includes('..')) { + send('error', { message: `Refusing to tail untrusted log path: ${logFile}` }); + send('end', { code: 400 }); + clearInterval(ping); + return res.end(); + } + const limits = streamLimitsFromConfig(); + send('ready', { + at: Date.now(), tail, transport: 'systemd', unit, logFile, + limits: { idleMinutes: limits.idleMs / 60000, maxMinutes: limits.maxMs / 60000, maxLinesPerSec: limits.maxLps } + }); + + const child = spawn('tail', ['-F', '-n', String(tail), logFile], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + // Resource ceilings. cleanup() unwinds everything; called from req-close, + // tail-exit, hard-cap timeout, and idle-disconnect path. + let lastLineAt = Date.now(); + let rateWindowStart = Date.now(); + let rateWindowLines = 0; + let rateDroppedThisWindow = 0; + + // 0 = disabled — skip the timer entirely. + const hardCapTimer = limits.maxMs > 0 ? setTimeout(() => { + send('end', { code: 0, reason: 'max-duration', limitMinutes: limits.maxMs / 60000 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }, limits.maxMs) : null; + + const idleTimer = limits.idleMs > 0 ? setInterval(() => { + if (Date.now() - lastLineAt > limits.idleMs) { + send('end', { code: 0, reason: 'idle-timeout', limitMinutes: limits.idleMs / 60000 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + } + }, 60_000) : null; + + const cleanup = () => { + clearInterval(ping); + if (idleTimer) clearInterval(idleTimer); + if (hardCapTimer) clearTimeout(hardCapTimer); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + }; + res.req.on('close', cleanup); + + // stdout = log lines; stderr usually = "cannot open" notices from tail + // when the file doesn't exist yet — surface as `log` lines too so the + // user sees what's happening without panicking the viewer. + const linebuf = (which) => { + let buf = ''; + return (chunk) => { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop(); + if (!lines.length) return; + lastLineAt = Date.now(); + + // Rate limit: rolling 1-second window. Lines past the ceiling drop; + // emit a single notice at window-close so the user knows a flood is + // ongoing without us spamming the notice line every iteration. + const now = Date.now(); + if (now - rateWindowStart >= 1000) { + if (rateDroppedThisWindow > 0) { + send('log', { stream: 'meta', lines: [`[rate-limit: ${rateDroppedThisWindow} line(s) dropped in the last second]`] }); + } + rateWindowStart = now; + rateWindowLines = 0; + rateDroppedThisWindow = 0; + } + const remaining = limits.maxLps - rateWindowLines; + if (remaining <= 0) { + rateDroppedThisWindow += lines.length; + return; + } + if (lines.length > remaining) { + send('log', { stream: which, lines: lines.slice(0, remaining) }); + rateDroppedThisWindow += lines.length - remaining; + rateWindowLines = limits.maxLps; + } else { + send('log', { stream: which, lines }); + rateWindowLines += lines.length; + } + }; + }; + child.stdout.on('data', linebuf('stdout')); + child.stderr.on('data', linebuf('stderr')); + + child.on('error', (err) => { + send('error', { message: `tail spawn failed: ${err.message}` }); + send('end', { code: 1 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + child.on('exit', (code) => { + send('end', { code: code ?? 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); +} + +// Look up a service entry in apps-services.json (the generator-produced file +// the frontend already consumes). Host-installed apps are emitted by +// webui_services.sh with `transport: 'systemd'` and a `unit` field — that's +// our signal to route logs to journalctl instead of `docker logs`. +// +// The lookup also doubles as an allow-list: we ONLY journalctl units that +// appear in this file, so a caller can't request `journalctl -u +// arbitrary.service`. The names there originate from CFG_*_HOST_SERVICES +// declared in container configs. +async function lookupServiceTransport(appName, serviceName) { + try { + const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8'); + const data = JSON.parse(raw); + const entries = Array.isArray(data?.services) ? data.services : []; + for (const s of entries) { + if (s.app !== appName) continue; + if (s.serviceName !== serviceName && s.name !== serviceName) continue; + if (s.transport === 'systemd' && typeof s.unit === 'string') { + return { transport: 'systemd', unit: s.unit, logFile: s.logFile || null }; + } + return { transport: 'docker' }; + } + } catch { /* fall through to docker default */ } + return { transport: 'docker' }; +} + +function appComposeFile(appName) { + return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml'); +} + +// ===================================================================== +// GET /api/apps/:appName/services/status +// → [{ serviceName, state, statusText, containerName, containerId }] +// ===================================================================== +router.get('/:appName/services/status', requireAuth, async (req, res) => { + const { appName } = req.params; + if (!safeName(appName)) return res.status(400).json({ error: 'Invalid app name' }); + + try { + const filters = JSON.stringify({ + label: [`com.docker.compose.project=${appName}`] + }); + const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters }); + + const services = (containers || []) + .map(c => { + const labels = c.Labels || {}; + const serviceName = labels['com.docker.compose.service']; + if (!serviceName) return null; + // c.Names is like ['/libreportal-service'] — strip leading slash. + const containerName = (c.Names && c.Names[0] || '').replace(/^\//, ''); + return { + serviceName, + state: c.State || 'unknown', + statusText: statusLineFromContainer(c), + containerName, + containerId: c.Id + }; + }) + .filter(Boolean); + + // Merge in synthetic host-service entries from apps-services.json. + // webui_services.sh emits transport=systemd rows for HOST_INSTALL apps; + // they don't appear in Docker but should still render on the Services + // tab so the user can see status + tail logs for the host agent(s). + try { + const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8'); + const data = JSON.parse(raw); + for (const s of (data?.services || [])) { + if (s.app !== appName) continue; + if (s.transport !== 'systemd') continue; + services.push({ + serviceName: s.serviceName || s.name, + state: s.status === 'active' ? 'running' : 'exited', + statusText: s.status === 'active' ? 'Active (host service)' : 'Inactive (host service)', + containerName: s.unit || s.serviceName, + containerId: null, + transport: 'systemd', + unit: s.unit + }); + } + } catch { /* file may not exist yet on fresh install */ } + + res.json(services); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ===================================================================== +// POST /api/apps/:appName/services/:serviceName/restart +// Creates a task that runs `docker compose restart ` on the +// host. The host has `docker` available; this container does not. +// ===================================================================== +router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => { + const { appName, serviceName } = req.params; + if (!safeName(appName) || !safeName(serviceName)) { + return res.status(400).json({ error: 'Invalid app or service name' }); + } + const compose = appComposeFile(appName); + if (!fs.existsSync(compose)) { + return res.status(404).json({ error: `Compose file not found: ${compose}` }); + } + + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const task = { + id, + command: `docker compose -f "${compose}" restart "${serviceName}"`, + type: 'service-restart', + app: appName, + config: serviceName, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null + }; + + try { + await fsp.mkdir(TASKS_DIR, { recursive: true }); + const taskPath = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${taskPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); + await fsp.rename(tmp, taskPath); + pokeFifo(FIFO_PATH, id); + res.status(201).json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ===================================================================== +// GET /api/apps/:appName/services/:serviceName/logs +// SSE-wraps the Docker /containers//logs?follow=1 stream. +// Docker multiplexes stdout+stderr into 8-byte-framed chunks unless +// the container has tty=true; we handle both. +// ===================================================================== +router.get('/:appName/services/:serviceName/logs', requireAuth, async (req, res) => { + const { appName, serviceName } = req.params; + if (!safeName(appName) || !safeName(serviceName)) { + return res.status(400).json({ error: 'Invalid app or service name' }); + } + const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200)); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + const send = (event, data) => { + try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ } + }; + + // Heartbeat for reverse proxies during quiet logs. + const ping = setInterval(() => { + try { res.write(': ping\n\n'); } catch { /* gone */ } + }, 25_000); + + // Fork: host-installed services (transport=systemd) get journalctl + // instead of `docker logs`. Lookup is via apps-services.json which also + // doubles as the unit-name allow-list — only units declared in + // CFG_*_HOST_SERVICES make it into that file. + const transport = await lookupServiceTransport(appName, serviceName); + if (transport.transport === 'systemd') { + if (!transport.logFile) { + send('error', { message: `Host service ${transport.unit} has no logFile configured.` }); + send('end', { code: 404 }); + clearInterval(ping); + return res.end(); + } + return streamHostLogFile(transport.unit, transport.logFile, tail, res, send, ping); + } + + let containerInspect, logStreamHandle; + + const cleanup = () => { + clearInterval(ping); + try { logStreamHandle?.req.destroy(); } catch { /* already gone */ } + }; + req.on('close', cleanup); + + try { + // 1. Resolve the container that owns this compose service. + const filters = JSON.stringify({ + label: [ + `com.docker.compose.project=${appName}`, + `com.docker.compose.service=${serviceName}` + ] + }); + const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters }); + if (!containers || containers.length === 0) { + send('error', { message: `No container found for ${appName}/${serviceName}` }); + send('end', { code: 404 }); + cleanup(); + return res.end(); + } + const containerId = containers[0].Id; + + // 2. Inspect once to learn whether the container has a TTY (changes + // how the log stream is framed). + containerInspect = await dockerRequest('GET', `/containers/${containerId}/json`); + const hasTty = !!(containerInspect.Config && containerInspect.Config.Tty); + + send('ready', { at: Date.now(), tail, tty: hasTty }); + + // 3. Open the log stream. + logStreamHandle = await dockerStream(`/containers/${containerId}/logs`, { + stdout: '1', + stderr: '1', + follow: '1', + tail: String(tail), + timestamps: '0' + }); + const stream = logStreamHandle.stream; + + if (hasTty) { + // Plain text — just split on newlines. + let buf = ''; + stream.on('data', chunk => { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop(); + if (lines.length) send('log', { stream: 'stdout', lines }); + }); + stream.on('end', () => { + if (buf) send('log', { stream: 'stdout', lines: [buf] }); + send('end', { code: 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } else { + // Multiplexed framing: + // [stream_type:1][0:3][size:4 BE][payload:size] + // stream_type: 1=stdout, 2=stderr (0=stdin, never seen here) + let pending = Buffer.alloc(0); + let stdoutBuf = ''; + let stderrBuf = ''; + + const flush = (which, line) => { + const buf = which === 'stdout' ? stdoutBuf : stderrBuf; + const all = buf + line; + const lines = all.split('\n'); + const tailPart = lines.pop(); + if (which === 'stdout') stdoutBuf = tailPart; else stderrBuf = tailPart; + if (lines.length) send('log', { stream: which, lines }); + }; + + stream.on('data', chunk => { + pending = pending.length ? Buffer.concat([pending, chunk]) : chunk; + while (pending.length >= 8) { + const streamType = pending[0]; + const size = pending.readUInt32BE(4); + if (pending.length < 8 + size) break; // wait for more bytes + const payload = pending.slice(8, 8 + size).toString('utf8'); + pending = pending.slice(8 + size); + flush(streamType === 2 ? 'stderr' : 'stdout', payload); + } + }); + stream.on('end', () => { + if (stdoutBuf) send('log', { stream: 'stdout', lines: [stdoutBuf] }); + if (stderrBuf) send('log', { stream: 'stderr', lines: [stderrBuf] }); + send('end', { code: 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } + + stream.on('error', err => { + send('error', { message: err.message }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } catch (err) { + send('error', { message: err.message }); + send('end', { code: 500 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js new file mode 100644 index 0000000..5b8d554 --- /dev/null +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -0,0 +1,253 @@ +// Setup Wizard backend. +// +// Three sync GETs (status / suggest-name / dns-check) plus one async POST +// (save) that hands off to the host task system. The lock file lives at +// /app/frontend/data/.setup_complete — under the existing frontend bind-mount +// so the container can read it and the host's setupApply can write it +// without us having to add a new bind-mount to docker-compose.yml. +// +// Sync endpoints intentionally do NOT round-trip through the task daemon — +// suggest-name and dns-check are pure read-only operations that we +// reimplement in JS, so they return in <50ms instead of waiting for the next +// cron tick. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const dns = require('dns').promises; +const https = require('https'); +const { requireAuth } = require('../utils/middleware.js'); +const { pokeFifo } = require('../utils/fifo.js'); + +const router = express.Router(); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const SETUP_LOCK_FILE = path.join(__dirname, '..', '..', 'frontend', 'data', '.setup_complete'); + +const ADJECTIVES = [ + 'Quantum', 'Neutrino', 'Photon', 'Plasma', 'Quasar', 'Pulsar', 'Tachyon', + 'Boson', 'Fermion', 'Hadron', 'Gluon', 'Muon', 'Higgs', 'Entangled', + 'Singular', 'Warped', 'Tunneling', 'Coherent', 'Superposed', 'Spectral', + 'Orbital', 'Cosmic', 'Stellar', 'Nebular', 'Astral', 'Gravitic', 'Inertial', + 'Relativistic', 'Helical', 'Toroidal', 'Holographic', 'Cryogenic', + 'Crystalline', 'Resonant', 'Harmonic', 'Phasic', 'Drifting', 'Spinning', + 'Pulsing', 'Hyper' +]; + +const NOUNS = [ + 'Frog', 'Fox', 'Otter', 'Raven', 'Wolf', 'Yak', 'Lynx', 'Owl', 'Hawk', + 'Crow', 'Newt', 'Wren', 'Eel', 'Crab', 'Squid', 'Octopus', 'Mantis', + 'Cobra', 'Viper', 'Ferret', 'Badger', 'Penguin', 'Panda', 'Lemur', 'Quark', + 'Nebula', 'Comet', 'Nova', 'Eclipse', 'Aurora', 'Vortex', 'Helix', 'Halo', + 'Phoenix', 'Hydra', 'Kraken', 'Sphinx', 'Specter', 'Phantom', 'Glyph' +]; + +function generateInstallName() { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + return `${adj}${noun}`; +} + +// Install order is enforced server-side. Monitoring goes first so apps +// installing later detect a live Prometheus/Grafana and wire their metrics +// export at install time — Traefik, CrowdSec et al. are monitoring consumers. +// Grafana follows Prometheus because its datasource points at it. +const INSTALL_TIERS = [ + ['prometheus', 'grafana'], + ['traefik', 'crowdsec'] +]; + +function sortAppsByTier(apps) { + const rank = new Map(); + let r = 0; + for (const tier of INSTALL_TIERS) for (const slug of tier) rank.set(slug, r++); + return [...apps].sort((a, b) => { + const ra = rank.has(a) ? rank.get(a) : Infinity; + const rb = rank.has(b) ? rank.get(b) : Infinity; + if (ra !== rb) return ra - rb; + return apps.indexOf(a) - apps.indexOf(b); + }); +} + +function fetchPublicIp() { + return new Promise((resolve) => { + const req = https.get('https://api.ipify.org', { timeout: 3000 }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve(body.trim() || null)); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + +router.get('/status', requireAuth, async (req, res) => { + const complete = fs.existsSync(SETUP_LOCK_FILE); + res.json({ complete }); +}); + +router.get('/suggest-name', requireAuth, (req, res) => { + res.set('Cache-Control', 'no-store'); + res.json({ name: generateInstallName() }); +}); + +router.get('/dns-check', requireAuth, async (req, res) => { + const domain = String(req.query.domain || '').trim().toLowerCase(); + if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) { + return res.status(400).json({ matches: false, error: 'invalid domain' }); + } + + const [serverIp, domainIps] = await Promise.all([ + fetchPublicIp(), + dns.resolve4(domain).catch(() => []) + ]); + + const domainIp = domainIps[0] || null; + const matches = !!(serverIp && domainIp && serverIp === domainIp); + + res.json({ matches, server_ip: serverIp, domain_ip: domainIp }); +}); + +// Each ticked app becomes its own `libreportal app install ` task — +// using the same task type the WebUI's app-install pipeline already +// understands, so the user sees individual progress per app instead of +// one opaque "setup apply" task. The first task writes the configs, the +// last marks the wizard complete; in between, the recommended apps run +// sequentially because the host daemon processes the FIFO in order. +async function enqueueTask(spec) { + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const task = { + id, + command: spec.command, + type: spec.type, + app: spec.app || 'libreportal', + config: spec.config || 'setup-wizard', + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null, + setupGroup: spec.setupGroup, + setupRole: spec.setupRole // 'config' | 'app' | 'finalize' + }; + const taskPath = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${taskPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); + await fsp.rename(tmp, taskPath); + pokeFifo(FIFO_PATH, id); + // Tiny stagger so each task gets a unique Date.now()-based id. + await new Promise(r => setTimeout(r, 2)); + return id; +} + +router.post('/save', requireAuth, async (req, res) => { + const payload = req.body || {}; + + if (!payload.install_name || !/^[a-zA-Z0-9-]+$/.test(payload.install_name)) { + return res.status(400).json({ error: 'invalid install_name' }); + } + if (!payload.timezone) { + return res.status(400).json({ error: 'timezone required' }); + } + + // Domains are optional but each entry must be a valid hostname. Cap at + // 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9. + const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i; + payload.domains = Array.isArray(payload.domains) + ? payload.domains.map(d => String(d).trim().toLowerCase()).filter(Boolean) + : []; + if (payload.domains.length > 9) payload.domains = payload.domains.slice(0, 9); + for (const d of payload.domains) { + if (!domainRe.test(d)) return res.status(400).json({ error: `invalid domain: ${d}` }); + } + + payload.apps = Array.isArray(payload.apps) ? payload.apps.filter(a => /^[a-z0-9_-]+$/i.test(a)) : []; + payload.apps = sortAppsByTier(payload.apps); + + // Validate appOptions — shape: { : { : bool, ... } } + const optsIn = (payload.appOptions && typeof payload.appOptions === 'object') ? payload.appOptions : {}; + const safeOpts = {}; + for (const [slug, opts] of Object.entries(optsIn)) { + if (!/^[a-z0-9_-]+$/i.test(slug)) continue; + if (!payload.apps.includes(slug)) continue; + if (!opts || typeof opts !== 'object') continue; + safeOpts[slug] = {}; + for (const [k, v] of Object.entries(opts)) { + if (/^[a-z0-9_-]+$/i.test(k) && typeof v === 'boolean') safeOpts[slug][k] = v; + } + } + payload.appOptions = safeOpts; + + const wantsTraefik = payload.apps.includes('traefik'); + if (wantsTraefik) { + if (!payload.traefik_email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.traefik_email)) { + return res.status(400).json({ error: 'traefik_email required when installing Traefik' }); + } + } else { + delete payload.traefik_email; + } + + const setupGroup = `setup_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const b64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + + try { + await fsp.mkdir(TASKS_DIR, { recursive: true }); + const taskIds = []; + + taskIds.push(await enqueueTask({ + command: `libreportal setup config ${b64}`, + type: 'setup-config', + setupGroup, + setupRole: 'config' + })); + + for (const appName of payload.apps) { + // Convert appOptions sub-flags into the framework's config_variables + // arg. Convention: sub-option on app maps to + // CFG___ENABLED. dockerInstallApp parses these and writes + // them into the template config before calling install. + let command = `libreportal app install ${appName}`; + const opts = payload.appOptions[appName] || {}; + const cfgPairs = []; + const slugUpper = appName.toUpperCase().replace(/-/g, '_'); + for (const [optId, value] of Object.entries(opts)) { + if (typeof value !== 'boolean') continue; + cfgPairs.push(`CFG_${slugUpper}_${optId.toUpperCase()}_ENABLED=${value}`); + } + if (cfgPairs.length) command += ` ${cfgPairs.join('|')}`; + + taskIds.push(await enqueueTask({ + command, + type: 'app-install', + app: appName, + setupGroup, + setupRole: 'app' + })); + } + + const finalizeId = await enqueueTask({ + command: `libreportal setup finalize`, + type: 'setup-finalize', + setupGroup, + setupRole: 'finalize' + }); + taskIds.push(finalizeId); + + res.status(201).json({ + setupGroup, + taskIds, + firstTaskId: taskIds[0], + finalizeTaskId: finalizeId, + installName: payload.install_name + }); + } catch (err) { + console.error('[setup] save failed:', err); + res.status(500).json({ error: 'failed to enqueue setup tasks' }); + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/task-routes.js b/containers/libreportal/backend/routes/task-routes.js new file mode 100755 index 0000000..4a63543 --- /dev/null +++ b/containers/libreportal/backend/routes/task-routes.js @@ -0,0 +1,430 @@ +// Task API + Server-Sent Events feed. +// +// Single source of truth: the task file under FRONTEND_DATA/tasks/.json. +// Status field defines lifecycle (queued -> running -> completed|failed|cancelled). +// We never write `current.json` or `queue.json` from here — those are gone. +// +// Push model: clients subscribe to GET /api/tasks/events (SSE). We watch the +// tasks dir with fs.watch and emit events whenever: +// - a task file is created or modified -> task.upsert (full task object) +// - a task file is deleted -> task.deleted (id only) +// - a task .log file grows -> task.log (taskId, appendedText) +// +// Latency from a bash write to a connected client receiving the event is +// typically under 50ms. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const { requireAuth } = require('../utils/middleware.js'); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const PROCESSOR_LOCK = path.join(TASKS_DIR, '.processor.lock'); + +// ===================================================================== +// SSE HUB +// ===================================================================== +// One Set of `res` objects. Every event goes to all of them. + +const sseClients = new Set(); +let nextClientId = 1; + +function sseBroadcast(event, data) { + if (sseClients.size === 0) return; + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const client of sseClients) { + try { client.res.write(payload); } catch { /* client gone; cleanup below */ } + } +} + +function attachSseClient(req, res) { + const id = nextClientId++; + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + // Initial hello so the client knows the connection is live. + res.write(`event: ready\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`); + + // Periodic comment-ping so intermediaries don't time the connection out. + const ping = setInterval(() => { + try { res.write(': ping\n\n'); } catch { /* will be cleaned on close */ } + }, 25_000); + + const client = { id, res }; + sseClients.add(client); + + req.on('close', () => { + clearInterval(ping); + sseClients.delete(client); + }); +} + +// ===================================================================== +// FILESYSTEM WATCH +// ===================================================================== +// Single dir watcher. Per-file tracking is kept in `logTails` so we know +// where each task's log ended last time we read it (for incremental tail). + +const logTails = new Map(); // taskId -> last position in bytes +const upsertDebounce = new Map(); // filename -> timer (debounce rapid writes) + +async function emitTaskUpsert(filename) { + const id = filename.replace(/\.json$/, ''); + const fullPath = path.join(TASKS_DIR, filename); + try { + const text = await fsp.readFile(fullPath, 'utf8'); + if (!text.trim()) return; + const task = JSON.parse(text); + sseBroadcast('task.upsert', task); + } catch { + // File may have been deleted between watch event and read. Treat as deleted. + sseBroadcast('task.deleted', { id }); + } +} + +// Per-task coalescing. fs.watch fires multiple events for a single append +// (a `change` and sometimes a `rename`), and a naive emitLogTail reads +// `prev` at entry but only writes `logTails.set(...)` after stat+open+read. +// Concurrent invocations therefore see the same `prev`, read the same +// range, and broadcast the chunk twice — clients render duplicate lines. +// State per id: `undefined` = idle, `false` = running, `true` = running and +// another event arrived while running (re-run after current pass). +const tailInflight = new Map(); + +async function emitLogTail(filename) { + const id = filename.replace(/\.log$/, ''); + if (tailInflight.has(id)) { + tailInflight.set(id, true); + return; + } + tailInflight.set(id, false); + try { + while (true) { + await emitLogTailOnce(id, filename); + if (tailInflight.get(id)) { + tailInflight.set(id, false); + continue; + } + break; + } + } finally { + tailInflight.delete(id); + } +} + +async function emitLogTailOnce(id, filename) { + const fullPath = path.join(TASKS_DIR, filename); + let stat; + try { stat = await fsp.stat(fullPath); } catch { return; } + + const prev = logTails.get(id) || 0; + // Truncated? Reset the cursor. + const start = stat.size < prev ? 0 : prev; + if (stat.size === start) return; + + let chunk; + try { + const fh = await fsp.open(fullPath, 'r'); + try { + const buf = Buffer.alloc(stat.size - start); + await fh.read(buf, 0, buf.length, start); + chunk = buf.toString('utf8'); + } finally { await fh.close(); } + } catch { return; } + + logTails.set(id, stat.size); + if (chunk) sseBroadcast('task.log', { id, chunk }); +} + +function startTasksWatcher() { + if (!fs.existsSync(TASKS_DIR)) { + try { fs.mkdirSync(TASKS_DIR, { recursive: true }); } catch {} + } + + // Single recursive=false watch on the tasks dir is enough — task files and + // their .log siblings live there. + try { + fs.watch(TASKS_DIR, { persistent: true }, (eventType, filename) => { + if (!filename) return; + // Skip hidden files (.processor.lock, .queue.fifo, …). + if (filename.startsWith('.')) return; + + // .json -> task upsert/delete + if (filename.startsWith('task_') && filename.endsWith('.json')) { + clearTimeout(upsertDebounce.get(filename)); + upsertDebounce.set(filename, setTimeout(() => { + upsertDebounce.delete(filename); + const fullPath = path.join(TASKS_DIR, filename); + fs.access(fullPath, (err) => { + if (err) { + const id = filename.replace(/\.json$/, ''); + logTails.delete(id); + sseBroadcast('task.deleted', { id }); + } else { + emitTaskUpsert(filename).catch(() => {}); + } + }); + }, 30)); + return; + } + + // .log -> incremental tail + if (filename.startsWith('task_') && filename.endsWith('.log')) { + emitLogTail(filename).catch(() => {}); + return; + } + }); + } catch (err) { + console.error('[tasks] failed to start fs watcher:', err.message); + } +} + +// ===================================================================== +// FIFO WAKE-UP +// ===================================================================== +// Best-effort poke at the bash processor. Never throws: if the FIFO doesn't +// exist or no reader is attached, we ignore the error and rely on the +// processor's idle timeout (≤3s) to pick the task up. + +// Per-task fs.watchFile polling fallback. fs.watch (inotify) is the primary +// notifier but can miss events on Docker bind-mounts; this 1s polling pass +// ensures status flips reach SSE within ~1s even if inotify silently drops. +// Self-disarms once the task hits a terminal state. +const activePolls = new Map(); +function armActiveTaskPoll(taskId) { + if (activePolls.has(taskId)) return; + const filePath = path.join(TASKS_DIR, `${taskId}.json`); + let lastStatus = null; + fs.watchFile(filePath, { interval: 1000 }, () => { + fs.readFile(filePath, 'utf8', (err, data) => { + if (err) { disarmActiveTaskPoll(taskId); return; } + let task; try { task = JSON.parse(data); } catch { return; } + if (task.status !== lastStatus) { + lastStatus = task.status; + sseBroadcast('task.upsert', task); + } + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + disarmActiveTaskPoll(taskId); + } + }); + }); + activePolls.set(taskId, filePath); +} +function disarmActiveTaskPoll(taskId) { + const fp = activePolls.get(taskId); + if (!fp) return; + fs.unwatchFile(fp); + activePolls.delete(taskId); +} + +function pokeFifo(taskId) { + fs.open(FIFO_PATH, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK, (err, fd) => { + if (err) return; // No reader / FIFO missing — fine. + fs.write(fd, `${taskId}\n`, () => fs.close(fd, () => {})); + }); +} + +// ===================================================================== +// HELPERS +// ===================================================================== + +function generateTaskId() { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +function isValidTaskId(id) { + return typeof id === 'string' && /^task_[0-9]+_[a-z0-9]+$/i.test(id); +} + +async function readTask(id) { + const text = await fsp.readFile(path.join(TASKS_DIR, `${id}.json`), 'utf8'); + return JSON.parse(text); +} + +async function writeTaskAtomic(id, task) { + const final = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${final}.tmp.${process.pid}.${Date.now()}`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2), 'utf8'); + await fsp.rename(tmp, final); +} + +// ===================================================================== +// ROUTES +// ===================================================================== + +const router = express.Router(); + +// SSE feed. Held open for the life of the page. +router.get('/events', requireAuth, (req, res) => { + attachSseClient(req, res); +}); + +// List all tasks (returns lightweight summaries). +router.get('/', requireAuth, async (req, res) => { + try { + const entries = await fsp.readdir(TASKS_DIR); + const out = []; + for (const entry of entries) { + if (!entry.startsWith('task_') || !entry.endsWith('.json')) continue; + try { + const text = await fsp.readFile(path.join(TASKS_DIR, entry), 'utf8'); + if (!text.trim()) continue; + const task = JSON.parse(text); + out.push(task); + } catch { /* skip unreadable entries */ } + } + out.sort((a, b) => String(b.createdAt || b.created_at || '').localeCompare(String(a.createdAt || a.created_at || ''))); + res.json(out); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Read a single task. +router.get('/:id', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + try { + const task = await readTask(id); + res.json(task); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Create a task. +router.post('/', requireAuth, async (req, res) => { + try { + const { command, type = 'custom', app = null, config = '' } = req.body || {}; + if (typeof command !== 'string' || !command.trim()) { + return res.status(400).json({ error: '`command` is required' }); + } + const id = generateTaskId(); + const task = { + id, + command, + type, + app, + config, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null + }; + await writeTaskAtomic(id, task); + pokeFifo(id); + sseBroadcast('task.upsert', task); + // fs.watch is unreliable on Docker bind-mounts; add a 1s polling + // fallback for this task until it terminates so a missed inotify + // event can't strand it as "running" in the UI. + armActiveTaskPoll(id); + res.status(201).json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Cancel a task. Drops a `.cancel` marker that the processor's heartbeat +// loop notices and then SIGTERMs the running command. +router.post('/:id/cancel', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + try { + const task = await readTask(id); + if (task.status !== 'running' && task.status !== 'queued') { + return res.status(409).json({ error: `Task is ${task.status}; cannot cancel.` }); + } + if (task.status === 'queued') { + const updated = { + ...task, + status: 'cancelled', + completedAt: new Date().toISOString(), + errorMessage: 'Cancelled before start.' + }; + await writeTaskAtomic(id, updated); + sseBroadcast('task.upsert', updated); + return res.json(updated); + } + // Drop the cancel marker; processor picks it up within HEARTBEAT_INTERVAL. + await fsp.writeFile(path.join(TASKS_DIR, `${id}.cancel`), '', 'utf8'); + res.json({ ok: true }); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Read full log (non-streaming). Use `position` for incremental polling +// fallback if SSE isn't connected for some reason. +router.get('/:id/log', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + const pos = parseInt(req.query.position, 10) || 0; + try { + const text = await fsp.readFile(path.join(TASKS_DIR, `${id}.log`), 'utf8'); + res.type('text/plain').send(pos > 0 ? text.slice(pos) : text); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).type('text/plain').send(''); + res.status(500).json({ error: err.message }); + } +}); + +// Delete a task. Default behaviour blocks deletion of running/queued tasks +// so the user can't accidentally orphan an in-flight workload — but +// `?force=1` overrides that, which is needed when a stuck task can't be +// cancelled (e.g. the bash processor has died, the cancel marker isn't +// being picked up, etc.) and the user just wants the row gone. +router.delete('/:id', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + const force = req.query.force === '1' || req.query.force === 'true'; + try { + const task = await readTask(id); + if (!force && (task.status === 'running' || task.status === 'queued')) { + return res.status(409).json({ error: `Task is ${task.status}; cancel first.` }); + } + await fsp.unlink(path.join(TASKS_DIR, `${id}.json`)).catch(() => {}); + await fsp.unlink(path.join(TASKS_DIR, `${id}.log`)).catch(() => {}); + // Drop any leftover cancel marker so the processor (when it does come + // back up) doesn't try to act on a task file that no longer exists. + await fsp.unlink(path.join(TASKS_DIR, `${id}.cancel`)).catch(() => {}); + logTails.delete(id); + sseBroadcast('task.deleted', { id }); + res.json({ ok: true, forced: force }); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Health-check style endpoint: is the bash processor alive? +router.get('/_meta/health', requireAuth, async (req, res) => { + let processorAlive = false; + try { + const pidText = await fsp.readFile(`${PROCESSOR_LOCK}.pid`, 'utf8'); + const pid = parseInt(pidText.trim(), 10); + processorAlive = Number.isFinite(pid) && pid > 0; + // We can't easily verify the pid is alive from inside the container if the + // processor runs on the host — but the existence of the pid file is a + // reasonable proxy. + } catch { /* processor not running */ } + res.json({ + processorAlive, + sseClients: sseClients.size, + tasksDir: TASKS_DIR + }); +}); + +// Init the watcher exactly once when the module is required. +startTasksWatcher(); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/theme.js b/containers/libreportal/backend/routes/theme.js new file mode 100755 index 0000000..94b3a4c --- /dev/null +++ b/containers/libreportal/backend/routes/theme.js @@ -0,0 +1,62 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const config = require('../utils/config.js'); + +const router = express.Router(); + +/* ========================= + GET Current Theme +========================= */ +router.get('/', (req, res) => { + try { + const theme = config.fileConfig.CFG_LIBREPORTAL_THEME || 'dark'; + res.json({ theme }); + } catch (err) { + console.error('Error getting theme:', err); + res.json({ theme: 'dark' }); // fallback to default + } +}); + +/* ========================= + POST Update Theme +========================= */ +router.post('/', (req, res) => { + try { + const { theme } = req.body; + if (!theme || typeof theme !== 'string') { + return res.status(400).json({ error: 'Invalid theme' }); + } + + const configPath = path.join(__dirname, '..', '..', 'libreportal.config'); + + // Read current config + let lines = []; + if (fs.existsSync(configPath)) { + lines = fs.readFileSync(configPath, 'utf8').split('\n'); + } + + // Update or add theme line + const themeLine = `CFG_LIBREPORTAL_THEME=${theme}`; + const themeIndex = lines.findIndex(line => line.startsWith('CFG_LIBREPORTAL_THEME=')); + + if (themeIndex >= 0) { + lines[themeIndex] = themeLine; + } else { + lines.push(themeLine); + } + + // Write updated config + fs.writeFileSync(configPath, lines.join('\n'), 'utf8'); + + // Update in-memory config + config.fileConfig.CFG_LIBREPORTAL_THEME = theme; + + res.json({ theme }); + } catch (err) { + console.error('Error updating theme:', err); + res.status(500).json({ error: 'Failed to update theme' }); + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/themes.js b/containers/libreportal/backend/routes/themes.js new file mode 100644 index 0000000..dca469a --- /dev/null +++ b/containers/libreportal/backend/routes/themes.js @@ -0,0 +1,71 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); + +const router = express.Router(); + +const THEMES_DIR = path.join(__dirname, '..', '..', 'frontend', 'themes'); + +/* Surface order — themes whose folder name appears here are listed first, + in this order. Anything else is appended alphabetically. Lets us keep + the built-ins (nebula / dark-blue / light) at the top of the dropdown + without hardcoding their existence in the API. */ +const PREFERRED_ORDER = ['nebula', 'dark-blue', 'light']; + +/* ========================= + GET /api/themes/list + + Walks frontend/themes// and returns one entry per directory that + contains a theme.css. Optional meta.json supplies a friendlier display + name. No hardcoded list — built-ins live in folders just like any + custom theme. + + Public — the list of theme names isn't sensitive and the frontend + needs it before login to render the right palette on the login + overlay too. +========================= */ +router.get('/list', (req, res) => { + const themes = []; + try { + if (fs.existsSync(THEMES_DIR)) { + for (const entry of fs.readdirSync(THEMES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const name = entry.name; + const cssPath = path.join(THEMES_DIR, name, 'theme.css'); + if (!fs.existsSync(cssPath)) continue; + + let displayName = name; + let builtin = false; + const metaPath = path.join(THEMES_DIR, name, 'meta.json'); + if (fs.existsSync(metaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (meta && typeof meta.displayName === 'string' && meta.displayName.trim()) { + displayName = meta.displayName.trim(); + } + if (meta && meta.builtin === true) builtin = true; + } catch (_) { + /* malformed meta.json — fall back to folder name */ + } + } + + themes.push({ name, displayName, css: `/themes/${name}/theme.css`, builtin }); + } + } + } catch (err) { + console.error('Error scanning themes directory:', err); + } + + themes.sort((a, b) => { + const ai = PREFERRED_ORDER.indexOf(a.name); + const bi = PREFERRED_ORDER.indexOf(b.name); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return a.displayName.localeCompare(b.displayName); + }); + + res.json(themes); +}); + +module.exports = router; diff --git a/containers/libreportal/backend/server.js b/containers/libreportal/backend/server.js new file mode 100755 index 0000000..fea1f96 --- /dev/null +++ b/containers/libreportal/backend/server.js @@ -0,0 +1,15 @@ +const express = require('express'); +const config = require('./utils/config.js'); +const middleware = require('./utils/middleware.js'); +const routes = require('./routes/routes.js'); +const auth = require('./utils/auth.js'); + +(async () => { + const app = express(); + middleware.setup(app); + await auth.initAuth(config.fileConfig); + routes.setup(app); + app.listen(config.PORT, '0.0.0.0', () => { + //console.log(`LibrePortal Web UI running on http://0.0.0.0:${config.PORT}`); + }); +})(); diff --git a/containers/libreportal/backend/utils/auth.js b/containers/libreportal/backend/utils/auth.js new file mode 100755 index 0000000..ffaa436 --- /dev/null +++ b/containers/libreportal/backend/utils/auth.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +const AUTH_FILE = path.join(__dirname, '..', '..', 'frontend', '.auth.json'); + +let authData = null; + +async function initAuth(fileConfig) { + if (fs.existsSync(AUTH_FILE)) { + authData = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); + console.log('[Auth] Loaded existing credentials for user:', authData.username); + } else { + const username = fileConfig.CFG_WEBUI_USERNAME || 'admin'; + const password = fileConfig.CFG_WEBUI_PASSWORD || 'changeme'; + console.log('[Auth] Creating auth credentials for user:', username); + const passwordHash = await bcrypt.hash(password, 12); + const jwtSecret = crypto.randomBytes(32).toString('hex'); + authData = { username, passwordHash, jwtSecret }; + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), 'utf8'); + } +} + +function generateToken(username) { + return jwt.sign({ sub: username }, authData.jwtSecret, { expiresIn: '30d' }); +} + +function verifyToken(token) { + try { + return jwt.verify(token, authData.jwtSecret); + } catch { + return null; + } +} + +async function verifyPassword(plain, hash) { + return bcrypt.compare(plain, hash); +} + +function getCredentials() { + return authData; +} + +module.exports = { initAuth, generateToken, verifyToken, verifyPassword, getCredentials }; diff --git a/containers/libreportal/backend/utils/config.js b/containers/libreportal/backend/utils/config.js new file mode 100755 index 0000000..43a6f24 --- /dev/null +++ b/containers/libreportal/backend/utils/config.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +/* ========================= + Parse a bash-style config file + Handles: KEY=value and KEY=value # inline comment +========================= */ +function parseConfigFile(filePath) { + const config = {}; + if (!fs.existsSync(filePath)) return config; + + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.substring(0, eqIdx).trim(); + let value = trimmed.substring(eqIdx + 1).trim(); + // Strip inline comment (space + #) + const commentIdx = value.search(/\s+#/); + if (commentIdx !== -1) value = value.substring(0, commentIdx).trim(); + if (key && value !== undefined) config[key] = value; + } + return config; +} + +/* ========================= + Parse Port from Config +========================= */ +function parsePortFromConfig(portConfig) { + if (!portConfig) return 1111; + const port = Number(portConfig); + return port >= 1 && port <= 65535 ? port : 1111; +} + +/* ========================= + Export Configuration +========================= */ +const libreportalConfig = parseConfigFile(path.join(__dirname, '..', '..', 'libreportal.config')); +const webuiLoginsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logins')); +const webuiLogsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logs')); + +// Merge: later sources override earlier. webui_logins / webui_logs hold +// CFG_WEBUI_* keys generated from /docker/configs/webui/* and bind-mounted +// in via libreportal's compose. +const fileConfig = { ...libreportalConfig, ...webuiLoginsConfig, ...webuiLogsConfig }; + +const PORT = parsePortFromConfig(fileConfig.CFG_LIBREPORTAL_PORT_1) || 1111; +if (!PORT || PORT < 1 || PORT > 65535) { + console.warn('Invalid or missing CFG_LIBREPORTAL_PORT_1 in libreportal.config, using default port 1111'); +} + +const COMMAND_TIMEOUT = Number(fileConfig.CFG_LIBREPORTAL_TIMEOUT || 30000); + +module.exports = { + PORT, + COMMAND_TIMEOUT, + fileConfig, + FRONTEND_PATH: path.join(__dirname, '..', '..', 'frontend') +}; diff --git a/containers/libreportal/backend/utils/fifo.js b/containers/libreportal/backend/utils/fifo.js new file mode 100644 index 0000000..9f2047f --- /dev/null +++ b/containers/libreportal/backend/utils/fifo.js @@ -0,0 +1,22 @@ +// Best-effort wake-up signal for the host-side bash task processor. +// +// The FIFO carries no meaningful payload — it's just a poke to make the +// processor poll sooner than its 3 s idle scan. So `pokeFifo` is +// non-blocking by design: +// - O_NONBLOCK on open means we don't wedge the Node event loop when no +// reader is attached (the previous writeFileSync would hang forever +// in that case, surfacing as "NetworkError failed to fetch" in the UI +// when the request never returned). +// - All errors are swallowed; the processor will pick the task up from +// the on-disk JSON via its idle scan even if the wake-up is lost. + +const fs = require('fs'); + +function pokeFifo(fifoPath, taskId) { + fs.open(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK, (err, fd) => { + if (err) return; + fs.write(fd, `${taskId}\n`, () => fs.close(fd, () => {})); + }); +} + +module.exports = { pokeFifo }; diff --git a/containers/libreportal/backend/utils/mail.js b/containers/libreportal/backend/utils/mail.js new file mode 100755 index 0000000..de7541a --- /dev/null +++ b/containers/libreportal/backend/utils/mail.js @@ -0,0 +1,142 @@ +const net = require('net'); + +/* ========================= + Mail Connection Test +========================= */ +async function testConnection(req, res) { + try { + const { host, port, secure, username, password, from } = req.body; + + // Validate required fields + if (!host || !port || !username || !password) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: host, port, username, password' + }); + } + + const result = await new Promise((resolve, reject) => { + const socket = new net.Socket(); + let response = ''; + let connected = false; + + socket.connect(parseInt(port), host, () => { + connected = true; + //console.log(`Connected to ${host}:${port}`); + + // Wait for SMTP greeting + socket.once('data', (data) => { + response = data.toString(); + //console.log('SMTP greeting:', response.trim()); + + if (response.startsWith('220')) { + // Send EHLO + socket.write('EHLO test.example.com\r\n'); + + // Wait for EHLO response + socket.once('data', (ehloData) => { + const ehloResponse = ehloData.toString(); + //console.log('EHLO response:', ehloResponse.trim()); + + if (ehloResponse.startsWith('250')) { + // Try to authenticate if username/password provided + if (username && password) { + socket.write(`AUTH LOGIN\r\n`); + + socket.once('data', (authChallenge) => { + const authResponse = authChallenge.toString(); + //console.log('AUTH response:', authResponse.trim()); + + if (authResponse.startsWith('334')) { + // Send username (base64 encoded) + const usernameB64 = Buffer.from(username).toString('base64'); + socket.write(usernameB64 + '\r\n'); + + socket.once('data', (userChallenge) => { + const userResponse = userChallenge.toString(); + //console.log('Username response:', userResponse.trim()); + + if (userResponse.startsWith('334')) { + // Send password (base64 encoded) + const passwordB64 = Buffer.from(password).toString('base64'); + socket.write(passwordB64 + '\r\n'); + + socket.once('data', (passResponse) => { + const passResult = passResponse.toString(); + //console.log('Password response:', passResult.trim()); + + if (passResult.startsWith('235')) { + resolve({ + success: true, + message: `Successfully connected and authenticated to ${host}:${port} with ${secure || 'none'} security` + }); + } else { + reject(new Error('Authentication failed: Invalid username or password')); + } + socket.end(); + }); + } else { + reject(new Error('Authentication failed: Invalid username')); + socket.end(); + } + }); + } else { + reject(new Error('Server does not support authentication')); + socket.end(); + } + }); + } else { + resolve({ + success: true, + message: `Successfully connected to ${host}:${port} with ${secure || 'none'} security (no authentication tested)` + }); + socket.end(); + } + } else { + reject(new Error('EHLO command failed')); + socket.end(); + } + }); + } else { + reject(new Error('Server did not send proper SMTP greeting')); + socket.end(); + } + }); + }); + + socket.on('error', (err) => { + if (!connected) { + reject(new Error(`Connection failed: ${err.message}`)); + } else { + reject(new Error(`SMTP error: ${err.message}`)); + } + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + res.json(result); + + } catch (error) { + console.error('Mail connection test error:', error); + res.status(500).json({ + success: false, + message: error.message || 'Mail connection test failed', + details: error.stack || 'No stack trace available', + error: error.toString(), + config: { + host: host, + port: port, + secure: secure, + username: username ? '[REDACTED]' : 'not provided', + password: password ? '[REDACTED]' : 'not provided', + from: from + } + }); + } +} + +module.exports = { testConnection }; diff --git a/containers/libreportal/backend/utils/middleware.js b/containers/libreportal/backend/utils/middleware.js new file mode 100755 index 0000000..6dd511a --- /dev/null +++ b/containers/libreportal/backend/utils/middleware.js @@ -0,0 +1,42 @@ +const express = require('express'); +const path = require('path'); +const cookieParser = require('cookie-parser'); +const config = require('./config.js'); +const { verifyToken } = require('./auth.js'); + +function requireAuth(req, res, next) { + const token = req.cookies?.libreportal_token; + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + const payload = verifyToken(token); + if (!payload) return res.status(401).json({ error: 'Token expired or invalid' }); + req.user = payload; + next(); +} + +// Prevent the browser from caching authenticated /data/* responses so they're +// not retained after logout or persisted to disk caches. +function noStore(req, res, next) { + res.setHeader('Cache-Control', 'no-store'); + next(); +} + +function setup(app) { + app.use(express.json()); + app.use(cookieParser()); + + // Block MIME sniffing on every response. + app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); + }); + + // /data/* requires auth. express.static doesn't generate directory listings, + // so the only way to read anything is to know an exact path. + app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data'))); + + // All other static assets (js, css, icons, html partials, index.html) remain public. + // dotfiles='ignore' by default so .auth.json is never served. + app.use(express.static(config.FRONTEND_PATH)); +} + +module.exports = { setup, requireAuth }; diff --git a/containers/libreportal/docker-compose.yml b/containers/libreportal/docker-compose.yml new file mode 100644 index 0000000..1787080 --- /dev/null +++ b/containers/libreportal/docker-compose.yml @@ -0,0 +1,50 @@ + + +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + libreportal-service: #LIBREPORTAL|SERVICE_TAG_1|libreportal-service + container_name: libreportal-service + build: + context: . + image: libreportal-service:latest + user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA + group_add: + - SOCKET_GID_DATA #LIBREPORTAL|SOCKET_GID_TAG|SOCKET_GID_DATA + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + volumes: + - ./frontend:/app/frontend + - ./backend/routes:/app/backend/routes + - ./backend/utils:/app/backend/utils + - ./backend/server.js:/app/backend/server.js + - ./libreportal.config:/app/libreportal.config:ro + - ../../configs/webui/webui_logins:/app/webui_logins:ro + - ../../configs/webui/webui_logs:/app/webui_logs:ro + # >>> crowdsec-host-logs >>> + #- /var/log/crowdsec.log:/host/var/log/crowdsec.log:ro + #- /var/log/crowdsec-firewall-bouncer.log:/host/var/log/crowdsec-firewall-bouncer.log:ro + # <<< crowdsec-host-logs <<< + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + environment: + FRONTEND_PATH: /data/frontend + LIBREPORTAL_CONFIG_PATH: /app/libreportal.config + TZ: TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.libreportal-service.entrypoints: web,websecure + traefik.http.routers.libreportal-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.libreportal-service.tls: true + traefik.http.routers.libreportal-service.tls.certresolver: production + traefik.http.services.libreportal-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.libreportal-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 diff --git a/containers/libreportal/frontend/css/apps-layout.css b/containers/libreportal/frontend/css/apps-layout.css new file mode 100644 index 0000000..44e1f4f --- /dev/null +++ b/containers/libreportal/frontend/css/apps-layout.css @@ -0,0 +1,93 @@ +/* Unified Apps Layout Styles. Extracted from apps-unified-layout.html + so all CSS lives under css/ — themes drive colors via the variables + defined in themes//theme.css. */ + +/* Sidebar + main fit the viewport exactly (minus the 60px topbar) + and scroll independently — same pattern Tasks uses, so the sidebar + background paints the full column even when the main content is + shorter than the viewport. */ +.apps-layout { + display: flex; + width: 100%; + height: calc(100vh - 60px); +} + +.sidebar-container { + flex-shrink: 0; + width: 220px; + height: 100%; + background: var(--sidebar-bg); + border-right: 1px solid var(--border-color); + overflow-y: auto; + transition: transform 0.3s ease; +} + +.main-content { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.content-view { + flex: 1; + overflow-y: auto; +} + +#app-detail-view { + display: none; + padding: 22px; +} + +.content-view.active { + display: block; +} + +#apps-view.active { + display: block; +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .apps-layout { + flex-direction: column; + } + + .sidebar-container { + width: 100%; + position: fixed; + top: 60px; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + transform: translateX(-100%); + background: var(--sidebar-bg); + } + + .sidebar-container.mobile-open { + transform: translateX(0); + } + + .main-content { + width: 100%; + } +} + +/* Ensure sidebar stays visible during transitions. Scoped to the + apps layout — on the config page the sidebar is a direct flex + child of .container with its own 220px width from sidebar.css, + and the unscoped width: 100% here was flex-basing the sidebar + to 100% of the container, breaking the config layout. */ +.apps-layout .sidebar { + width: 100%; + height: 100%; + overflow-y: auto; +} + +@media (max-width: 768px) { + #app-detail-view { + padding: 10px; + } +} diff --git a/containers/libreportal/frontend/css/apps.css b/containers/libreportal/frontend/css/apps.css new file mode 100644 index 0000000..ac8c164 --- /dev/null +++ b/containers/libreportal/frontend/css/apps.css @@ -0,0 +1,409 @@ + + +/* App center cards, grid, tags, and detail view. Extracted from style.css. */ + +.apps-section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin: 22px; + padding: 22px; + background: rgba(var(--text-rgb), 0.025); + border: 1px solid var(--border-subtle); + border-radius: 16px; +} + +/* Override grid styling when showing loading content */ +.apps-section .loading-content { + position: absolute !important; + top: 20px !important; + left: 20px !important; + right: 20px !important; + bottom: 0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + width: calc(100% - 40px) !important; + height: calc(100% - 20px) !important; + padding: 60px 20px !important; + background: var(--input-bg) !important; + border: 2px solid var(--border-color) !important; + border-radius: 12px !important; + box-sizing: border-box !important; + margin: 0 !important; + min-height: 400px !important; +} + +/* Make apps-section relative for absolute positioning */ +.apps-section { + position: relative !important; +} + +.app-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--card-shadow); + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; + min-height: 120px; + width: 100%; + position: relative; +} + +.app-card-top { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.app-card-icon { + width: 70px; + height: 70px; + background: rgba(var(--text-rgb), 0.1); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border: 1px solid rgba(var(--text-rgb), 0.2); + flex-shrink: 0; +} + +.app-card-icon svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.app-card-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: 100%; + max-height: 100%; +} + +.app-card-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.app-card-title { + font-size: 16px; + font-weight: 600; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-card-long-description { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.3; + margin-top: 8px; + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; /* Standard property */ + -webkit-box-orient: vertical; + overflow: hidden; + font-style: italic; + width: 100%; +} + +.app-card-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.app-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid; + transition: all 0.2s ease; +} + +/* Category tags - Blue */ +.app-tag.category-tag { + background: rgba(var(--accent-rgb), 0.1); + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.2); +} + +.app-tag.category-tag:hover { + background: rgba(var(--accent-rgb), 0.2); + transform: translateY(-1px); +} + +/* Description tags - White to match title */ +.app-tag.description-tag { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border-color: rgba(var(--text-rgb), 0.2); + font-style: italic; +} + +/* Installed tags - Green. Light pastel green text on a saturated + green pill so the label stays in the green family but actually + reads on dark themes (the default --status-success #28a745 is too + dark at small sizes). Leading ✓ glyph reinforces the state at a + glance. */ +.app-tag.installed-tag { + background: rgba(var(--status-success-rgb), 0.35); + color: #86efac; + border-color: rgba(var(--status-success-rgb), 0.70); + transform: translateY(-2px); +} + +.app-tag.installed-tag::before { + content: '✓'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* Not Installed tags - Gray with a leading ✕ glyph for symmetry with + the ✓ on the installed pill. */ +.app-tag.not-installed-tag { + background: rgba(var(--text-rgb), 0.10); + color: var(--text-secondary); + border-color: rgba(var(--text-rgb), 0.30); +} + +.app-tag.not-installed-tag::before { + content: '✕'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* Clickable tags (category / installed-status) — jump to that filter view */ +.app-tag.clickable { + cursor: pointer; +} + +.app-tag.clickable:hover { + transform: translateY(-1px); +} + +.app-tag img { + width: 12px; + height: 12px; + margin: 0; +} + +.app-card-actions { + display: flex; + gap: 8px; + align-items: stretch; + flex-direction: row; + margin-top: auto; + overflow: visible; + position: relative; +} + +.app-card-actions button { + flex: 1; +} + +.app-card:hover { + transform: translateY(-3px); + border-color: var(--accent) !important; + box-shadow: var(--card-shadow-hover); +} + +.app-card button { + margin-top: 0; + padding: 12px 16px; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + font-size: 14px; + text-align: center; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + white-space: nowrap; +} + +.app-icon { + width: 80px; + height: 80px; + border-radius: 12px; + flex-shrink: 0; +} + +.app-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: 100%; + max-height: 100%; +} + +.app-details .app-description { + font-size: 16px; + color: var(--text-secondary, #ccc); + margin-bottom: 8px; +} + +/* .btn-primary / .btn-secondary / .btn-outline / .btn-danger + live in themes.css with the nebula-glass treatment. + .btn-install / .btn-manage / .btn-uninstall ditto. */ + +/* App-card manage / install button styling lives in themes.css. + The previous block was 6 layers of "ultra-specific" selectors + re-asserting solid-colour fallbacks — all dead now that + themes.css owns the glass treatment with !important. */ + +/* Force green with attribute selectors */ +.app-card-actions button[class*="install-btn"] { + background: var(--status-success) !important; + color: #ffffff !important; + border: 1px solid var(--status-success) !important; +} + +.app-card-actions button[class*="install-btn"]:hover { + background: var(--status-success-hover) !important; + border-color: var(--status-success-hover) !important; +} + +.installed-apps .app-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + transition: all 0.2s ease; + cursor: pointer; +} + +.installed-apps .app-card:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow-hover); + border-color: var(--primary-color); +} + +.installed-apps .app-card-top { + display: flex; + align-items: center; + gap: 16px; +} + +.installed-apps .app-card-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + flex-shrink: 0; +} + +.installed-apps .app-card-icon img { + width: 32px; + height: 32px; + object-fit: contain; +} + +.installed-apps .app-card-content { + flex: 1; + min-width: 0; +} + +.installed-apps .app-card-title { + font-size: 16px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 4px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.installed-apps .app-card-description { + font-size: 14px; + color: var(--text-secondary, #ccc); + margin: 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; /* Standard property */ + -webkit-box-orient: vertical; + overflow: hidden; +} + +.installed-apps .app-card-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.installed-apps .app-card-actions .manage-btn { + display: flex; + align-items: center; + gap: 6px; + background: var(--accent) !important; + color: var(--text-primary) !important; + border: 1px solid var(--accent) !important; + border-radius: 6px; + font-size: 12px; + font-weight: 600 !important; + cursor: pointer; + transition: all 0.2s; +} + +.installed-apps .app-card-actions .manage-btn:hover { + background: var(--accent-hover) !important; + border-color: var(--accent-hover) !important; + transform: translateY(-1px) !important; +} + +/* Extra specific rules for index page manage button */ +.app-card-actions .btn-loading.manage-btn { + color: transparent !important; +} + +.app-card-actions .btn-loading.manage-btn * { + opacity: 0 !important; + visibility: hidden !important; +} + +@media (max-width: 768px) { + .apps-section { + margin: 10px; + padding: 12px; + gap: 12px; + } + + /* Install screen action buttons stack full-width below the console. */ + .console-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; + } + + .console-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/containers/libreportal/frontend/css/aurora-background.css b/containers/libreportal/frontend/css/aurora-background.css new file mode 100644 index 0000000..145aa63 --- /dev/null +++ b/containers/libreportal/frontend/css/aurora-background.css @@ -0,0 +1,253 @@ +/* + Shared "aurora" background — cool sparkly-blue swirling water. + Applied to both the login overlay and the loading screen so they share + one visual identity. + + Usage: a container element gets `.aurora-bg`. Two pseudo-element layers + paint: + ::before — slowly rotating conic-gradient swirl + ::after — drifting radial-gradient "blobs" + Plus a separate `.aurora-stars` overlay child for sparkle twinkles + (kept as a real element so its animation can stack on top of pseudo + layers without z-index gymnastics). +*/ + +.aurora-bg { + background: + radial-gradient(ellipse at 20% 30%, var(--gradient-mid) 0%, transparent 55%), + radial-gradient(ellipse at 80% 70%, var(--gradient-to) 0%, transparent 55%), + linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-from) 40%, var(--gradient-mid) 100%); + overflow: hidden; + isolation: isolate; +} + +/* Pair with `.aurora-bg` only if the host element isn't already + positioned. Login overlay and loading screen are both `position: fixed` + so the pseudo-elements anchor correctly without us overriding their + positioning. */ + +/* Slow conic swirl — the "current" of the water */ +.aurora-bg::before { + content: ''; + position: absolute; + inset: -25%; + background: conic-gradient( + from 0deg at 50% 50%, + rgba(var(--accent-rgb), 0.0) 0deg, + rgba(var(--accent-rgb), 0.18) 60deg, + rgba(var(--accent-rgb), 0.22) 130deg, + rgba(var(--accent-rgb), 0.20) 200deg, + rgba(var(--accent-rgb), 0.18) 280deg, + rgba(var(--accent-rgb), 0.0) 360deg + ); + filter: blur(60px); + animation: auroraSpin 38s linear infinite; + z-index: -2; + pointer-events: none; +} + +/* Drifting glow blobs — the "sparkly" volumes */ +.aurora-bg::after { + content: ''; + position: absolute; + inset: -10%; + background: + radial-gradient(circle at 18% 22%, rgba(var(--accent-rgb), 0.45) 0%, transparent 35%), + radial-gradient(circle at 78% 18%, rgba(var(--accent-rgb), 0.40) 0%, transparent 32%), + radial-gradient(circle at 30% 78%, rgba(var(--accent-rgb), 0.38) 0%, transparent 38%), + radial-gradient(circle at 82% 80%, rgba(var(--accent-rgb), 0.45) 0%, transparent 36%), + radial-gradient(circle at 50% 50%, rgba(var(--accent-rgb), 0.18) 0%, transparent 50%); + filter: blur(40px); + animation: auroraDrift 22s ease-in-out infinite alternate; + z-index: -1; + pointer-events: none; +} + +@keyframes auroraSpin { + to { transform: rotate(360deg); } +} + +@keyframes auroraDrift { + 0% { transform: translate(0, 0) scale(1); opacity: 0.85; } + 50% { transform: translate(-3%, 4%) scale(1.08); opacity: 1; } + 100% { transform: translate(2%, -3%) scale(0.95); opacity: 0.9; } +} + +/* + Sparkles. We render a tiny tiling of radial-gradient dots and + modulate opacity so they twinkle. Two staggered layers feel less + uniform than one. +*/ +.aurora-stars { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} + +.aurora-stars::before, +.aurora-stars::after { + content: ''; + position: absolute; + inset: 0; + background-repeat: repeat; +} + +.aurora-stars::before { + background-image: + radial-gradient(1.5px 1.5px at 12px 18px, rgba(var(--text-rgb),0.9), transparent 60%), + radial-gradient(1px 1px at 47px 92px, rgba(var(--accent-rgb),0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 110px 40px, rgba(var(--text-rgb),0.75), transparent 60%), + radial-gradient(1px 1px at 165px 130px, rgba(var(--accent-rgb),0.7), transparent 60%); + background-size: 200px 200px; + animation: auroraTwinkleA 4.5s ease-in-out infinite; +} + +.aurora-stars::after { + background-image: + radial-gradient(1px 1px at 30px 60px, rgba(var(--accent-rgb),0.8), transparent 60%), + radial-gradient(1.4px 1.4px at 88px 22px, rgba(var(--text-rgb),0.7), transparent 60%), + radial-gradient(1px 1px at 140px 100px, rgba(var(--accent-rgb),0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 195px 70px, rgba(var(--text-rgb),0.6), transparent 60%); + background-size: 240px 240px; + background-position: 80px 50px; + animation: auroraTwinkleB 6.5s ease-in-out infinite; +} + +@keyframes auroraTwinkleA { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +@keyframes auroraTwinkleB { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* Children of an aurora surface should sit above the FX layers */ +.aurora-bg > :not(.aurora-stars) { + position: relative; + z-index: 2; +} + +/* Shared header used by both the loading screen and the login overlay + so the two surfaces have identical branding. */ +.aurora-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.aurora-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; +} + +.aurora-logo img { + width: 48px; + height: 48px; + /* Fade in once the SVG actually loads — avoids the brief broken-image + flash on first paint. The .loaded class is added by the inline onload + handler on the img tag in each surface that uses this header. */ + opacity: 0; + transition: opacity 0.45s ease; +} + +.aurora-logo img.loaded { + opacity: 1; +} + +.aurora-logo h1 { + font-size: 3rem; + font-weight: 700; + margin: 0; + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.01em; +} + +.aurora-subtitle { + margin: 0.6rem 0 0 0; + font-size: 1.05rem; + font-weight: 300; + color: var(--text-secondary); + font-style: italic; + letter-spacing: 0.01em; +} + +/* Shrink the header on phones — 3rem title + 48px icon swallow the + viewport on small screens. */ +@media (max-width: 480px) { + .aurora-header { + margin-bottom: 1.5rem; + } + + .aurora-logo { + gap: 10px; + } + + .aurora-logo img { + width: 36px; + height: 36px; + } + + .aurora-logo h1 { + font-size: 2.1rem; + } + + .aurora-subtitle { + font-size: 0.9rem; + margin-top: 0.4rem; + padding: 0 12px; + } +} + +/* Respect reduced-motion — drop the animations but keep the gradient */ +@media (prefers-reduced-motion: reduce) { + .aurora-bg::before, + .aurora-bg::after, + .aurora-stars::before, + .aurora-stars::after { + animation: none; + } +} + +/* + Static modifier — pair with `.aurora-bg` to keep the gradient identity + but drop the per-frame work that was making login / loading / setup + sluggish on lower-power systems. We kill the animations only — the + blur filter is kept because, without the rotation, the blur layer + rasterises exactly once at first paint and then just composites each + frame for free. Removing the blur entirely created visible banding + and a dark seam where the conic gradient wraps from 360° back to 0°. +*/ +.aurora-bg.aurora-static::before, +.aurora-bg.aurora-static::after, +.aurora-bg.aurora-static .aurora-stars::before, +.aurora-bg.aurora-static .aurora-stars::after { + animation: none !important; +} + +/* dark-blue and light themes get a flat, solid loading/login surface + — the cyan swirl + glow blobs + stars belong to the Nebula identity. */ +html[data-theme="dark-blue"] .aurora-bg::before, +html[data-theme="dark-blue"] .aurora-bg::after, +html[data-theme="dark-blue"] .aurora-stars::before, +html[data-theme="dark-blue"] .aurora-stars::after, +html[data-theme="light"] .aurora-bg::before, +html[data-theme="light"] .aurora-bg::after, +html[data-theme="light"] .aurora-stars::before, +html[data-theme="light"] .aurora-stars::after { + display: none; +} + +/* Same suppression on the body-level layers Nebula uses. */ +html[data-theme="dark-blue"]::before, +html[data-theme="dark-blue"]::after, +html[data-theme="light"]::before, +html[data-theme="light"]::after { + display: none; +} diff --git a/containers/libreportal/frontend/css/backup.css b/containers/libreportal/frontend/css/backup.css new file mode 100755 index 0000000..bcb5e05 --- /dev/null +++ b/containers/libreportal/frontend/css/backup.css @@ -0,0 +1,1063 @@ +/* Backup Page — restic-engine UI */ + +.backup-layout { + display: flex; + min-height: calc(100vh - var(--topbar-height, 60px)); +} + +.backup-layout .main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +.backup-page { + color: var(--text-primary); + width: 100%; + padding-bottom: 48px; +} + +/* The whole backup page is one .config-section card containing both the + .page-header and the body. Remove the card's inner padding so the + .page-header sits flush at the top and its border-bottom acts as a + full-width divider; the body gets its own padding. */ +.backup-page-section { + padding: 0; + overflow: hidden; +} + +.backup-page-section > .page-header { + margin-bottom: 0; +} + +.backup-page-body { + padding: 22px; +} + +/* Configuration tab embeds /config's renderConfig, which emits its own + .page-header. The outer backup page already has one, so suppress the + embedded one to avoid the duplicate "Backup" heading. */ +.backup-embedded-config > .page-header { + display: none; +} + +/* SVG icon slot inside the shared .page-header (defined in config.css). */ +.page-header-icon-slot { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.page-header-icon-slot svg { + width: 32px; + height: 32px; +} + +.backup-engine-badge { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); + padding: 4px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.backup-primary-btn, +.backup-secondary-btn, +.backup-danger-btn, +.backup-refresh-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 8px; + border: none; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.backup-primary-btn { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.25); +} + +.backup-primary-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(var(--accent-rgb), 0.35); +} + +.backup-secondary-btn, +.backup-refresh-btn { + background: rgba(var(--text-rgb), 0.06); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.12); +} + +.backup-secondary-btn:hover, +.backup-refresh-btn:hover { + background: rgba(var(--text-rgb), 0.1); +} + +.backup-danger-btn { + background: linear-gradient(135deg, #dc2626, #b91c1c); + color: #fff; +} + +.backup-danger-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(220, 38, 38, 0.35); +} + +.backup-tabpanel { + display: none; +} + +.backup-tabpanel.active { + display: block; + animation: backupFadeIn 0.25s ease; +} + +@keyframes backupFadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.backup-summary-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.backup-summary-tile { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 20px; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.backup-summary-tile:hover { + transform: translateY(-2px); + border-color: rgba(var(--accent-rgb), 0.35); +} + +.backup-summary-tile-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + margin-bottom: 8px; +} + +.backup-summary-tile-value { + font-size: 1.6rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.backup-summary-tile-detail { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + margin-top: 4px; +} + +.backup-cards-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; +} + +@media (max-width: 980px) { + .backup-cards-row { + grid-template-columns: 1fr; + } +} + +.backup-card { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 22px; +} + +.backup-card-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.backup-card-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.backup-card-hint { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); +} + +.backup-app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} + +.backup-app-tile { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + padding: 12px 14px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.backup-app-tile:hover { + border-color: rgba(var(--accent-rgb), 0.4); + transform: translateY(-1px); +} + +.backup-app-tile-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 8px; + object-fit: cover; + background: rgba(var(--text-rgb), 0.05); +} + +.backup-app-tile-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.backup-app-tile-name { + font-weight: 600; + font-size: 0.95rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backup-app-tile-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.backup-status-dot.ok { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); } +.backup-status-dot.warn { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); } +.backup-status-dot.fail { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } +.backup-status-dot.none { background: rgba(var(--text-rgb), 0.25); } + +.backup-repo-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.backup-repo-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 10px; + gap: 12px; +} + +.backup-repo-row-name { + font-weight: 600; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 8px; +} + +.backup-repo-row-meta { + font-size: 0.75rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + text-align: right; +} + +.backup-repo-type-pill { + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backup-repo-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +/* Locations list — expandable rows mirroring the Tasks page .task-item shell */ +.backup-location-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.backup-location-row.task-item { + margin-bottom: 0; +} + +.backup-location-header { + padding: 18px 22px; + gap: 14px; + user-select: none; + min-height: 64px; +} + +.backup-location-row-type-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.backup-location-row-type-icon[data-type="sftp"] { color: #818cf8; } +.backup-location-row-type-icon[data-type="rest"], +.backup-location-row-type-icon[data-type="s3"], +.backup-location-row-type-icon[data-type="b2"], +.backup-location-row-type-icon[data-type="gs"], +.backup-location-row-type-icon[data-type="azure"], +.backup-location-row-type-icon[data-type="rclone"] { color: #38bdf8; } + +/* Status pill — mirrors the task-status pill on the Tasks page so the + visual language is consistent. */ +.task-status.backup-loc-status { + padding: 3px 8px; + border-radius: 999px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + border: 1px solid transparent; + line-height: 1.3; +} + +.task-status.backup-loc-status.status-ready { + background: rgba(var(--status-success-rgb), 0.30); + border-color: rgba(var(--status-success-rgb), 0.65); + color: #86efac; +} + +.task-status.backup-loc-status.status-init { + background: rgba(var(--status-warning-rgb), 0.22); + border-color: rgba(var(--status-warning-rgb), 0.60); + color: #fcd34d; +} + +.task-status.backup-loc-status.status-disabled { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--text-rgb), 0.18); + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-location-row-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.backup-location-row-name { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +.backup-location-row-status-pill { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-primary); +} + +.backup-location-row-stat { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + white-space: nowrap; +} + +.backup-location-row-sep { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.35)); +} + +.backup-pill-mini { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Hide separator + stat tokens on narrow screens to keep the row from + wrapping — the expanded details still show full info. */ +@media (max-width: 720px) { + .backup-location-row-sep, + .backup-location-row-stat { + display: none; + } +} + +.backup-location-chevron { + flex-shrink: 0; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + transition: transform 0.2s ease; +} + +.backup-location-row.expanded .backup-location-chevron { + transform: rotate(180deg); +} + +.backup-location-details { + padding: 16px 20px 20px; +} + +.backup-location-details > .config-category:first-of-type { + margin-top: 0; +} + +.backup-location-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-modal-wide .backup-modal-inner, +.backup-modal-inner.backup-modal-wide { + max-width: 640px; +} + +.backup-modal-wide { + /* selector also catches when class is on inner */ +} + +.backup-modal-inner.backup-modal-wide, +#backup-location-modal .backup-modal-inner { + max-width: 640px; + width: min(92vw, 640px); +} + +.backup-repo-card { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 22px; +} + +.backup-repo-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.backup-repo-card-title { + font-weight: 600; + font-size: 1.05rem; + display: flex; + align-items: center; + gap: 10px; +} + +.backup-repo-disabled-pill { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + padding: 3px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.backup-repo-enabled-pill { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.backup-repo-detail { + display: grid; + grid-template-columns: 110px 1fr; + gap: 6px 12px; + font-size: 0.82rem; + margin-bottom: 12px; +} + +.backup-repo-detail-key { + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); +} + +.backup-repo-detail-value { + color: var(--text-primary); + word-break: break-all; +} + +.backup-snapshot-table-wrap { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + overflow: hidden; +} + +.backup-snapshot-table { + width: 100%; + border-collapse: collapse; +} + +.backup-snapshot-table th, +.backup-snapshot-table td { + padding: 12px 16px; + text-align: left; + font-size: 0.875rem; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-snapshot-table th { + background: rgba(var(--text-rgb), 0.04); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-snapshot-table tbody tr:hover { + background: rgba(var(--accent-rgb), 0.04); +} + +.backup-col-actions { + width: 180px; + text-align: right; +} + +.backup-snapshot-id { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.82rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.7)); +} + +.backup-row-action-btn { + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border-radius: 6px; + padding: 5px 10px; + font-size: 0.78rem; + cursor: pointer; + margin-left: 6px; + transition: all 0.15s ease; +} + +.backup-row-action-btn:hover { + background: rgba(var(--accent-rgb), 0.12); + border-color: var(--accent); + color: var(--accent); +} + +.backup-row-action-btn.danger:hover { + background: rgba(239, 68, 68, 0.12); + border-color: #ef4444; + color: #ef4444; +} + +.backup-filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +@media (max-width: 600px) { + .backup-filters { + grid-template-columns: 1fr; + } +} + +.backup-filter-input, +.backup-filter-select { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 8px; + padding: 9px 12px; + color: var(--text-primary); + font-size: 0.875rem; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.backup-filter-input:focus, +.backup-filter-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15); +} + +/* Inline forms inside backup cards */ +.backup-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px 20px; + margin-top: 4px; +} + +.backup-form-row { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.875rem; +} + +.backup-form-row-toggle { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 0; +} + +.backup-form-label { + color: var(--text-secondary, rgba(var(--text-rgb), 0.7)); + font-weight: 500; + font-size: 0.82rem; +} + +.backup-form-readonly { + font-family: ui-monospace, SFMono-Regular, monospace; + color: var(--text-primary); + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + padding: 9px 12px; + font-size: 0.85rem; +} + +.backup-form-section-title { + margin-top: 22px; + margin-bottom: 10px; + font-weight: 600; + font-size: 0.85rem; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.backup-form-section-title .backup-card-hint { + text-transform: none; + letter-spacing: normal; + margin-left: 8px; + font-weight: 400; +} + +.backup-retention-block { + grid-template-columns: 1fr; +} + +.backup-retention-preset-block { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 14px; + margin-bottom: 6px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-retention-hint { + margin-top: -4px; + font-style: italic; +} + +.backup-retention-advanced[hidden] { + display: none; +} + +.backup-retention-advanced { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed rgba(var(--text-rgb), 0.08); +} + +.backup-form-footer { + display: flex; + justify-content: flex-end; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(var(--text-rgb), 0.06); +} + +/* iOS-style toggle */ +.backup-toggle { + position: relative; + width: 38px; + height: 22px; + flex-shrink: 0; +} + +.backup-toggle input { + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; + position: absolute; + inset: 0; + margin: 0; + z-index: 1; +} + +.backup-toggle-slider { + position: absolute; + inset: 0; + background: rgba(var(--text-rgb), 0.15); + border-radius: 999px; + transition: background 0.18s ease; +} + +.backup-toggle-slider::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.18s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} + +.backup-toggle input:checked + .backup-toggle-slider { + background: var(--accent); +} + +.backup-toggle input:checked + .backup-toggle-slider::after { + transform: translateX(16px); +} + +/* Repo card extras */ +.backup-repo-stats { + display: flex; + gap: 18px; + flex-wrap: wrap; + padding: 10px 14px; + background: rgba(var(--text-rgb), 0.04); + border-radius: 10px; + margin-bottom: 16px; + font-size: 0.82rem; +} + +.backup-repo-stat-label { + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + margin-right: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; +} + +.backup-warning-banner { + margin-top: 18px; + padding: 14px 16px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: 10px; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 16px; +} + +.backup-warning-banner-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.backup-warning-banner [data-action="export-passwords"] { + flex-shrink: 0; + white-space: nowrap; +} + +.backup-warning-banner [data-action="export-passwords"][data-busy="1"] { + opacity: 0.6; + cursor: progress; +} + +.backup-warning-banner strong { + color: #f59e0b; +} + +.backup-warning-banner code { + background: rgba(var(--text-rgb), 0.06); + padding: 4px 8px; + border-radius: 6px; + font-size: 0.8rem; + color: var(--text-primary); + display: inline-block; + margin-top: 4px; +} + +.backup-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.backup-modal.open { + display: flex; +} + +/* Slightly translucent — backdrop blur still shows through, but enough + alpha to keep the modal legible on busy gradients. */ +.backup-modal-inner { + background: color-mix(in srgb, var(--surface-bg-solid, #1a1d24) 78%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(var(--text-rgb), 0.14); + border-radius: 14px; + width: 90%; + max-width: 460px; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); +} + +.backup-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-modal-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.backup-modal-close { + background: none; + border: none; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + font-size: 1.4rem; + cursor: pointer; + line-height: 1; + padding: 0 6px; +} + +.backup-modal-body { + padding: 18px 20px; + font-size: 0.9rem; + color: var(--text-primary); + max-height: 60vh; + overflow-y: auto; +} + +.backup-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-empty-state { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + font-size: 0.9rem; +} + +.backup-engine-input-row { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 10px; + width: 100%; +} + +.backup-engine-input-row > *:not(.backup-engine-details-btn) { + flex: 1 1 0%; + width: auto; + min-width: 0; + max-width: none; +} + +.backup-engine-input-row > .custom-select { + display: block; +} + +.backup-engine-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 0 0 auto; + padding: 0 16px; + min-height: 44px; + white-space: nowrap; + line-height: 1; +} + +/* Engine name pill next to the type pill on each location row. */ +.backup-engine-pill { + background: rgba(var(--accent-rgb), 0.16); + color: var(--accent); + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backup-engine-pill[data-engine="borg"] { + background: rgba(245, 158, 11, 0.18); + color: #f59e0b; +} + +.backup-engine-pill[data-engine="kopia"] { + background: rgba(99, 102, 241, 0.18); + color: #818cf8; +} + +/* Engine details modal. */ +.backup-engine-modal-head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; +} + +.backup-engine-logo { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); +} + +.backup-engine-modal-head h4 { + margin: 0 0 4px 0; + font-size: 1.1rem; + font-weight: 600; +} + +.backup-engine-props { + width: 100%; + border-collapse: collapse; + margin-bottom: 18px; +} + +.backup-engine-props th, +.backup-engine-props td { + padding: 8px 10px; + text-align: left; + font-size: 0.85rem; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-engine-props th { + width: 35%; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + font-weight: 500; +} + +.backup-engine-features { + margin: 6px 0 18px; + padding-left: 22px; + font-size: 0.88rem; + line-height: 1.55; +} + +.backup-engine-features li { + margin-bottom: 4px; +} + +.backup-engine-docs-link { + color: var(--accent); + text-decoration: none; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.85rem; +} + +.backup-engine-docs-link:hover { + text-decoration: underline; +} + +/* Per-app backup status badge (used on app detail page) */ +.backup-app-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 999px; + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.65)); +} diff --git a/containers/libreportal/frontend/css/config.css b/containers/libreportal/frontend/css/config.css new file mode 100644 index 0000000..7da081e --- /dev/null +++ b/containers/libreportal/frontend/css/config.css @@ -0,0 +1,601 @@ + + +/* Config page forms, domain blocks, danger zone, toggles, warning banners. Extracted from style.css. */ + +/* Simple Configuration Tabs - Clean Design */ +.config-actions { + display: flex; + gap: 12px; + padding: 20px 20px 20px 0; + background: transparent; + margin-top: 0; + flex-wrap: wrap; +} + +.config-actions .action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + text-decoration: none; +} + +.config-actions .action-btn svg { + width: 16px; + height: 16px; +} + +.config-actions .action-btn.primary { + background: var(--primary-color); + color: var(--text-primary); + border: 1px solid var(--primary-color); +} + +.config-actions .action-btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} + +.config-actions .action-btn.secondary { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + color: var(--text-primary); +} + +.config-actions .action-btn.secondary:hover { + background: rgba(var(--text-rgb), 0.2); + transform: translateY(-1px); +} + +.config-actions .action-btn:not(.primary):not(.secondary) { + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-primary, #fff); +} + +.config-actions .action-btn:not(.primary):not(.secondary):hover { + background: var(--hover-bg); + transform: translateY(-1px); +} + +.config-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.config-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.config-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +/* Page-level header used on /config and /backup so both surfaces start + with the same prominent H1 + description above their content. */ +.page-header { + display: flex; + align-items: center; + gap: 16px; + padding: 22px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); + margin-bottom: 22px; + flex-wrap: wrap; + width: 100%; + box-sizing: border-box; +} + +.page-header-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); +} + +.page-header-title { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1; +} + +.page-header-title h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.page-header-title p { + margin: 0; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + font-size: 0.9rem; +} + +.page-header-title p:empty { + display: none; +} + +.page-header-actions { + display: flex; + gap: 10px; + flex-shrink: 0; + align-items: center; +} + +.config-container { + border-radius: 12px; + padding: 0; + margin-bottom: 20px; + overflow: hidden; +} + +/* "Unsaved config changes" bar — sits in flow between an app's config content + and its action buttons, shown by JS while any field differs from its saved + value. */ +.config-dirty-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 8px 0 16px; + padding: 12px 16px; + background: var(--card-bg, #1c1c24); + border: 1px solid var(--border-color, #333); + border-left: 3px solid var(--status-warning); + border-radius: 10px; + animation: configDirtyIn 0.2s ease; +} + +.config-dirty-msg { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + font-weight: 600; + color: var(--status-warning); +} + +.config-dirty-msg svg { flex-shrink: 0; } + +.config-dirty-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.advanced-warning-banner .warning-content h4 { + margin: 0 0 4px 0; + color: var(--status-warning); + font-size: 14px; + font-weight: 600; +} + +.advanced-warning-banner .warning-content p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; +} + +/* Traefik / domains "not installed" notice — glass card tinted with + the theme warning colour. Matches the .system-card recipe. */ +.traefik-warning-banner { + background: rgba(var(--status-warning-rgb), 0.10); + border: 1px solid rgba(var(--status-warning-rgb), 0.30); + border-left: 4px solid var(--status-warning); + border-radius: 12px; + padding: 14px 18px; + margin-bottom: 18px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.traefik-warning-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.traefik-warning-icon { + flex-shrink: 0; + font-size: 18px; + line-height: 1.4; + color: var(--status-warning); +} + +.traefik-warning-text { + flex: 1; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} + +.traefik-warning-text strong { + color: var(--status-warning); + font-weight: 600; + margin-right: 2px; +} + +/* Configuration Form */ +.config-form { + padding: 0; + margin: 0; +} + +.config-form h3 { + font-size: 20px; + font-weight: 600; + margin-bottom: 2px; +} + +.config-group { +} + +.config-group h4 { + font-size: 16px; + font-weight: 600; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 2px solid; +} + +.config-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + align-items: start; + grid-auto-flow: dense; + grid-auto-rows: minmax(min-content, max-content); +} + +/* Responsive column counts — colocated here (and not in style.css) + because the base 3-column rule above loads later than style.css, + so the unscoped base would otherwise win the cascade against the + style.css media queries that try to step down to 2 / 1 columns. */ +@media (max-width: 1280px) { + .config-fields { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .config-fields { + grid-template-columns: 1fr; + gap: 12px; + } +} + +.config-field { + display: flex; + flex-direction: column; + gap: 8px; + min-height: fit-content; +} + +.master-toggle, + +.master-toggle .field-group, +.git-master-toggle .field-group { + margin: 0; + width: 100%; +} + +.master-toggle input[type="checkbox"], +.git-master-toggle input[type="checkbox"] { + display: none; +} + +.config-field label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.config-field input, +.config-field select { + padding: 10px 12px; + border: 1px solid; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.config-field input:focus, +.config-field select:focus { + outline: none; + border-color: var(--accent); +} + +/* Enhanced Config Form Styles */ +.config-group { +} + +.config-group h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #fff); + margin: 0 0 8px 0; + padding-bottom: 8px; + border-bottom: 2px solid var(--primary-color, var(--accent)); +} + +.config-actions { + display: flex; + gap: 12px; + padding-top: 24px; + margin-top: 0px; +} + +.git-master-toggle .master-toggle { + display: flex; + align-items: center; + padding: 8px 0; + background: transparent; + border: none; + border-radius: 0; + cursor: pointer; + transition: none; +} + +.git-master-toggle .master-toggle:hover { + background: rgba(var(--text-rgb), 0.05); + transform: none; +} + +/* System Config Sections - Override domain-specific CSS for non-domain configs */ +.config-category:not(.app-config) .domains-header { + display: block; + margin-bottom: 16px; +} + +.config-category:not(.app-config) .domains-header h3 { + display: block; + width: auto; + justify-content: flex-start; +} + +/* Each subcategory on the global config page renders as its own glass + card, matching the .app-card recipe used on the App Center grid. + The outer .config-section becomes a transparent layout wrapper so + the categories aren't double-nested in two card chromes. */ +.config-category:not(.app-config) { + background: var(--card-bg); + border: 1px solid var(--card-border, var(--border-color)); + border-radius: 12px; + padding: 20px; + box-shadow: var(--card-shadow); + margin-bottom: 16px; +} + +.config-category:not(.app-config) .spacer-lg { + display: none; +} + +/* Advanced subcategory badge — applied to subcategories rendered + inside #advanced-sections. The old "🛠️ Advanced Configuration" + divider above the group is gone; each subcategory's own h3 now + carries an inline red "ADVANCED" tag so it self-identifies. */ +.is-advanced .domains-header h3, +.is-advanced .config-category h3 { + display: block; + width: 100%; + margin: 0; +} + +.is-advanced .domains-header h3::after, +.is-advanced .config-category h3::after { + content: 'Advanced'; + display: inline-block; + vertical-align: middle; + margin-left: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 4px; + background: rgba(var(--status-danger-rgb), 0.18); + border: 1px solid rgba(var(--status-danger-rgb), 0.55); + color: var(--status-danger); + line-height: 1; + position: relative; + top: -1px; +} + +/* App Config Sections - Complete separation from domain/system styling */ +.app-config { + /* App config container styling */ +} + +.app-config .panel-fields { + display: flex; + flex-direction: column; + gap: 16px; +} + +.domain-input { + background: var(--card-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 12px 16px; + font-size: 16px; + font-weight: 500; + transition: all 0.3s ease; +} + +.domain-input:focus { + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.1); + outline: none; +} + +.domain-building-blocks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + align-items: start; + grid-auto-flow: dense; + grid-auto-rows: minmax(min-content, max-content); +} + +.domain-building-block { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + min-height: fit-content; + gap: 12px; +} + +.domain-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.domain-actions { + padding: 10px 0; + display: flex; + justify-content: flex-start; + align-items: flex-start; +} + +.domain-input.flash { + animation: flash 0.5s ease-in-out 2; +} + +/* Wrapper around the whole config-page content — matches the visual + "card" the App Center uses for its grid, so the two pages feel + consistent (subtle glass surface + soft border + rounded corners + floating on the cosmic gradient). */ +.config-section { + margin: 22px; + padding: 22px; + background: rgba(var(--text-rgb), 0.025); + border: 1px solid var(--border-subtle); + border-radius: 16px; +} + +/* Inside an app-detail tab-pane the .config-section's card chrome is + redundant — the tab-pane is itself a card and the sibling tabs + (Services / Tools / Backups / …) drop their content directly into + the pane without an extra wrapper. Strip the inner card so the + Config tab matches the rest. */ +.tab-pane .config-section { + margin: 0; + padding: 0; + background: transparent; + border: none; +} + +/* ===== Mobile (≤768px) — global Config page ===== */ +@media (max-width: 768px) { + .config-section { + margin: 10px; + padding: 12px; + /* flex-shrink: 0 keeps the section at its content's natural height + inside .main's flex column — without it, the section's overflow + (combined with .main's flex-shrink) collapsed the section down to + the .main viewport height and the vertical scrollbar never + appeared. overflow-x: hidden still clips wide grandchildren. */ + overflow-x: hidden; + flex-shrink: 0; + } + + .config-category:not(.app-config) { + padding: 22px; + } + + .config-section, + .config-form, + .config-category, + .config-group, + .config-fields, + .config-field, + .field-group, + .domains-wrapper, + .domains-header, + .password-input-group { + min-width: 0; + max-width: 100%; + } + + .field-group, + .config-field { + width: 100%; + } + + .config-field input, + .config-field select, + .config-field textarea, + .field-group input, + .field-group select, + .field-group textarea, + .form-input, + .form-select, + .form-textarea, + .form-control { + min-width: 0; + max-width: 100%; + width: 100%; + } + + .config-form { + margin: 0; + padding: 0; + } + + .config-title { + padding: 14px; + } + + .config-title h3 { + font-size: 16px; + } + + .config-container { + margin-bottom: 12px; + } + + .config-fields { + grid-template-columns: 1fr; + gap: 12px; + } + + .config-actions { + padding: 14px 0; + gap: 8px; + } + + .config-actions .action-btn { + flex: 1 1 100%; + justify-content: center; + padding: 12px 16px; + } + + .config-dirty-bar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } +} diff --git a/containers/libreportal/frontend/css/dashboard.css b/containers/libreportal/frontend/css/dashboard.css new file mode 100644 index 0000000..e28362a --- /dev/null +++ b/containers/libreportal/frontend/css/dashboard.css @@ -0,0 +1,62 @@ +/* Dashboard page styling. Extracted from dashboard-content.html so all + CSS lives under css/. Currently only mobile-responsive overrides. */ + +@media (max-width: 768px) { + .section-header { + display: flex; + flex-direction: column; + gap: 15px; + } + + .header-row { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + .header-left { + display: flex; + align-items: center; + flex: 1; + } + + .header-left h2 { + margin: 0; + font-size: 1.2rem; + } + + .install-btn { + padding: 8px 12px; + font-size: 0.9rem; + white-space: nowrap; + margin-left: auto; + flex-shrink: 0; + } + + .filter-controls { + display: flex; + flex-direction: column; + gap: 10px; + } + + .search-input, + .category-filter { + width: 100%; + margin-bottom: 10px; + } + + /* Remove the bottom flex-stretch on the dashboard page only. + Scoped to .dashboard-main (added in dashboard-content.html) so + this never leaks onto Config / Tasks / Apps, where their inner + .main needs flex: 1 to fill the column next to the sidebar. */ + .dashboard-main { + flex: none !important; + margin-bottom: 0 !important; + } + + .dashboard-content { + margin-bottom: 0; + padding-bottom: 0; + } +} diff --git a/containers/libreportal/frontend/css/forms.css b/containers/libreportal/frontend/css/forms.css new file mode 100644 index 0000000..b5625e2 --- /dev/null +++ b/containers/libreportal/frontend/css/forms.css @@ -0,0 +1,1042 @@ + + +/* Form fields, inputs, checkboxes/tickboxes, input groups. Extracted from style.css. */ + +/* Form Fields */ +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.form-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--text-primary, #fff); + font-size: 13px; +} + +.required { + color: var(--danger-color); + font-weight: bold; +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background: var(--primary-color); + color: var(--text-primary); + border-radius: 50%; + font-size: 9px; + font-weight: bold; + cursor: help; + position: relative; +} + +.help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--tooltip-bg); + color: var(--tooltip-text); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + z-index: 1000; + margin-bottom: 6px; + min-width: 180px; + text-align: center; + font-weight: normal; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary, #fff); + font-size: 13px; + transition: all 0.2s ease; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 80px; + font-family: inherit; +} + +.form-help { + color: var(--text-secondary, #ccc); + font-size: 11px; + font-style: italic; + margin-top: 4px; +} + +.checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); + opacity: 0.5; +} + +.checkbox-label.disabled .checkbox-text { + color: var(--text-secondary, #ccc); +} + +.form-label.disabled { + color: var(--text-secondary, #ccc); + opacity: 0.6; +} + +.form-input.disabled { + background: var(--hover-bg); + border-color: var(--border-color); + opacity: 0.6; + cursor: not-allowed; +} + +/* Input Group for Number + Unit */ +.input-group { + display: flex; + align-items: stretch; +} + +.input-group .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; +} + +.input-group .form-control:focus { + border-right: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(var(--accent-rgb), 0.25); +} + +.input-group-text { + display: flex; + align-items: center; + padding: 10px 12px; + font-size: 14px; + font-weight: 500; + line-height: 1.5; + color: var(--text-color); + text-align: center; + white-space: nowrap; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + min-width: 50px; + justify-content: center; +} + +.master-toggle .checkbox-label, +.git-master-toggle .checkbox-label { + display: flex; + align-items: center; + font-weight: 600; + font-size: 16px; + cursor: pointer; + width: 100%; +} + +/* .master-toggle and .git-master-toggle are section-enable controls + (e.g. backup remote 1, mail config). They inherit the default toggle + visual from .checkbox-custom — no overrides needed here. The + .git-master-toggle block lower in this file remains for legacy + layout (font-weight/sizing on the surrounding label). */ + +.section-content.disabled .field-group { + opacity: 0.6; +} + +/* Themed in a .custom-select div — we have to constrain the + wrapper (which is what's visually rendered) AND the native select + (which is what's there when JS hasn't enhanced it yet, or when + custom-select is opted out via data-no-enhance). */ +.git-master-toggle select.form-control, +.git-master-toggle .custom-select { + width: auto !important; + max-width: 33%; +} + +.git-master-toggle .checkbox-text { + font-weight: 600; + font-size: 16px; + margin-left: 8px; +} + +.git-master-toggle .checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 0; + border-radius: 0; + transition: none; + background: transparent; +} + +.git-master-toggle .checkbox-label:hover { + background: transparent; +} + +/* Toggle Switch Design - Complete override */ +.git-master-toggle .checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; +} + +.git-master-toggle .checkbox-custom { + position: relative; + width: 48px; + height: 24px; + background: var(--border-strong); + border-radius: 24px; + margin-right: 12px; + transition: all 0.3s ease; + cursor: pointer; + border: none !important; +} + +.git-master-toggle .checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: all 0.3s ease; +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--primary-color) !important; + border-color: var(--primary-color) !important; +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { +} + +.git-master-toggle .checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +/* Config page checkboxes */ +.checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 10px 16px; + min-height: 44px; + border-radius: 10px; + transition: background 0.18s ease, border-color 0.18s ease; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +/* In a multi-column config row the toggle cell only contains the + toggle (no label/help text above it like the input cells do), so + it sits at the top of the row while the inputs underneath their + labels run lower. align-self: end drops the toggle to the bottom + of the grid row so its row aligns with the inputs' row, not the + inputs' labels. */ +.config-fields > .checkbox-field { + align-self: end; +} + +.checkbox-label:hover { + background: rgba(var(--text-rgb), 0.07); + border-color: rgba(var(--accent-rgb), 0.35); +} + +.checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; + background: transparent; + border-color: var(--border-color); +} + +.checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); +} + +.checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; + cursor: pointer; +} + +/* ------------------------------------------------------------------ + Default checkbox visual = TOGGLE SWITCH. + Most app/config options are binary data settings (Enable X, Auto-Y) + for which a sliding switch reads as "on/off". + + The TICKBOX variant (square with check) lives further down, scoped + to .toggle-section and .advanced-toggle-field — those host the + "Show Advanced / Show Unused" UI preferences where a tickbox better + signals "this affects what you see, not the data". + ------------------------------------------------------------------ */ +.checkbox-custom { + position: relative; + width: 48px; + height: 24px; + flex-shrink: 0; + margin-right: 12px; + background: rgba(var(--text-rgb), 0.20); + border: none; + border-radius: 24px; + cursor: pointer; + transition: background 0.18s ease, box-shadow 0.18s ease; +} + +.checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: var(--text-primary); + border-radius: 50%; + transition: transform 0.18s ease; +} + +.checkbox-label input[type="checkbox"]:hover + .checkbox-custom { + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.10); +} + +.checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--accent); +} + +.checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +/* ------------------------------------------------------------------ + TICKBOX variant — used for "this changes what you see" preferences + like Show Advanced Options / Show Unused Options / Show advanced + settings. Scoped by parent class so the toggle stays the default + everywhere else. + ------------------------------------------------------------------ */ +.toggle-section .checkbox-custom, +.advanced-toggle-field .checkbox-custom { + width: 20px; + height: 20px; + margin-right: 10px; + background: rgba(var(--text-rgb), 0.05); + border: 1.5px solid rgba(var(--text-rgb), 0.40); + border-radius: 4px; + box-shadow: none; +} + +.toggle-section .checkbox-custom::before, +.advanced-toggle-field .checkbox-custom::before { + content: none; +} + +.toggle-section .checkbox-custom::after, +.advanced-toggle-field .checkbox-custom::after { + content: ''; + position: absolute; + top: 2px; + left: 6px; + width: 5px; + height: 10px; + border: solid var(--text-on-accent, #ffffff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.15s ease; +} + +.toggle-section .checkbox-label input[type="checkbox"]:hover + .checkbox-custom, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { + border-color: var(--accent); + box-shadow: none; +} + +.toggle-section .checkbox-label input[type="checkbox"]:checked + .checkbox-custom, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--accent); + border-color: var(--accent); +} + +.toggle-section .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::after, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::after { + opacity: 1; +} + +.checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +.group-fields .form-field { + margin-bottom: 20px; +} + +.group-fields .form-field:last-child { + margin-bottom: 0; +} + +.app-config .form-field { + margin-bottom: 16px; +} + +.app-config .form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +/* App-config inputs share the .form-control recipe used by the standard + config page — translucent theme-aware border + glass fill — so app + config fields stop reading as a near-black slab against the nebula + gradient. */ +.app-config .form-input, +.app-config .form-select, +.app-config .config-input, +.config-input { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(var(--text-rgb), 0.20); + border-radius: 8px; + background: rgba(var(--text-rgb), 0.05); + color: var(--text-primary, #fff); + font-size: 14px; + font-family: inherit; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.app-config .form-input:focus, +.app-config .form-select:focus, +.app-config .config-input:focus, +.config-input:focus { + outline: none; + border-color: var(--accent); + background: rgba(var(--text-rgb), 0.08); +} + +.app-config .form-input::placeholder, +.app-config .config-input::placeholder, +.config-input::placeholder { + color: var(--text-secondary, #ccc); + opacity: 0.7; +} + +.app-config .form-help { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* App Config Checkboxes - EXACT copy of global toggle switch styling */ +.app-config .checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: all 0.2s ease; + background: #0000; + border: 1px solid transparent; +} + +.app-config .checkbox-label:hover { + /* background: rgba(var(--text-rgb),0.05); */ +} + +.app-config .checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; + background: transparent; + border-color: var(--border-color); +} + +.app-config .checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); +} + +.app-config .checkbox-label.disabled .checkbox-text { + color: var(--text-secondary, #ccc); +} + +.app-config .checkbox-label:hover .checkbox-text { + color: var(--text-primary); +} + +.app-config .checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +.app-config .checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; + cursor: pointer; +} + +.app-config .checkbox-custom { + position: relative; + width: 48px; + height: 24px; + background: var(--border-strong); + border-radius: 24px; + margin-right: 12px; + transition: all 0.3s ease; + cursor: pointer; + border: none; +} + +.app-config .checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: all 0.3s ease; +} + +.app-config .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { +} + +.app-config .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.app-config .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +/* Field Group Styling for Domain Blocks */ +.domain-building-block .field-group { + margin: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.domain-building-block .field-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +.domain-building-block .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.domain-building-block .form-control:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.25); +} + +/* Toggle-specific spacing */ +.checkbox-label.master-toggle { + padding: 20px; +} + +/* Enhanced field styling */ +.form-field { + position: relative; +} + +.form-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + margin-bottom: 6px; +} + +.required { + color: var(--error-color); + font-weight: 600; +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: var(--primary-color); + color: var(--text-primary); + border-radius: 50%; + font-size: 10px; + font-weight: bold; + cursor: help; + margin-left: 4px; +} + +.form-help { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); + font-style: italic; +} + +/* "Show Advanced / Show Unused" UI-preference wrapper. Glass card that + reads on every theme — replaces an older recipe that hardcoded + var(--surface-bg-solid) (solid dark navy on nebula) plus a near-white + #f9f9f9 hover, which made the label invisible on dark themes. */ +.toggle-section .checkbox-label { + display: flex; + align-items: flex-start; + cursor: pointer; + width: 100%; + padding: 14px 16px; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 10px; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.toggle-section .checkbox-label:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +/* .toggle-section .checkbox-custom intentionally NOT redefined here. + The shared tickbox styling earlier in this file already scopes the + square-check visual to .toggle-section, so adding another rule here + would just override it. */ + +.toggle-section .checkbox-text { + font-weight: 600; + color: var(--text-primary); + font-size: 16px; + margin-bottom: 4px; +} + +.toggle-section .checkbox-description { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* Red-flash highlight for required-but-empty fields when the user clicks + Install. Cleared on the next input/change. Native title attribute on the + element provides the "Required" tooltip on hover. */ +.field-required-error { + border: 1.5px solid var(--status-danger) !important; + box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.20); + animation: requiredFlash 0.6s ease-in-out 2; + background: rgba(var(--status-danger-rgb), 0.06) !important; +} + +/* ============================================================ + Custom popups are drawn by the OS and ignore CSS for the + popup chrome. This component visually replaces the native select + while keeping it in the DOM for form submission / change events. + ============================================================ */ +.custom-select { + position: relative; + width: 100%; + display: inline-block; +} + +/* The native with themed + up/down chevrons that read consistently across browsers/themes. + ============================================================ */ + +/* Hide native browser spin buttons. */ +.custom-number-input::-webkit-outer-spin-button, +.custom-number-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.custom-number-input { + -moz-appearance: textfield; +} + +.custom-number { + position: relative; + display: inline-flex; + width: 100%; + align-items: stretch; +} + +/* The input itself stretches to fill, leaving room for the controls. */ +.custom-number .custom-number-input { + flex: 1; + min-width: 0; + padding-right: 36px; +} + +.custom-number.is-disabled { + opacity: 0.6; +} + +.custom-number-controls { + position: absolute; + top: 4px; + right: 4px; + bottom: 4px; + display: flex; + flex-direction: column; + width: 24px; + border-radius: 6px; + overflow: hidden; + background: rgba(var(--text-rgb), 0.05); +} + +.custom-number-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: rgba(var(--text-rgb), 0.65); + cursor: pointer; + padding: 0; + margin: 0; + transition: background 0.12s ease, color 0.12s ease; +} + +.custom-number-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.20); + color: var(--accent); +} + +.custom-number-btn:active:not(:disabled) { + background: rgba(var(--accent-rgb), 0.35); +} + +.custom-number-btn:disabled, +.custom-number-btn.is-disabled { + opacity: 0.30; + cursor: not-allowed; +} + +/* Subtle divider between up/down. */ +.custom-number-up { + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} diff --git a/containers/libreportal/frontend/css/ip-whitelist.css b/containers/libreportal/frontend/css/ip-whitelist.css new file mode 100755 index 0000000..c1d2beb --- /dev/null +++ b/containers/libreportal/frontend/css/ip-whitelist.css @@ -0,0 +1,248 @@ + + +/* Whitelist Building Blocks Container - Scoped to config section */ +.config-category .whitelist-building-blocks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + grid-auto-rows: minmax(min-content, max-content); +} + +/* Responsive Grid Layout */ +@media (max-width: 1600px) { + .config-category .whitelist-building-blocks { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .config-category .whitelist-building-blocks { + grid-template-columns: 1fr; + gap: 16px; + } +} + +/* Individual Whitelist Building Block - Scoped */ +.config-category .whitelist-building-block { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + min-height: fit-content; + gap: 12px; +} + +.config-category .whitelist-building-block:hover { + transform: translateY(-2px); +} + +/* Whitelist Header Layout - Scoped */ +.config-category .whitelist-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +/* Delete Whitelist Button - Scoped to match domain manager */ +.config-category .delete-whitelist-btn { + background: var(--status-danger); + color: var(--text-primary); + border: none; + border-radius: 4px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +.config-category .delete-whitelist-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.05); +} + +.config-category .delete-whitelist-btn.disabled { + background: var(--text-muted); + color: var(--text-muted); + cursor: not-allowed; + transform: none; +} + +.config-category .delete-whitelist-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +/* Delete Icon - Scoped */ +.config-category .delete-icon { + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +/* Whitelist Input Fields - Scoped */ +.config-category .whitelist-input { + flex: 1; + min-width: 0; +} + +/* Whitelist Empty State - Scoped */ +.config-category .whitelist-empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 40px 20px; + background: var(--card-bg); + border: 2px dashed var(--border-color); + border-radius: 8px; + color: var(--text-muted); +} + +.config-category .whitelist-empty-state p { + margin-bottom: 20px; + font-size: 16px; + line-height: 1.5; +} + +/* Whitelist Actions Container - Scoped */ +.config-category .whitelist-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-start; + padding: 10px 0; +} + +/* Add Whitelist Entry Button - Scoped */ +.config-category .whitelist-actions .btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.config-category .whitelist-actions .btn-primary { + background: var(--primary-color, var(--accent)); + color: var(--text-primary); +} + +.config-category .whitelist-actions .btn-primary:hover { + background: var(--primary-hover, var(--accent-hover)); + transform: translateY(-1px); +} + +.config-category .whitelist-actions .btn-secondary { + background: var(--secondary-color, var(--text-muted)); + color: var(--text-primary); + cursor: not-allowed; +} + +.config-category .whitelist-actions .btn-secondary:hover { + background: var(--secondary-hover, var(--text-muted)); + transform: none; +} + +/* Add Icon - Scoped */ +.config-category .add-icon { + font-size: 16px; + font-weight: bold; + line-height: 1; +} + +/* Field Group Styling for Whitelist - Scoped */ +.config-category .whitelist-building-block .field-group { + margin: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.config-category .whitelist-building-block .field-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +.config-category .whitelist-building-block .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.config-category .whitelist-building-block .form-control:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.25); +} + +.config-category .whitelist-building-block .form-control.error { + border-color: var(--status-danger); +} + +.config-category .whitelist-building-block .field-description { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Flash Animation for Validation - Scoped */ +@keyframes whitelist-flash { + 0%, 100% { + background-color: transparent; + border-color: var(--border-color); + } + 50% { + background-color: rgba(var(--status-danger-rgb), 0.1); + border-color: var(--status-danger); + } +} + +.config-category .whitelist-building-block.flash { + animation: whitelist-flash 0.5s ease-in-out 2; +} + +.config-category .whitelist-input.flash { + animation: whitelist-flash 0.5s ease-in-out 2; +} + +/* Responsive Adjustments - Scoped */ +@media (max-width: 600px) { + .config-category .whitelist-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .config-category .delete-whitelist-btn { + align-self: flex-end; + margin-top: 8px; + } + + .config-category .whitelist-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/containers/libreportal/frontend/css/loading-screen.css b/containers/libreportal/frontend/css/loading-screen.css new file mode 100755 index 0000000..1cb5ab6 --- /dev/null +++ b/containers/libreportal/frontend/css/loading-screen.css @@ -0,0 +1,541 @@ +/* Loading Screen Styles */ +.loading-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + max-height: 100vh; + overflow-y: auto; + background: linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-mid) 50%, var(--gradient-to) 100%); + color: var(--text-primary); + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + opacity: 1; + transition: opacity 0.3s ease-out; + padding: 2rem 0; + box-sizing: border-box; +} + +.loading-screen.hiding { + opacity: 0; + pointer-events: none; +} + +.loading-screen.success { + animation: successGlow 0.8s ease-out forwards; +} + +@keyframes successGlow { + 0% { + box-shadow: inset 0 0 0 rgba(var(--accent-rgb), 0); + } + 100% { + } +} + +.success-message { + text-align: center; + padding: 1rem; + margin-top: 1rem; + opacity: 0; + transform: translateY(20px); + transition: all 0.3s ease-out; +} + +.loading-screen.success .success-message { + opacity: 1; + transform: translateY(0); +} + +.success-message h2 { + margin: 0; + font-size: 2rem; + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.success-message p { + margin: 0.5rem 0 0 0; + color: var(--accent); + font-size: 1.1rem; +} + +.loading-container { + max-width: 600px; + width: 90%; + padding: 2rem; +} + +.loading-subtitle { + margin-top: 1rem; +} + +#loading-status-text { + font-size: 1.2rem; + color: var(--text-secondary); + font-weight: 400; +} + +/* Progress Bar */ +.loading-progress { + margin-bottom: 3rem; +} + +.progress-bar-container { + background: rgba(var(--text-rgb), 0.1); + border-radius: 8px; + padding: 2px; + backdrop-filter: blur(10px); + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.progress-bar { + height: 8px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 20px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 20px; + width: 0%; + transition: width 0.3s ease-out; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, + transparent, + rgba(var(--text-rgb), 0.3), + transparent + ); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + padding: 0 0.5rem; + font-size: 0.9rem; +} + +#progress-percentage { + font-weight: 600; + color: var(--accent); +} + +#progress-details { + color: var(--text-secondary); +} + +/* System Status Cards */ +.loading-systems { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + max-height: 268px; /* Fixed height */ + overflow-y: auto; /* Scrollbar when content overflows */ + padding-right: 0.5rem; /* Space for scrollbar */ +} + +/* Custom scrollbar styling */ +.loading-systems::-webkit-scrollbar { + width: 6px; +} + +.loading-systems::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.05); + border-radius: 3px; +} + +.loading-systems::-webkit-scrollbar-thumb { + background: rgba(var(--accent-rgb), 0.3); + border-radius: 3px; +} + +.loading-systems::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.5); +} + +.system-card { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 12px; + padding: 1rem; + display: flex; + align-items: center; + gap: 1rem; + backdrop-filter: blur(10px); + transition: all 0.3s ease; +} + +.system-card:hover { + background: rgba(var(--text-rgb), 0.08); + transform: translateY(-2px); +} + +.system-icon { + font-size: 1.5rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--text-rgb), 0.1); + border-radius: 8px; +} + +.system-info { + flex: 1; + text-align: left; +} + +.system-name { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.system-status { + font-size: 0.85rem; + color: var(--text-secondary); + transition: color 0.3s ease; +} + +.system-indicator { + font-size: 1.2rem; +} + +/* System Card States */ +.system-card.checking { + border-color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.1); +} + +.system-card.checking .system-status { + color: var(--status-warning); +} + +.system-card.retrying { + border-color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.1); + animation: pulse-retry 2s infinite; +} + +.system-card.retrying .system-status { + color: var(--status-warning); +} + +.system-card.waiting { + border-color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.1); + animation: pulse-wait 3s infinite; +} + +.system-card.waiting .system-status { + color: var(--status-danger); +} + +.system-card.passed { + border-color: #86efac; + background: rgba(134, 239, 172, 0.10); +} + +.system-card.passed .system-status { + color: #86efac; +} + +.system-card.failed { + border-color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.1); +} + +.system-card.failed .system-status { + color: var(--status-danger); +} + +.system-card.skipped { + border-color: var(--text-muted); + background: rgba(var(--text-rgb), 0.1); +} + +.system-card.skipped .system-status { + color: var(--text-muted); +} + +/* Retry and waiting animations */ +@keyframes pulse-retry { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +@keyframes pulse-wait { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* Command styling for tooltips and messages */ +.command-box { + background: var(--code-bg); + color: var(--text-secondary); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + border: 1px solid var(--border-strong); + white-space: nowrap; +} + +/* Actions */ +.loading-actions { + margin-bottom: 2rem; + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + min-width: 120px; + justify-content: center; +} + +.btn-primary { + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); +} + +.btn-primary:hover { + transform: translateY(-2px); +} + +.btn-secondary { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.2); +} + +.btn-secondary:hover { + background: rgba(var(--text-rgb), 0.15); + transform: translateY(-2px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.btn-icon { + font-size: 1.1rem; +} + +/* Error Details */ +.error-details { + background: rgba(var(--status-danger-rgb), 0.1); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + border-radius: 12px; + padding: 1.5rem; + margin: 0 auto 2rem auto; /* Push down from system cards, space before actions */ + max-width: 500px; + text-align: left; + animation: slideDown 0.3s ease-out; +} + +.error-details h4 { + margin: 0 0 1rem 0; + color: var(--status-danger); + font-size: 1.1rem; +} + +.error-details ul { + margin: 0; + padding-left: 1.5rem; + color: var(--status-danger); +} + +.error-details li { + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.error-details small { + color: var(--status-danger); + font-size: 0.85rem; +} + +.error-details p { + margin: 1rem 0 0 0; + color: var(--text-secondary); + font-style: italic; +} + +/* Footer */ +.loading-footer { + margin-top: auto; + padding-top: 2rem; +} + +.loading-tips { + background: rgba(var(--text-rgb), 0.05); + border-radius: 8px; + padding: 1rem; + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.loading-tips p { + margin: 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .loading-container { + width: 95%; + padding: 1rem; + } + + .loading-logo h1 { + font-size: 2.5rem; + } + + .loading-actions { + flex-direction: column; + align-items: center; + } + + .btn { + width: 100%; + max-width: 250px; + } +} + +@media (max-width: 480px) { + .loading-logo h1 { + font-size: 2rem; + } + + .loading-logo p { + font-size: 1rem; + } + + .loading-subtitle, + #loading-status-text { + font-size: 1rem; + } + + .system-card { + padding: 0.75rem; + } + + .system-icon { + width: 35px; + height: 35px; + font-size: 1.2rem; + } +} + +/* Custom scrollbar for error list */ +.error-list-container::-webkit-scrollbar { + width: 10px !important; +} + +.error-list-container::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.1) !important; + border-radius: 4px !important; +} + +.error-list-container::-webkit-scrollbar-thumb { + background: rgba(var(--status-danger-rgb), 0.8) !important; + border-radius: 4px !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.4) !important; +} + +.error-list-container::-webkit-scrollbar-thumb:hover { + background: rgba(var(--status-danger-rgb), 1.0) !important; +} + +/* Full page scrollbar for loading screen */ +.loading-screen::-webkit-scrollbar { + width: 12px; +} + +.loading-screen::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.05); + border-radius: 6px; +} + +.loading-screen::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.2); + border-radius: 6px; + border: 2px solid transparent; + background-clip: content-box; +} + +.loading-screen::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.3); + background-clip: content-box; +} + +/* Error list container styling */ +.error-list-container { + max-height: 200px !important; + overflow-y: auto !important; + border: 1px solid var(--status-danger) !important; + border-radius: 4px !important; + padding: 10px !important; + margin: 10px 0 !important; + background: rgba(var(--status-danger-rgb), 0.1) !important; +} + +.error-list-container ul { + margin: 0 !important; + padding-left: 20px !important; +} + +.error-list-container li { + margin-bottom: 8px !important; +} diff --git a/containers/libreportal/frontend/css/login.css b/containers/libreportal/frontend/css/login.css new file mode 100755 index 0000000..1af0f5b --- /dev/null +++ b/containers/libreportal/frontend/css/login.css @@ -0,0 +1,199 @@ +.login-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + animation: loginOverlayIn 0.2s ease; +} + +.login-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 420px; + margin: 1rem; +} + +.login-overlay .login-card { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.12); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.login-overlay .login-label { + color: var(--text-secondary); +} + +.login-overlay .login-input { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.12); + color: var(--text-primary); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.login-overlay .login-input:focus { + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.18); +} + +.login-overlay .login-input::placeholder { + color: var(--text-muted); +} + +.login-overlay.hiding { + animation: loginOverlayOut 0.25s ease forwards; +} + +@keyframes loginOverlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes loginOverlayOut { + from { opacity: 1; } + to { opacity: 0; } +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--card-bg, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 14px; + padding: 2rem; + animation: loginCardIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes loginCardIn { + from { transform: translateY(16px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.login-form { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.login-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.login-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary, #888); + letter-spacing: 0.02em; +} + +.login-input { + width: 100%; + padding: 0.6rem 0.875rem; + background: var(--input-bg, #2a2a3e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + color: var(--text-primary, var(--text-secondary)); + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + box-sizing: border-box; +} + +.login-input:focus { + border-color: var(--accent-color, var(--accent)); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15); +} + +.login-input::placeholder { + color: var(--text-muted, #555); +} + +.login-error { + display: none; + align-items: center; + gap: 0.6rem; + margin-top: 0.25rem; + padding: 0.7rem 0.85rem; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.35; + color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.12); + border: 1px solid rgba(var(--status-danger-rgb), 0.35); + border-left: 3px solid var(--status-danger); + border-radius: 10px; +} + +.login-error.visible { + display: flex; + animation: loginErrorIn 0.25s ease; +} + +@keyframes loginErrorIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.login-error-icon { + flex-shrink: 0; + color: var(--status-danger); +} + +.login-error-text { + flex: 1; +} + +.login-btn { + margin-top: 0.5rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-on-accent); + border: none; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.15s ease; +} + +.login-btn:hover:not(:disabled) { + transform: translateY(-2px); + filter: brightness(1.05); +} + +.login-btn:active:not(:disabled) { + transform: translateY(0); +} + +.login-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.login-btn-spinner { + display: none; + width: 14px; + height: 14px; + border: 2px solid rgba(var(--text-rgb),0.3); + border-top-color: var(--text-primary); + border-radius: 50%; + animation: loginSpin 0.6s linear infinite; + vertical-align: middle; + margin-right: 6px; +} + +.login-btn.loading .login-btn-spinner { display: inline-block; } +.login-btn.loading .login-btn-label { display: none; } + +@keyframes loginSpin { + to { transform: rotate(360deg); } +} diff --git a/containers/libreportal/frontend/css/modal.css b/containers/libreportal/frontend/css/modal.css new file mode 100755 index 0000000..4f73c3d --- /dev/null +++ b/containers/libreportal/frontend/css/modal.css @@ -0,0 +1,602 @@ +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(var(--bg-rgb), 0.92); +} + +/* Reusable toggle switch — replaces stock checkboxes inside modals. + Pure CSS, no JS, accessible (the underlying input still toggles). */ +.eo-toggle { + display: inline-flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; +} +.eo-toggle input[type="checkbox"] { + position: absolute; + opacity: 0; + pointer-events: none; +} +.eo-toggle .eo-toggle-track { + position: relative; + width: 40px; + height: 22px; + background: rgba(var(--text-rgb), 0.18); + border-radius: 22px; + transition: background-color 0.12s linear; + flex-shrink: 0; + will-change: background-color; +} +.eo-toggle .eo-toggle-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: var(--text-primary); + border-radius: 50%; + transition: transform 0.12s ease-out; + will-change: transform; +} +.eo-toggle input[type="checkbox"]:checked + .eo-toggle-track { + background: var(--status-success); +} +.eo-toggle input[type="checkbox"]:checked + .eo-toggle-track::after { + transform: translateX(18px); +} +.eo-toggle input[type="checkbox"]:focus-visible + .eo-toggle-track { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.eo-toggle .eo-toggle-text { + display: flex; + flex-direction: column; + gap: 2px; +} +.eo-toggle .eo-toggle-text-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} +.eo-toggle .eo-toggle-text-help { + font-size: 12px; + color: rgba(var(--text-rgb), 0.6); +} + +/* Card-framed variant — wraps the toggle in a subtle panel so it stands + apart inside a busy modal body. Use `.eo-toggle.eo-toggle-card`. */ +.eo-toggle-card { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 6px; +} +.eo-toggle-card:hover { + background: rgba(var(--text-rgb), 0.07); +} + +.modal-content { + background-color: var(--card-bg); + margin: 5% auto; + padding: 0; + border-radius: 12px; + width: 90%; + max-width: 900px; + max-height: 85vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Reusable empty/info-state card. Use inside any modal body or panel + when there's nothing to render and we need to explain why. Variants: + .info (cyan), .warning (amber, default), .danger (red). */ +.eo-empty-state { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + background: rgba(var(--status-warning-rgb), 0.08); + border: 1px solid rgba(var(--status-warning-rgb), 0.30); + color: var(--status-warning); + margin: 4px 0; +} +.eo-empty-state.info { + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.30); + color: var(--accent); +} +.eo-empty-state.danger { + background: rgba(var(--status-danger-rgb), 0.08); + border-color: rgba(var(--status-danger-rgb), 0.35); + color: #fecaca; +} +.eo-empty-state-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(var(--text-rgb), 0.06); + color: currentColor; +} +.eo-empty-state-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.eo-empty-state-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.eo-empty-state-text { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: rgba(var(--text-rgb), 0.78); +} +.eo-empty-state-text strong { + color: var(--text-primary); + font-weight: 600; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + background-color: var(--card-bg); +} + +.modal-header h2 { + margin: 0; + color: var(--text-color); + font-size: 20px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 28px; + color: var(--text-color); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: background-color 0.2s; +} + +.modal-close:hover { + background-color: rgba(var(--text-rgb), 0.1); +} + +.modal-body { + padding: 0; + overflow: hidden; + flex: 1; +} + +#readme-iframe { + width: 100%; + height: 80vh; + border: none; + background: white; + border-radius: 0 0 0 0; +} + +#readme-content { + color: var(--text-color); + line-height: 1.6; +} + +#readme-content h1, +#readme-content h2, +#readme-content h3 { + color: var(--text-color); + margin-top: 24px; + margin-bottom: 16px; +} + +#readme-content h1:first-child { + margin-top: 0; +} + +#readme-content p { + margin-bottom: 16px; +} + +#readme-content code { + background-color: rgba(var(--text-rgb), 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.9em; +} + +#readme-content pre { + background-color: rgba(var(--text-rgb), 0.05); + padding: 16px; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +#readme-content pre code { + background: none; + padding: 0; +} + +#readme-content ul, +#readme-content ol { + margin-bottom: 16px; + padding-left: 24px; +} + +#readme-content blockquote { + border-left: 4px solid var(--primary-color); + padding-left: 16px; + margin: 16px 0; + color: var(--text-muted); + font-style: italic; +} + +#readme-content a { + color: var(--primary-color); + text-decoration: none; +} + +#readme-content a:hover { + text-decoration: underline; +} + +.loading { + text-align: center; + color: var(--text-color); + padding: 40px; + font-style: italic; +} + +.error { + color: var(--status-danger); + text-align: center; + padding: 40px; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + margin: 2% auto; + max-height: 95vh; + } + + .modal-header { + padding: 16px 20px; + } + + .modal-body { + padding: 20px; + } + + .modal-header h2 { + font-size: 18px; + } +} + +/* ============================================================ + EO Modal — unified modal system. Use openEoModal() in JS. + See eo-modal.js for API + composable section primitives. + ============================================================ */ + +.eo-modal { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--bg-rgb), 0.92); + padding: 16px; +} +.eo-modal-content { + display: flex; + flex-direction: column; + width: 100%; + max-width: 640px; + max-height: 88vh; + background: var(--card-bg, var(--code-bg)); + border: 1px solid var(--border-color, var(--border-strong)); + border-radius: 12px; + overflow: hidden; +} +.eo-modal[data-size="sm"] .eo-modal-content { max-width: 460px; } +.eo-modal[data-size="lg"] .eo-modal-content { max-width: 820px; } + +.eo-modal-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-bottom: 1px solid var(--border-color, var(--border-strong)); +} +.eo-modal-header-info { display: flex; gap: 14px; align-items: center; flex: 1; min-width: 0; } +.eo-modal-icon { + width: 56px; + height: 56px; + border-radius: 10px; + object-fit: contain; + background: rgba(var(--text-rgb), 0.04); + padding: 4px; + flex-shrink: 0; +} +.eo-modal[data-size="sm"] .eo-modal-icon { width: 36px; height: 36px; } +.eo-modal-eyebrow { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--accent); + margin-bottom: 2px; +} +.eo-modal-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } +.eo-modal-desc { margin: 4px 0 0 0; font-size: 13px; color: rgba(var(--text-rgb), 0.65); line-height: 1.4; } +.eo-modal-close { + background: none; + border: none; + color: rgba(var(--text-rgb), 0.7); + font-size: 26px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} +.eo-modal-close:hover { background: rgba(var(--text-rgb), 0.10); color: var(--text-primary); } + +.eo-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 18px 22px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.eo-modal-footer { + display: flex; + gap: 10px; + padding: 14px 22px 18px; + border-top: 1px solid var(--border-color, var(--border-strong)); +} +.eo-modal-footer .btn { flex: 1 1 0; } + +/* ----- Composable body primitives ----- */ +.eo-modal-section { display: flex; flex-direction: column; gap: 6px; } +.eo-modal-section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(var(--text-rgb), 0.55); + font-weight: 600; +} +.eo-modal-section-text { font-size: 13px; color: rgba(var(--text-rgb), 0.80); line-height: 1.5; margin: 0; } + +.eo-modal-badge-row { display: flex; flex-wrap: wrap; gap: 6px; } +.eo-modal-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + color: rgba(var(--text-rgb), 0.85); +} +.eo-modal-badge.success { background: rgba(var(--status-success-rgb),0.10); border-color: rgba(var(--status-success-rgb),0.30); color: var(--status-success); } +.eo-modal-badge.info { background: rgba(var(--accent-rgb),0.10); border-color: rgba(var(--accent-rgb),0.30); color: var(--accent); } +.eo-modal-badge.purple { background: rgba(var(--accent-rgb),0.10); border-color: rgba(var(--accent-rgb),0.30); color: #d8b4fe; } +.eo-modal-badge.warning { background: rgba(var(--status-warning-rgb),0.10); border-color: rgba(var(--status-warning-rgb),0.30); color: var(--status-warning); } +.eo-modal-badge.danger { background: rgba(var(--status-danger-rgb),0.10); border-color: rgba(var(--status-danger-rgb),0.30); color: var(--status-danger); } + +.eo-modal-url-list { display: flex; flex-direction: column; gap: 4px; } +.eo-modal-url-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + text-decoration: none; + transition: background 0.12s, border-color 0.12s; + min-width: 0; +} +.eo-modal-url-row:hover { background: rgba(var(--accent-rgb), 0.08); border-color: rgba(var(--accent-rgb), 0.30); } +.eo-modal-url-label { font-size: 14px; font-weight: 500; color: var(--text-primary); flex-shrink: 0; } +.eo-modal-url-href { + font-size: 11px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + color: rgba(var(--text-rgb), 0.50); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + text-align: right; +} +.eo-modal-url-row svg { color: rgba(var(--text-rgb), 0.50); flex-shrink: 0; } + +.eo-modal-cred { + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + padding: 10px 12px; +} +.eo-modal-cred + .eo-modal-cred { margin-top: 6px; } +.eo-modal-cred-title { font-size: 12px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } +.eo-modal-cred-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; } +.eo-modal-cred-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(var(--text-rgb), 0.50); + width: 38px; + flex-shrink: 0; +} +.eo-modal-cred-value { + flex: 1; + min-width: 0; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 13px; + color: #e0f2fe; + padding: 4px 8px; + background: rgba(var(--bg-rgb), 0.25); + border-radius: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.eo-modal-cred-toggle { + font-size: 11px; + padding: 4px 10px; + background: transparent; + color: var(--accent); + border: 1px solid rgba(var(--accent-rgb), 0.40); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} +.eo-modal-cred-toggle:hover { background: rgba(var(--accent-rgb), 0.10); } + +.eo-modal-cred-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + color: rgba(var(--text-rgb), 0.50); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} +.eo-modal-cred-copy:hover { + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(var(--accent-rgb), 0.08); +} +.eo-modal-cred-copy.copied { + color: var(--status-success); + border-color: rgba(var(--status-success-rgb), 0.50); + background: rgba(var(--status-success-rgb), 0.10); +} + +.eo-modal-empty { text-align: center; color: rgba(var(--text-rgb), 0.55); font-size: 13px; padding: 12px 0; } + +/* Body content shouldn't push the modal wider than its container. */ +.eo-modal-body :where(input, textarea, select) { max-width: 100%; box-sizing: border-box; } +.eo-modal-body :where(p, h1, h2, h3, h4) { overflow-wrap: anywhere; } + +/* Keep close X reachable in the corner even when the header stacks. */ +.eo-modal-header { position: relative; } +.eo-modal-close { min-width: 44px; min-height: 44px; } + +/* Tablet + smaller phones */ +@media (max-width: 560px) { + .eo-modal { padding: 8px; align-items: flex-end; } + .eo-modal-content { max-height: 92vh; border-radius: 14px 14px 8px 8px; } + .eo-modal-header { padding: 14px 56px 14px 16px; } + .eo-modal-icon { width: 44px; height: 44px; } + .eo-modal-title { font-size: 18px; } + .eo-modal-close { position: absolute; top: 8px; right: 8px; } + .eo-modal-body { padding: 14px 16px; gap: 12px; } + .eo-modal-footer { padding: 12px 16px 16px; } + .eo-modal-url-row { flex-direction: column; align-items: flex-start; gap: 4px; } + .eo-modal-url-href { text-align: left; width: 100%; } + .eo-modal-cred-row { flex-wrap: wrap; } + .eo-modal-cred-value { width: 100%; } +} + +/* Very narrow phones — stack the footer buttons so labels don't squash. */ +@media (max-width: 380px) { + .eo-modal-footer { flex-direction: column-reverse; } + .eo-modal-footer .btn { width: 100%; } + .eo-modal-eyebrow { font-size: 10px; } + .eo-modal-title { font-size: 17px; } + .eo-modal-icon { width: 38px; height: 38px; } +} + +/* Uninstall-modal toggles — text in the middle, decorative coloured icon on the right. */ +.eo-toggle.eo-toggle-card.uninstall-extra { gap: 12px; align-items: center; } +.eo-toggle.eo-toggle-card.uninstall-extra .eo-toggle-text { flex: 1; min-width: 0; } +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid transparent; + transition: filter 0.15s ease, transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon.image { background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.28); color: var(--accent); } +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon.tasks { background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.30); color: var(--accent); } +.eo-toggle.eo-toggle-card.uninstall-extra:hover .uninstall-extra-icon { filter: brightness(1.2); transform: scale(1.04); } +.eo-toggle.eo-toggle-card.uninstall-extra input[type="checkbox"]:checked ~ .uninstall-extra-icon.image { background: rgba(var(--status-success-rgb), 0.18); border-color: rgba(var(--status-success-rgb), 0.45); color: var(--status-success); } +.eo-toggle.eo-toggle-card.uninstall-extra input[type="checkbox"]:checked ~ .uninstall-extra-icon.tasks { background: rgba(var(--status-success-rgb), 0.18); border-color: rgba(var(--status-success-rgb), 0.45); color: var(--status-success); } + +/* End icon in the eo-modal header — sits on the right side of the + header (before the close X). Use for tool emoji / accent badges + that don't belong in the title text. */ +.eo-modal-end-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 9px; + font-size: 20px; + line-height: 1; + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.28); + color: var(--accent); +} diff --git a/containers/libreportal/frontend/css/port-manager.css b/containers/libreportal/frontend/css/port-manager.css new file mode 100755 index 0000000..f9bf205 --- /dev/null +++ b/containers/libreportal/frontend/css/port-manager.css @@ -0,0 +1,590 @@ +/* Port Manager Component Styles */ + +/* Basic/Advanced toggle — when the .show-advanced class isn't on the + .port-manager root, fields tagged .port-field-advanced collapse out so + the basic view stays simple. */ +.port-manager:not(.show-advanced) .port-field-advanced { display: none; } + +.port-manager-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.port-manager-advanced-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 13px; + color: var(--text-secondary, #a0a0a0); + cursor: pointer; + user-select: none; +} + +.port-manager-advanced-toggle input { margin: 0; } + +/* Ensure help icons are always visible in port manager */ +.port-manager .help-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 14px !important; + height: 14px !important; + background: var(--primary-color) !important; + color: white !important; + border-radius: 50% !important; + font-size: 9px !important; + font-weight: bold !important; + cursor: help !important; + position: relative !important; + opacity: 1 !important; + visibility: visible !important; +} + +/* Custom speech bubble tooltip for port manager */ +.port-manager .help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--card-bg, #2a2a2a); + color: var(--text-primary, #fff); + padding: 8px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; /* Ensure normal case */ + white-space: nowrap; + max-width: 200px; + white-space: normal; + z-index: 99999; + border: 2px solid var(--primary-color, var(--accent)); +} + +/* Speech bubble triangle pointing down */ +.port-manager .help-icon:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card-bg, #2a2a2a); + z-index: 100000; +} + +/* Enhanced tooltips for main config help icons */ +.help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--card-bg, #2a2a2a); + color: var(--text-primary, #fff); + padding: 8px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; /* Ensure normal case */ + white-space: nowrap; + max-width: 200px; + white-space: normal; + z-index: 99999; + border: 2px solid var(--primary-color, var(--accent)); +} + +/* Speech bubble triangle for main config */ +.help-icon:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card-bg, #2a2a2a); + z-index: 100000; +} + +/* Auto-match indicator styling */ +.auto-match-indicator { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 14px !important; + height: 14px !important; + background: var(--status-success) !important; + color: white !important; + border-radius: 50% !important; + font-size: 9px !important; + font-weight: bold !important; + cursor: help !important; + position: relative !important; + margin-left: 4px !important; + opacity: 0 !important; + visibility: hidden !important; + transition: all 0.2s ease !important; +} + +.auto-match-indicator[style*="display: inline-flex"] { + opacity: 1 !important; + visibility: visible !important; +} + +/* Auto-match indicator tooltip */ +.auto-match-indicator:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--status-success); + color: var(--text-primary); + padding: 6px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; + white-space: nowrap; + min-width: 160px; + text-align: center; + z-index: 99999; + border: 2px solid var(--accent); +} + +/* Auto-match indicator triangle */ +.auto-match-indicator:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--status-success); + z-index: 100000; +} + +/* Ensure form fields allow tooltips to overflow */ +.form-field { + overflow: visible !important; +} + +.panel-fields { + overflow: visible !important; +} + +/* Hide form field labels for port manager containers */ +.form-field[id^="PORT_"] .form-label { + display: none; +} + +.form-field[id^="PORT_"] .form-help { + display: none; +} + +/* Hide the entire port field containers except the first one */ +.form-field[id^="PORT_"]:not(:first-of-type) { + display: none !important; +} + +/* Override panel-fields layout for port managers specifically */ +div.panel-fields > div.form-field[id^="PORT_"] { + grid-column: 1 / -1 !important; + width: 100% !important; +} + +/* Target the new full-width class */ +.panel-fields .form-field:has(.port-manager-full-width) { + grid-column: 1 / -1 !important; + width: 100% !important; +} + +/* Alternative targeting */ +.panel-fields .form-field[id^="PORT_"] { + grid-column: 1 / -1 !important; /* Make form fields with PORT_ IDs span full width */ +} + +.panel-fields .port-manager-container { + width: 100%; /* Ensure full width */ +} + +.port-manager { + margin: 0px 0; +} + +.port-manager-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid var(--primary-color, var(--accent)); +} + +.port-manager-header h4 { + margin: 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.add-port-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 14px; + background: var(--primary-color, var(--accent)); + color: var(--text-primary); + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-port-btn:hover { + background: var(--primary-color-hover, var(--accent)); + transform: translateY(-1px); +} + +.add-icon { + font-size: 16px; + font-weight: bold; +} + +.port-manager-list { + display: flex; + flex-direction: column; + gap: 16px; + overflow: visible; /* Allow tooltips to escape */ +} + +/* Port card border colors based on access type */ +.port-card[data-access="public"] { + border-color: var(--status-success); /* Green for public */ + box-shadow: 0 0 0 1px var(--status-success); +} + +.port-card[data-access="private"] { + border-color: var(--status-warning); /* Orange for private */ + box-shadow: 0 0 0 1px var(--status-warning); +} + +.port-card[data-access="disabled"] { + border-color: var(--status-danger); /* Red for disabled */ + box-shadow: 0 0 0 1px var(--status-danger); +} + +/* Hover effects maintain the access color theme */ +.port-card[data-access="public"]:hover { + border-color: var(--accent); +} + +.port-card[data-access="private"]:hover { + border-color: var(--status-warning); +} + +.port-card[data-access="disabled"]:hover { + border-color: var(--status-danger); +} + +/* Default fallback */ +.port-card { + background: var(--card-bg, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + overflow: visible; /* Changed from hidden to allow tooltips to escape */ + transition: all 0.2s ease; + margin-bottom: 16px; +} + +.port-card:hover { + border-color: var(--primary-color, var(--accent)); +} + +.port-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(var(--text-rgb), 0.05); + border-bottom: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px 8px 0 0; +} + +.port-card-title { + font-weight: 600; + color: var(--text-secondary, #ccc); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.remove-port-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--status-danger); + color: var(--text-primary); + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: bold; +} + +.remove-port-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.1); +} + +.remove-icon { + line-height: 1; +} + +.port-card-body { + padding: 16px; + overflow: visible; /* Allow tooltips to escape */ +} + +.port-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.port-row:last-child { + margin-bottom: 0; +} + +.port-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.port-field label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #ccc); + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; +} + +.port-field input, +.port-field select { + padding: 8px 12px; + background: var(--input-bg, #3a3a3a); + border: 1px solid var(--border-color, #555); + border-radius: 4px; + color: var(--text-primary, #fff); + font-size: 14px; + transition: all 0.2s ease; +} + +.port-field input:focus, +.port-field select:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.2); +} + +.port-field input[type="checkbox"] { + width: auto; + margin: 0; + transform: scale(1.2); +} + +.port-manager-hidden { + display: none; +} + +/* Modal Styles */ +.port-manager-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--bg-rgb), 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.port-manager-modal { + background: var(--card-bg, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + max-width: 400px; + width: 90%; + max-height: 90vh; + overflow: auto; +} + +.port-manager-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #444); +} + +.port-manager-modal-header h3 { + margin: 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.port-manager-modal-close { + background: none; + border: none; + color: var(--text-secondary, #ccc); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; +} + +.port-manager-modal-close:hover { + background: var(--hover-bg, #444); + color: var(--text-primary, #fff); +} + +.port-manager-modal-body { + padding: 20px; +} + +.port-manager-modal-body p { + margin: 0; + color: var(--text-primary, #fff); + line-height: 1.5; +} + +.port-manager-modal-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 16px 20px; + border-top: 1px solid var(--border-color, #444); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .port-manager-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .port-row { + grid-template-columns: 1fr; + } + + .port-manager-modal { + width: 95%; + margin: 20px; + } + + .port-manager-modal-footer { + flex-direction: column; + } + + .port-manager-modal-footer button { + width: 100%; + } +} + +/* Button Styles (reuse existing) */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-primary { + background: var(--primary-color, var(--accent)); + color: var(--text-primary); +} + +.btn-primary:hover { + background: var(--primary-color-hover, var(--accent)); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--secondary-color, var(--text-muted)); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--secondary-color-hover, var(--text-muted)); +} + +.btn-danger { + background: var(--status-danger); + color: var(--text-primary); +} + +.btn-danger:hover { + background: var(--status-danger-hover); +} + +.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.btn-xs { + padding: 2px 6px; + font-size: 11px; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .port-manager-modal-overlay { + background: rgba(var(--bg-rgb), 0.8); + } +} + +/* Manual dark mode class support */ +body.dark .port-manager-modal-overlay, +[data-theme="dark-blue"], +[data-theme="nebula"] .port-manager-modal-overlay, +.dark .port-manager-modal-overlay { + background: rgba(var(--bg-rgb), 0.8); +} diff --git a/containers/libreportal/frontend/css/routing.css b/containers/libreportal/frontend/css/routing.css new file mode 100644 index 0000000..67637ca --- /dev/null +++ b/containers/libreportal/frontend/css/routing.css @@ -0,0 +1,134 @@ +/* Traefik Routing panel — surfaced only on the Traefik app's detail page. */ + +.routing-list { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1rem 1.25rem 2rem; +} + +.routing-title-block h3 { margin: 0 0 0.25rem; } +.routing-title-block p { + margin: 0; + color: var(--text-secondary, #a0a0a0); + font-size: 13px; +} +.routing-title-block code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; +} + +.routing-section { border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); padding-top: 0.75rem; } +.routing-section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; } +.routing-section-head h4 { margin: 0; font-size: 14px; } +.routing-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 1.5em; padding: 0 0.45em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #ccc); + font-size: 11px; font-weight: 600; + margin-left: 0.4rem; +} +.routing-section-hint { color: var(--text-secondary, #a0a0a0); font-size: 12px; } + +.routing-show-advanced { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 12px; color: var(--text-secondary, #a0a0a0); + cursor: pointer; user-select: none; +} +.routing-show-advanced input { margin: 0; } + +.routing-table { display: flex; flex-direction: column; gap: 0.5rem; } +.routing-advanced-table { display: none; } +.routing-advanced-table.routing-advanced-open { display: flex; } + +.routing-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--surface-color, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.06)); + border-radius: 6px; +} +.routing-icon { width: 28px; height: 28px; object-fit: contain; flex-shrink: 0; } +.routing-meta { flex: 1; min-width: 0; } +.routing-title { + display: flex; align-items: center; gap: 0.5rem; + font-size: 13px; color: var(--text-primary, #fff); + flex-wrap: wrap; +} +.routing-app { font-weight: 600; } +.routing-port-name { color: var(--text-secondary, #ccc); } +.routing-port-num { color: var(--text-secondary, #a0a0a0); font-family: monospace; font-size: 12px; } +.routing-url { + font-family: monospace; font-size: 11px; + color: var(--text-secondary, #888); + margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.routing-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.routing-badge-webui { background: rgba(108, 99, 255, 0.18); color: #b5b0ff; } +.routing-badge-public { background: rgba(255, 159, 64, 0.18); color: #ffce8a; } + +.routing-toggle { + position: relative; + display: inline-block; + width: 38px; + height: 22px; + cursor: pointer; + flex-shrink: 0; +} +.routing-toggle input { opacity: 0; width: 0; height: 0; } +.routing-toggle-track { + position: absolute; inset: 0; + background: rgba(255, 255, 255, 0.12); + border-radius: 999px; + transition: background 120ms ease; +} +.routing-toggle-track::before { + content: ''; + position: absolute; left: 3px; top: 3px; + width: 16px; height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 120ms ease; +} +.routing-toggle input:checked + .routing-toggle-track { + background: var(--accent-color, #6c63ff); +} +.routing-toggle input:checked + .routing-toggle-track::before { + transform: translateX(16px); +} + +.routing-apply-bar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--surface-color, rgba(20, 20, 28, 0.95)); + border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + border-radius: 6px; + margin-top: 0.5rem; +} +.routing-apply-hint { color: var(--text-secondary, #a0a0a0); font-size: 13px; } + +.routing-empty { + text-align: center; color: var(--text-secondary, #888); + padding: 1rem; font-size: 13px; font-style: italic; +} diff --git a/containers/libreportal/frontend/css/service-buttons.css b/containers/libreportal/frontend/css/service-buttons.css new file mode 100644 index 0000000..4fab42c --- /dev/null +++ b/containers/libreportal/frontend/css/service-buttons.css @@ -0,0 +1,198 @@ + + +/* Service URL trigger buttons and popovers (visible on app cards). Extracted from style.css. */ + +.service-buttons-container { + display: flex; + flex-direction: row; + gap: 8px; + flex-wrap: wrap; + margin-top: 16px; +} + +.service-button { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px solid rgba(var(--accent-rgb), 0.30); + border-radius: 6px; + color: var(--text-primary); + text-decoration: none; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; + cursor: pointer; +} + +.service-button:hover { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.55); + transform: translateY(-1px); +} + +.service-button .service-icon { + font-size: 18px; + flex-shrink: 0; +} + +.service-button .service-text { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.service-button .service-external-icon { + font-size: 14px; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.service-button:hover .service-external-icon { + opacity: 1; +} + +.service-buttons-container .no-buttons { + color: rgba(var(--text-rgb), 0.5); + font-size: 14px; + text-align: center; + padding: 20px; +} + +/* Service Trigger on App Cards */ +.service-trigger { + position: relative; + flex-shrink: 0; + order: -1; /* left of manage button */ +} + +.service-trigger-icon { + width: 100%; + height: 100%; + min-width: 38px; + background: linear-gradient(135deg, var(--status-success), #1e7e34); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 1px solid rgba(var(--status-success-rgb), 0.5); + transition: all 0.2s ease; + color: #ffffff; + padding: 0 10px; + gap: 5px; + font-size: 12px; + font-weight: 600; +} + +.service-trigger:hover .service-trigger-icon, +.service-trigger.open .service-trigger-icon { + background: linear-gradient(135deg, var(--status-success-hover), #155724); + border-color: rgba(var(--status-success-rgb), 0.8); +} + +.service-trigger-popup { + display: none; + position: absolute; + bottom: calc(100% + 10px); + left: 0; + background: rgba(12, 12, 18, 0.97); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 10px; + padding: 8px; + min-width: 210px; + z-index: 1000; + backdrop-filter: blur(12px); +} + +.service-trigger.open .service-trigger-popup { + display: block; +} + +.service-trigger-popup::after { + content: ''; + position: absolute; + bottom: -6px; + left: 14px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid rgba(12, 12, 18, 0.97); +} + +.service-trigger-popup .service-button { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 11px; + border-radius: 7px; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.07); + color: var(--text-primary); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: all 0.15s ease; + margin-bottom: 4px; + white-space: nowrap; +} + +.service-trigger-popup .service-button:last-child { + margin-bottom: 0; +} + +.service-trigger-popup .service-button:hover { + background: rgba(var(--status-success-rgb), 0.18); + border-color: rgba(var(--status-success-rgb), 0.35); +} + +.service-trigger-popup .service-button svg { + flex-shrink: 0; + opacity: 0.65; +} + +.service-trigger-popup .service-button:hover { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.40); +} + +.service-trigger-popup .service-button svg { + flex-shrink: 0; + opacity: 0.65; +} + +/* Per-port lock badge on a service URL button — surfaces only on the + specific URLs whose port has login_required=true. */ +.service-button.protected { + border-color: rgba(245, 158, 11, 0.35); +} + +.service-lock-icon { + display: inline-flex; + align-items: center; + color: #fbbf24; + margin-left: 4px; + cursor: help; +} + +.service-lock-icon svg { + stroke: currentColor; +} + cyan + gradient accent so it reads as the "info / status" entry-point + distinct from the URL-open buttons that follow. */ +.service-button.service-button-welcome { + font: inherit; + cursor: pointer; + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.40); + color: var(--text-primary); +} + +.service-button.service-button-welcome:hover { + background: rgba(var(--accent-rgb), 0.25); + border-color: rgba(var(--accent-rgb), 0.65); + transform: translateY(-1px); +} + +.service-button.service-button-welcome .service-icon { font-size: 14px; line-height: 1; } diff --git a/containers/libreportal/frontend/css/services.css b/containers/libreportal/frontend/css/services.css new file mode 100644 index 0000000..c1e6fab --- /dev/null +++ b/containers/libreportal/frontend/css/services.css @@ -0,0 +1,199 @@ +/* + Services tab — rows reuse the .task-item / .task-header / .task-info / + .task-actions / .task-details / .log-container pattern from the + task list so the two surfaces look identical. The only service-only + bits are the status dot inside the status pill, the port chips, and + a streaming-state hint on the log container. +*/ + +.services-section { + padding: 0; +} + +/* Mirrors .config-title for visual parity across tabs. */ +.services-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.services-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.services-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.services-list { + display: flex; + flex-direction: column; +} + +/* Recessed dark panel wrapping the service rows — mirrors the + .tasks-container the Tasks tab uses on the app detail page so the + two tabs share one visual idiom. rgba(bg, 0.2) reads as a sunken + pocket inside the tab-pane's glass surface. */ +.services-rows { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 16px; + margin: 16px; + background: rgba(var(--bg-rgb), 0.2); + border-radius: 8px; +} + +/* ------------------------------------------------------------------ */ +/* Loading + empty + error states */ +/* ------------------------------------------------------------------ */ +.services-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 2rem; + color: var(--text-secondary, var(--text-muted)); +} + +.services-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.15); + border-top-color: var(--accent-color, var(--accent)); + border-radius: 50%; + animation: services-spin 0.7s linear infinite; +} + +@keyframes services-spin { to { transform: rotate(360deg); } } + +.services-empty { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, var(--text-muted)); +} + +.services-empty-icon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; +} + +.services-empty p { + margin: 0.25rem 0; +} + +.services-empty-hint { + font-size: 0.85rem; + opacity: 0.75; +} + +/* ------------------------------------------------------------------ */ +/* Status dot inside the .task-status pill */ +/* ------------------------------------------------------------------ */ +.service-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + background: var(--text-muted); +} + +@keyframes service-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* ------------------------------------------------------------------ */ +/* Port + IP chips inside .task-info (alongside status / time) */ +/* ------------------------------------------------------------------ */ +.service-port { + display: inline-flex; + align-items: center; + gap: 2px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + background: rgba(var(--accent-rgb), 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.25); + color: var(--text-secondary); + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.service-port-arrow { + opacity: 0.5; + margin: 0 1px; +} + +.service-port-proto { + margin-left: 4px; + font-size: 0.65rem; + opacity: 0.6; + text-transform: uppercase; +} + +.service-ip { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 4px; + padding: 1px 6px; + font-size: 0.72rem; + color: var(--text-secondary, var(--text-muted)); +} + +/* The Open button — flagged with .open on top of .task-btn so the + shared task-row hover styles still apply but a slightly different + accent makes it distinguishable from Restart. */ +.task-btn.open { + color: var(--text-secondary); +} +.task-btn.open:hover { + background: rgba(var(--accent-rgb), 0.15); +} + +.service-app-icon { + width: 30px; + height: 30px; + flex-shrink: 0; +} + +/* ------------------------------------------------------------------ */ +/* Streaming state hint on the log container */ +/* ------------------------------------------------------------------ */ +.service-log-output[data-stream="connecting"]::before { + content: 'Connecting…'; + color: var(--status-warning); + display: block; + margin-bottom: 0.25rem; +} + +.service-log-output[data-stream="disconnected"]::before { + content: '⚠ disconnected — retrying…'; + color: var(--status-warning); + display: block; + margin-bottom: 0.25rem; +} + +.service-log-output[data-stream="closed"]::after { + content: '— stream closed —'; + color: var(--text-muted); + display: block; + margin-top: 0.25rem; +} + +/* Spinner-on-restart while the request is in flight, mirroring the + subtle “task is doing something” visual cue used by the task list. */ +.task-btn.is-running { + opacity: 0.6; + cursor: wait; +} diff --git a/containers/libreportal/frontend/css/setup-wizard.css b/containers/libreportal/frontend/css/setup-wizard.css new file mode 100755 index 0000000..7bb9770 --- /dev/null +++ b/containers/libreportal/frontend/css/setup-wizard.css @@ -0,0 +1,1054 @@ +/* ────────────────────────────────────────────────────────────────────── + Setup Wizard — multi-step slide-right + Reuses the shared .aurora-bg + .aurora-stars from aurora-background.css + so it shares the loading screen's visual identity. The wizard itself is + a translucent shell over that background, with a horizontal track of + step panels that slides as the user advances. + ────────────────────────────────────────────────────────────────────── */ + +body.setup-wizard-open { + overflow: hidden; +} + +.setup-wizard { + position: fixed; + inset: 0; + z-index: 9999; + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: 2rem 1.5rem; + opacity: 0; + animation: setupFadeIn 0.6s ease forwards; + box-sizing: border-box; +} + +.setup-wizard.hiding { + animation: setupFadeOut 0.5s ease forwards; +} + +.setup-wizard.setup-launched .setup-card { + transform: scale(0.96); + opacity: 0.5; + filter: blur(2px); + transition: all 0.5s ease; +} + +@keyframes setupFadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes setupFadeOut { from { opacity: 1; } to { opacity: 0; } } + +/* Vertical stack: logo header on top, card below — same shape as + `.login-content` in the login overlay so the two surfaces share a + visual identity. The aurora-header / aurora-logo / aurora-subtitle + classes themselves are inherited from aurora-background.css so the + logo treatment is byte-identical to login + loading. */ +.setup-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 620px; + margin: 1rem; +} + +/* Header inherits .aurora-header / .aurora-logo sizing from + aurora-background.css so it matches the loading screen byte-for-byte — + single source of truth. The only wizard-specific tweak is a slightly + tighter bottom margin since a card sits below it. */ +.setup-content .aurora-header { + margin-bottom: 1.5rem; +} + +/* Card — translucent panel matching .login-card / loading screen style */ +.setup-card { + width: 100%; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 14px; + padding: 24px 28px 20px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + animation: setupCardRise 0.4s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both; + display: flex; + flex-direction: column; + gap: 20px; +} + +@keyframes setupCardRise { + from { transform: translateY(16px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +/* Progress bar — same look as loading screen's progress section */ +.setup-progress { + display: flex; + flex-direction: column; + gap: 8px; +} + +.setup-progress-bar { + background: rgba(var(--text-rgb), 0.10); + border-radius: 8px; + padding: 2px; + border: 1px solid rgba(var(--text-rgb), 0.08); + height: 12px; + box-sizing: border-box; + overflow: hidden; +} + +.setup-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 6px; + width: 0%; + transition: width 0.45s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; +} + +.setup-progress-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(var(--text-rgb),0.3), transparent); + animation: setupShimmer 2s infinite; +} + +@keyframes setupShimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.setup-progress-text { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: rgba(var(--text-rgb), 0.78); + font-family: 'SF Mono', Menlo, monospace; + letter-spacing: 0.5px; +} + +.setup-progress-text > #sw-progress-step { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.setup-progress-sep { + color: rgba(var(--text-rgb), 0.20); + margin: 0 2px; +} + +.setup-progress-icon { + display: inline-flex; + align-items: center; + color: var(--accent); +} + +.setup-progress-name { + color: var(--text-primary); + font-weight: 600; + letter-spacing: 0.3px; +} + +/* Step transitions — fade in / fade out. The previous slide-track approach + fought browser flex-basis math whenever step content height varied + (e.g. step 2 reveals the domain field when Public is toggled). Fade is + simpler: only the active step is in the layout, the card naturally + heights to its content, and the swap feels like the wizard "settles + into the next thought" rather than swinging horizontally. */ +.setup-track-wrap { + position: relative; + width: 100%; +} + +.setup-track { + display: block; + width: 100%; +} + +.setup-step { + display: none; + flex-direction: column; + gap: 16px; +} + +.setup-step.active { + display: flex; + animation: stepFadeIn 0.32s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes stepFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.setup-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +/* Multi-domain editor — list of removable rows + an "Add domain" button. */ +.setup-domain-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.setup-domain-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Empty status pill collapses so blank-domain rows don't claim vertical space */ +.setup-domain-row .setup-dns-status:empty { + display: none; +} + +.setup-domain-row .setup-input-row { + align-items: stretch; +} + +.setup-domain-remove { + flex: 0 0 auto; + width: 36px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + color: rgba(var(--text-rgb), 0.65); + font-size: 18px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.setup-domain-remove:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.45); + color: var(--status-danger); +} + +.setup-domain-add { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px dashed rgba(var(--accent-rgb), 0.40); + border-radius: 10px; + padding: 8px 14px; + color: var(--accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.setup-domain-add:hover { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.65); + border-style: solid; + transform: translateY(-1px); +} + +.setup-domain-add span { + font-size: 16px; + line-height: 1; +} + +.setup-step-note { + font-size: 12px; + color: rgba(var(--text-rgb), 0.65); + margin: 12px 0 0; + font-style: italic; +} + +.setup-section-hint { + font-size: 12px; + color: rgba(var(--text-rgb), 0.62); + margin: 0 0 10px; +} + +/* Field styling — matches the login form's compact subtle look: + small lowercase labels, translucent inputs with cyan focus glow. */ +.setup-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.setup-field label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + letter-spacing: 0.02em; + text-transform: none; + margin: 0; +} + +.setup-field input[type=text], +.setup-field input[type=email], +.setup-field select { + width: 100%; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 8px; + padding: 0.6rem 0.875rem; + color: var(--text-primary); + font-size: 0.95rem; + font-family: inherit; + box-sizing: border-box; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.setup-field input[type=text]:focus, +.setup-field input[type=email]:focus, +.setup-field select:focus { + outline: none; + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.18); +} + +.setup-field input::placeholder { + color: var(--text-secondary); +} + +/* Live validation — green border + glow when valid, red when invalid. + The error message is held on data-error and surfaced via a tooltip + that floats ABOVE the input on focus/hover (like the ? badge does). + Uses a bright mint #86efac (134,239,172) so the border reads against + the dark wizard backdrop — the theme's #28a745 is too muddy here. */ +.setup-field input.is-valid, +.setup-field select.is-valid { + border-color: #86efac; + box-shadow: 0 0 0 3px rgba(134, 239, 172, 0.22); +} + +.setup-field input.is-invalid, +.setup-field select.is-invalid { + border-color: rgba(var(--status-danger-rgb), 0.85); + box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.22); +} + +/* Mirror the valid/invalid state onto the custom-select button when the + native — the browser's + native chevron occupies the right edge, so right:14px collides with it. */ +.setup-input-row:has(select.is-valid)::after, +.setup-input-row:has(select.is-invalid)::after { + right: 32px; +} + +.setup-input-row:has(select.is-valid) .setup-input-with-icon, +.setup-input-row:has(select.is-invalid) .setup-input-with-icon { + padding-right: 3.5rem !important; +} + +/* Error message floating above the input — same visual language as the + ? tooltip but anchored to the input row. We read the message from + data-error which the JS mirrors from the input onto the row (pseudo + elements can only read attrs from their own host element). */ +.setup-input-row[data-error]::before { + content: attr(data-error); + position: absolute; + bottom: calc(100% + 8px); + left: 14px; + background: rgba(var(--bg-rgb), 0.45); + color: var(--status-danger); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid rgba(var(--status-danger-rgb), 0.45); + white-space: nowrap; + pointer-events: none; + opacity: 0; + z-index: 11; + transition: opacity 0.18s ease, transform 0.18s ease; + transform: translateY(4px); +} + +.setup-input-row[data-error]:hover::before, +.setup-input-row[data-error]:focus-within::before { + opacity: 1; + transform: translateY(0); +} + +/* Input with leading icon — icon sits absolutely positioned over the + left padding zone of the input. Reroll button (when present) sits to + the right of the input via the row's flex layout. */ +.setup-input-row { + position: relative; + display: flex; + gap: 8px; + align-items: stretch; +} + +.setup-field-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + pointer-events: none; + z-index: 1; + transition: color 0.2s ease, filter 0.2s ease; +} + +.setup-input-row .setup-input-with-icon:focus ~ .setup-field-icon, +.setup-input-row:focus-within .setup-field-icon { + color: var(--accent); +} + +.setup-input-with-icon { + flex: 1; + padding-left: 2.25rem !important; +} + +.setup-field-icon svg { display: block; } + +.setup-field-icon-emoji { + font-size: 16px; + line-height: 1; +} + +.setup-input-row:focus-within .setup-field-icon-emoji { +} + +#sw-name.setup-input-with-icon { + font-family: 'SF Mono', Menlo, monospace; + letter-spacing: 0.3px; + color: var(--accent); + padding-right: 7.75rem !important; +} + +.setup-input-row:has(#sw-name.is-valid) #sw-name, +.setup-input-row:has(#sw-name.is-invalid) #sw-name { + padding-right: 7.75rem !important; +} + +.setup-input-row:has(#sw-name)::after { + display: none; +} + +/* Tooltip "?" badge after the label. Hover or keyboard-focus reveals + a small floating tip with the description text. */ +.setup-tooltip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.15); + color: var(--accent); + font-size: 0.65rem; + font-weight: 700; + margin-left: 6px; + cursor: help; + position: relative; + user-select: none; + vertical-align: middle; + border: 1px solid rgba(var(--accent-rgb), 0.35); + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.setup-tooltip:hover, +.setup-tooltip:focus { + outline: none; + background: rgba(var(--accent-rgb), 0.30); + color: var(--text-primary); +} + +.setup-tooltip::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(var(--bg-rgb), 0.45); + color: var(--text-primary); + font-size: 0.72rem; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(var(--accent-rgb), 0.40); + width: max-content; + max-width: 240px; + white-space: normal; + text-align: left; + line-height: 1.35; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 10; +} + +.setup-tooltip::before { + content: ''; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(var(--accent-rgb), 0.55); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.setup-tooltip:hover::after, +.setup-tooltip:focus::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.setup-tooltip:hover::before, +.setup-tooltip:focus::before { + opacity: 1; +} + +.setup-name-pulse { + animation: setupNamePulse 0.6s ease; +} + +@keyframes setupNamePulse { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 60% { box-shadow: 0 0 0 12px rgba(var(--accent-rgb), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +.setup-manifest { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + height: calc(100% - 12px); + z-index: 2; + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + border: 1px solid rgba(var(--accent-rgb), 0.32); + border-radius: 8px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease, color 0.18s ease; + white-space: nowrap; +} + +.setup-manifest .setup-manifest-icon { + color: var(--accent); + transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), color 0.2s ease, filter 0.2s ease; +} + +.setup-manifest:hover { + background: rgba(var(--accent-rgb), 0.22); + border-color: rgba(var(--accent-rgb), 0.55); + color: var(--text-primary); + transform: translateY(calc(-50% - 1px)); +} + +.setup-manifest:hover .setup-manifest-icon { + color: var(--accent); +} + +/* Click animation: full-spin icon + cosmic burst halo around the button */ +.setup-manifest.manifesting { + animation: manifestBurst 0.7s ease; +} + +.setup-manifest.manifesting .setup-manifest-icon { + transform: rotate(360deg); +} + +@keyframes manifestBurst { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.75), 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 60% { box-shadow: 0 0 0 14px rgba(var(--accent-rgb), 0), 0 0 0 28px rgba(var(--accent-rgb), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0), 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +/* DNS check status */ +.setup-dns-status { + font-size: 12px; + margin-top: 8px; + padding: 6px 10px; + border-radius: 6px; + font-family: 'SF Mono', Menlo, monospace; + min-height: 14px; +} + +.setup-dns-status.checking { + background: rgba(var(--text-rgb), 0.05); + color: rgba(var(--text-rgb), 0.6); +} + +.setup-dns-status.ok { + background: rgba(var(--status-success-rgb), 0.12); + color: var(--status-success); + border: 1px solid rgba(var(--status-success-rgb), 0.3); +} + +.setup-dns-status.warn { + background: rgba(var(--status-warning-rgb), 0.10); + color: var(--status-warning); + border: 1px solid rgba(var(--status-warning-rgb), 0.3); +} + +/* App selection sections */ +.setup-section { + border-top: 1px solid rgba(var(--text-rgb), 0.06); + padding-top: 14px; +} +.setup-section:first-child { border-top: none; padding-top: 0; } + +.setup-section-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(var(--accent-rgb), 1); + margin-bottom: 10px; +} + +.setup-app { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 12px; + cursor: pointer; + margin-bottom: 8px; + transition: all 0.15s ease; +} + +.setup-app:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} + +.setup-app input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + flex-shrink: 0; + cursor: pointer; + border-radius: 6px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; +} + +.setup-app:hover input[type=checkbox] { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.08); +} + +.setup-app input[type=checkbox]:focus-visible { + outline: none; + border-color: rgba(var(--accent-rgb), 0.85); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.20); +} + +.setup-app input[type=checkbox]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.35); +} + +.setup-app input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 14px 14px; + animation: setupCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes setupCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.setup-app:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.setup-app-icon-wrap { + width: 36px; + height: 36px; + border-radius: 9px; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.08); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} + +.setup-app-icon-wrap .setup-app-icon { + width: 24px; + height: 24px; + object-fit: contain; +} + +.setup-app:has(input:checked) .setup-app-icon-wrap { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.setup-app-info { flex: 1; min-width: 0; } +.setup-app-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.setup-app-desc { font-size: 12px; color: rgba(var(--text-rgb), 0.82); margin-top: 2px; } + +/* Parent tile + sub-option as one merged card. Parent loses its bottom + radius; sub-option is a flush drawer below with only the bottom corners + rounded. Shared horizontal bounds so the two pieces read as one. */ +.setup-app-group { margin-bottom: 8px; } + +.setup-app-group .setup-app { + margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-color: rgba(var(--text-rgb), 0.04); +} + +.setup-app-suboption { + display: flex; + align-items: center; + gap: 10px; + /* Left padding lines the sub-checkbox up under the parent's checkbox + (parent: 14px padding + ~3px to centre the smaller 14px box). */ + padding: 7px 14px 8px 17px; + margin: 0; + background: rgba(var(--text-rgb), 0.035); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-top: none; + border-radius: 0 0 12px 12px; + font-size: 12px; + color: rgba(var(--text-rgb), 0.82); + cursor: pointer; + transition: all 0.15s ease; +} + +/* When the parent is selected, the drawer picks up the same blue tint so + the merged card reads as one selected unit. */ +.setup-app-group:has(.setup-app input[type=checkbox]:checked) .setup-app { + border-bottom-color: rgba(var(--accent-rgb), 0.30); +} +.setup-app-group:has(.setup-app input[type=checkbox]:checked) .setup-app-suboption { + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + border-top: none; +} + +.setup-app-suboption:hover { + background: rgba(var(--accent-rgb), 0.12); +} +.setup-app-suboption.disabled { + opacity: 0.35; + pointer-events: none; +} +.setup-app-suboption input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + flex-shrink: 0; + cursor: pointer; + border-radius: 4px; + background: rgba(var(--text-rgb), 0.04); + border: 1.4px solid rgba(var(--text-rgb), 0.20); + position: relative; + transition: background 0.15s ease, border-color 0.15s ease; +} +.setup-app-suboption:hover input[type=checkbox] { + border-color: rgba(var(--accent-rgb), 0.55); +} +.setup-app-suboption input[type=checkbox]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); +} +.setup-app-suboption input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 10px 10px; +} +.setup-app-suboption-label { font-weight: 500; } + +/* Navigation */ +.setup-nav { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.setup-btn-back, +.setup-btn-next { + background: rgba(var(--text-rgb), 0.06); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 12px; + padding: 14px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.setup-btn-back { flex: 0 0 auto; } +.setup-btn-next { flex: 1; } + +.setup-btn-back:hover:not(:disabled), +.setup-btn-next:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.14); + border-color: rgba(var(--accent-rgb), 0.40); + transform: translateY(-1px); +} + +.setup-btn-back:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.setup-launch { + flex: 1; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + border: none; + border-radius: 12px; + padding: 14px 18px; + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: all 0.2s ease; +} + +.setup-launch:hover:not(:disabled) { + transform: translateY(-2px); + filter: brightness(1.05); +} + +.setup-launch:active:not(:disabled) { transform: translateY(0); } +.setup-launch:disabled { opacity: 0.6; cursor: not-allowed; } + +.setup-launch-arrow { transition: transform 0.2s ease; } +.setup-launch:hover:not(:disabled) .setup-launch-arrow { transform: translateX(4px); } + +/* Error */ +.setup-error { + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; +} + +/* Top-nav disabled state — applied while setup isn't complete. */ +.topbar-nav.setup-needed .nav-item { + opacity: 0.35; + pointer-events: none; + filter: grayscale(60%); +} + +/* Setup-in-progress banner — pinned to top of viewport while the wizard's + tasks are still running (any page). Auto-removed when finalize completes. */ +.setup-progress-banner { + position: fixed; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 9000; + background: rgba(var(--bg-rgb), 0.45); + border: 1px solid rgba(var(--accent-rgb), 0.40); + border-radius: 12px; + padding: 10px 16px; + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + min-width: 320px; + max-width: min(520px, 92vw); + animation: setupBannerIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.setup-progress-banner.leaving { + animation: setupBannerOut 0.35s ease both; +} + +.setup-progress-banner.failed { + border-color: rgba(var(--status-danger-rgb), 0.5); +} + +.setup-progress-banner-inner { + display: grid; + grid-template-columns: 18px 1fr; + grid-template-rows: auto auto; + column-gap: 12px; + align-items: center; +} + +.setup-progress-banner-icon { + grid-row: 1 / span 2; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.setup-progress-banner.failed .setup-progress-banner-icon { + color: var(--status-danger); +} + +.setup-progress-banner-text { + grid-column: 2; + grid-row: 1; + letter-spacing: 0.2px; +} + +.setup-progress-banner-text strong { + font-weight: 600; + color: var(--text-primary); +} + +.setup-progress-banner-count { + color: rgba(var(--text-rgb), 0.78); + font-family: 'SF Mono', Menlo, monospace; + font-size: 12px; + margin-left: 4px; +} + +.setup-progress-banner-bar { + grid-column: 2; + grid-row: 2; + height: 4px; + background: rgba(var(--text-rgb), 0.08); + border-radius: 999px; + overflow: hidden; + margin-top: 6px; +} + +.setup-progress-banner-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 999px; + transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.setup-progress-banner.failed .setup-progress-banner-fill { + background: linear-gradient(90deg, var(--status-danger), var(--status-danger-hover)); +} + +@keyframes setupBannerIn { + from { opacity: 0; transform: translate(-50%, -16px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +@keyframes setupBannerOut { + from { opacity: 1; transform: translate(-50%, 0); } + to { opacity: 0; transform: translate(-50%, -16px); } +} + +@media (max-width: 600px) { + .setup-shell { padding: 22px 18px 18px; border-radius: 14px; } + .setup-logo h1 { font-size: 20px; } + .setup-input-row { flex-direction: column; } + .setup-input-row .setup-field-icon { top: 22px; transform: none; } + .setup-input-row .setup-input-with-icon { padding-left: 2.25rem !important; } + .setup-reroll { padding: 10px; } + .setup-nav { flex-direction: column; } + .setup-btn-back { order: 2; } + .setup-btn-next, .setup-launch { order: 1; } +} diff --git a/containers/libreportal/frontend/css/sidebar.css b/containers/libreportal/frontend/css/sidebar.css new file mode 100644 index 0000000..d511981 --- /dev/null +++ b/containers/libreportal/frontend/css/sidebar.css @@ -0,0 +1,155 @@ + + +/* Sidebar layout and category navigation items. Extracted from style.css. */ + +/* Sidebar — full column height so its glass background paints the + entire side of the viewport even when the items don't fill it. + Internal scroll keeps the list usable without the page scrolling. */ +.sidebar { + width: 220px; + height: 100%; + display: flex; + flex-direction: column; + background: var(--sidebar-bg); + color: var(--sidebar-text); + border-right: 1px solid var(--sidebar-border); + position: relative; + z-index: 100; + transition: transform 0.3s ease; + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); + overflow-y: auto; +} + +/* Tasks sidebar only: the first sidebar-category sits flush against the top + without any top padding. Apps and config sidebars don't need this — their + first items already have spacing baked in. */ +.tasks-layout .sidebar { + padding-top: 20px; +} + +/* Apps sidebar search bar — glass input pinned to the top of the + sidebar above the category list. Toggles the clear (×) button + visibility via .has-value. */ +.apps-search { + position: relative; + padding: 14px 20px; + border-bottom: 1px solid var(--sidebar-border); +} + +.apps-search-icon { + position: absolute; + left: 32px; + top: 50%; + transform: translateY(-50%); + color: rgba(var(--text-rgb), 0.55); + pointer-events: none; +} + +.apps-search-input { + width: 100%; + padding: 9px 34px 9px 36px; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.apps-search-input::placeholder { + color: rgba(var(--text-rgb), 0.5); +} + +.apps-search-input:focus { + outline: none; + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); +} + +/* Strip the native search-cancel (we render our own × button). */ +.apps-search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.apps-search-clear { + position: absolute; + right: 28px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border: none; + background: rgba(var(--text-rgb), 0.10); + color: rgba(var(--text-rgb), 0.7); + border-radius: 50%; + font-size: 18px; + line-height: 1; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: background 0.18s ease, color 0.18s ease; +} + +.apps-search-clear:hover { + background: rgba(var(--status-danger-rgb), 0.20); + color: var(--status-danger); +} + +.apps-search.has-value .apps-search-clear { + display: flex; +} + +.sidebar h2 { + padding: 20px; + text-align: center; + font-size: 20px; + font-weight: 600; + border-bottom: 1px solid var(--sidebar-border); +} + +.category { + padding: 15px 20px; + cursor: pointer; + color: var(--text-secondary); + border-bottom: 1px solid var(--sidebar-border); + display: flex; + align-items: center; + gap: 10px; + transition: background 0.2s, color 0.2s; +} + +.category:hover, +.category.active { + background: var(--surface-hover); + color: var(--text-primary); +} + +.category img, +.category .category-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Mobile: take the sidebar out of flex flow entirely so the main + content fills the full viewport. The drawer carries its contents + when the burger is open. Lives here (and not in style.css) because + sidebar.css loads after style.css — without this co-location the + base .sidebar { position: relative } below wins the cascade and the + sidebar keeps its 220px column at mobile widths. */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + top: 60px; + left: 0; + height: calc(100vh - 60px); + transform: translateX(-100%); + border-right: none; + z-index: 100; + } +} diff --git a/containers/libreportal/frontend/css/style.css b/containers/libreportal/frontend/css/style.css new file mode 100755 index 0000000..7a4ee06 --- /dev/null +++ b/containers/libreportal/frontend/css/style.css @@ -0,0 +1,3816 @@ + + +/* Reset */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +/* ---------------------------------------------------------------------- + Global themed scrollbars. + + - Firefox uses scrollbar-color (and scrollbar-width: thin). + - WebKit/Blink uses the ::-webkit-scrollbar pseudo elements. + + The thumb's transparent border + background-clip: padding-box gives + the thumb breathing room (the visible thumb is thinner than the + 8px channel) so it feels less heavy. Hover swaps the thumb to the + theme accent. Track is transparent so the scrollbar floats over + whatever surface it's on, matching the cosmic glass elsewhere. + ---------------------------------------------------------------------- */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(var(--text-rgb), 0.20) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.20); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 8px; + transition: background-color 0.18s ease; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.55); + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:active { + background: rgba(var(--accent-rgb), 0.75); + background-clip: padding-box; +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + display: flex; + flex-direction: column; + min-height: 100vh; + padding-top: 60px; + background: var(--surface-bg); + background-attachment: fixed; + color: var(--text-primary); +} + +/* Nebula body — same recipe as .aurora-bg.aurora-static used on the + loading + login screens, so the chrome and main content share one + atmosphere. Three layers cover the viewport: the base radial + + linear gradient on html, a static cyan-blob plume on ::before + (mirrors aurora-bg::after), and the star-particle pattern on + ::after (mirrors .aurora-stars::before). No animation. */ +html[data-theme="nebula"] { + background: + radial-gradient(ellipse at 20% 30%, var(--gradient-mid) 0%, transparent 55%), + radial-gradient(ellipse at 80% 70%, var(--gradient-to) 0%, transparent 55%), + linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-from) 40%, var(--gradient-mid) 100%); + background-attachment: fixed; +} + +html[data-theme="nebula"] body { + background: transparent; +} + +html[data-theme="nebula"]::before { + content: ''; + position: fixed; + inset: -10%; + z-index: -2; + background: + /* Warm cosmic accents — magenta + violet bloom for nebula richness */ + radial-gradient(circle at 12% 88%, rgba(180, 90, 220, 0.32) 0%, transparent 42%), + radial-gradient(circle at 88% 12%, rgba(255, 120, 180, 0.22) 0%, transparent 38%), + /* Cyan accent plumes (theme accent colour) */ + radial-gradient(circle at 18% 22%, rgba(var(--accent-rgb), 0.42) 0%, transparent 45%), + radial-gradient(circle at 78% 18%, rgba(var(--accent-rgb), 0.34) 0%, transparent 42%), + radial-gradient(circle at 30% 78%, rgba(var(--accent-rgb), 0.30) 0%, transparent 48%), + radial-gradient(circle at 82% 80%, rgba(var(--accent-rgb), 0.40) 0%, transparent 46%), + radial-gradient(circle at 50% 50%, rgba(var(--accent-rgb), 0.14) 0%, transparent 60%); + pointer-events: none; +} + +html[data-theme="nebula"]::after { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + background-image: + radial-gradient(1.5px 1.5px at 12px 18px, rgba(var(--text-rgb), 0.9), transparent 60%), + radial-gradient(1px 1px at 47px 92px, rgba(var(--accent-rgb), 0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 110px 40px, rgba(var(--text-rgb), 0.75), transparent 60%), + radial-gradient(1px 1px at 165px 130px, rgba(var(--accent-rgb), 0.70), transparent 60%); + background-size: 200px 200px; + pointer-events: none; +} + +.mobile-menu-toggle { + display: none; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: background 0.2s; +} + +.mobile-menu-toggle:hover { + background: rgba(var(--text-rgb), 0.1); +} + +.theme-selector { + padding: 6px 12px; + border: 1px solid; + border-radius: 4px; + font-size: 14px; + cursor: pointer; +} + +/* Layout — config page (and any other page using .container/.main) uses + a viewport-locked flex row so the sidebar paints its background the + full column height and the main pane scrolls independently. Same + pattern as .tasks-layout and .apps-layout. */ +.container { + display: flex; + width: 100%; + height: calc(100vh - 60px); + overflow: hidden; +} + +.mobile-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(var(--bg-rgb), 0.5); + z-index: 99; + opacity: 0; + transition: opacity 0.3s ease; +} + +.mobile-overlay.active { + display: block; + opacity: 1; +} + +/* Main content — fills the remaining width inside .container/.apps-layout + and scrolls internally so the sidebar can stay locked at viewport + height. */ +.main { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + padding: 0px; + overflow-y: auto; +} + +.advanced-field.is-hidden { + display: none; +} + +.mullvad-generate-field .mullvad-generate-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.mullvad-generate-field .mullvad-generate-btn { margin: 0; } +.mullvad-generate-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 10px; + border-radius: 12px; + border: 1px solid rgba(var(--text-rgb), 0.15); + background: rgba(var(--text-rgb), 0.05); + color: rgba(var(--text-rgb), 0.6); +} +.mullvad-generate-status.is-configured { + background: rgba(var(--status-success-rgb), 0.12); + border-color: rgba(var(--status-success-rgb), 0.35); + color: var(--status-success); +} +.mullvad-generate-status .mullvad-generate-tick { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid currentColor; + font-size: 11px; + line-height: 1; +} +.mullvad-generate-status.is-configured .mullvad-generate-tick { + background: var(--status-success); + border-color: var(--status-success); + color: #0b3d1c; +} + +.gluetun-countries-field { + display: flex; + align-items: center; + gap: 12px; +} +.gluetun-countries-display { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: nowrap; + gap: 6px; + height: 32px; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--card-bg); + overflow: hidden; + white-space: nowrap; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); +} +.gluetun-country-chip { flex-shrink: 0; } +.gluetun-country-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 12px; + background: rgba(52, 152, 219, 0.15); + border: 1px solid rgba(52, 152, 219, 0.3); + color: var(--text-color); + font-size: 12px; +} +.gluetun-flag { + font-size: 14px; + line-height: 1; + font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; +} +.gluetun-country-empty { + color: var(--text-secondary, #888); + font-style: italic; + font-size: 12px; +} +.gluetun-countries-edit { flex-shrink: 0; } +.gluetun-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} +.gluetun-modal .modal-close { + background: none; + border: none; + font-size: 24px; + color: var(--text-color); + cursor: pointer; + padding: 0; +} +.gluetun-modal .modal-body { + padding: 20px; + overflow-y: auto; +} +.gluetun-modal .modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border-color); +} +.gluetun-provider-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(56, 189, 248, 0.10); + border: 1px solid rgba(56, 189, 248, 0.30); + border-radius: 10px; + margin-bottom: 14px; +} +.gluetun-provider-icon-wrap { + width: 44px; + height: 44px; + border-radius: 9px; + background: var(--surface-bg-solid); + border: 1px solid rgba(var(--text-rgb), 0.10); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} +.gluetun-provider-icon { width: 32px; height: 32px; object-fit: contain; } +.gluetun-provider-text { flex: 1; min-width: 0; } +.gluetun-provider-label { margin: 0; font-size: 12px; color: rgba(var(--text-rgb), 0.60); text-transform: uppercase; letter-spacing: 0.5px; } +.gluetun-provider-name { margin: 2px 0 0 0; font-size: 16px; font-weight: 600; color: var(--text-primary); text-transform: capitalize; } + +.gluetun-search-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + margin-bottom: 12px; +} +.gluetun-search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.gluetun-search-row:focus-within { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12); +} +.gluetun-search-icon { color: rgba(var(--text-rgb), 0.55); flex-shrink: 0; } +.gluetun-country-search { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-size: 14px; + padding: 2px 0; +} +.gluetun-search-actions { + display: flex; + gap: 8px; +} +.gluetun-search-actions .btn { flex: 1 1 0; } + +.gluetun-modal .modal-footer { + display: flex; + gap: 12px; +} +.gluetun-modal .modal-footer .btn { flex: 1 1 0; } + +.gluetun-country-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px 14px; + max-height: 45vh; + overflow-y: auto; + padding: 4px 2px; +} +.gluetun-country-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.gluetun-country-item:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(56, 189, 248, 0.25); +} +.gluetun-country-item:has(input:checked) { + background: rgba(56, 189, 248, 0.10); + border-color: rgba(56, 189, 248, 0.45); +} + +.gluetun-country-item input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + border-radius: 5px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; + margin: 0; +} +.gluetun-country-item:hover input[type=checkbox] { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.08); +} +.gluetun-country-item input[type=checkbox]:focus-visible { + outline: none; + border-color: rgba(56, 189, 248, 0.85); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.20); +} +.gluetun-country-item input[type=checkbox]:checked { + background: linear-gradient(135deg, #38bdf8, #818cf8); + border-color: rgba(56, 189, 248, 0.9); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.35); +} +.gluetun-country-item input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 13px 13px; + animation: gluetunCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes gluetunCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.gluetun-country-name { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 15px; + color: var(--text-primary); + font-weight: 500; +} +.gluetun-country-name .gluetun-flag { + font-size: 18px; + line-height: 1; +} +.gluetun-country-empty-msg { + grid-column: 1 / -1; + color: var(--text-secondary, #888); + font-style: italic; +} + +/* Output console */ +.console { + background: var(--console-bg); + color: var(--console-text); + border: 1px solid var(--border); + border-radius: 12px; + padding: 5px; /* Further reduced to 5px */ + height: 125px; /* Reduced from 250px to half */ + overflow-y: auto; + white-space: pre-wrap; + font-size: 14px; + font-family: 'Courier New', monospace; + margin: 0; + position: relative; /* Ensure proper positioning */ + top: 0; /* Force to top */ +} + +.console-section { + margin-top: 20px; +} + +/* Remove gaps in console output */ +.log-entry { + margin: 0; + padding: 2px 8px; + border-radius: 4px; + display: block; + line-height: 1.4; +} + +.log-entry:first-child { + padding-top: 0; + margin-top: 0; + border-top: none; +} + +.log-entry:last-child { + padding-bottom: 0; +} + +.log-timestamp { + color: rgba(var(--text-rgb), 0.5); + font-size: 10px; + margin-right: 8px; + display: inline; +} + +.tabs-wrapper { + display: block; + width: 100%; +} + +.tabs-list { + display: flex; + background: var(--hover-bg); + border-bottom: 1px solid var(--border-color); + padding: 0; + margin: 0; + width: 100%; + overflow-x: auto; + scrollbar-color: rgba(var(--text-rgb), 0.4) rgba(var(--text-rgb), 0.08); +} + +/* Tabs inside .tabs-wrapper or .tab-navigation share the row evenly so + the bar fills its container instead of leaving empty space on the right. + Children also get centered so labels (and any leading icon/emoji) sit + in the middle of each tab. */ +.tabs-wrapper .tabs-list .tab-button, +.tab-navigation .tab-button { + flex: 1 1 0; + min-width: 0; + text-align: center; + white-space: nowrap; + justify-content: center; +} + +/* Task Status Indicator */ +.task-status-indicator { + position: fixed; + top: 20px; + right: 20px; + background: rgba(33, 150, 243, 0.95); + color: var(--text-primary); + padding: 12px 16px; + border-radius: 8px; + border: 1px solid rgba(76, 175, 80, 0.3); + z-index: 1000; + font-size: 14px; + font-weight: 500; + backdrop-filter: blur(10px); + animation: slideIn 0.3s ease-out; +} + +.task-status-content { + display: flex; + align-items: center; + gap: 8px; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.3); + border-top: 2px solid #4CAF50; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Button States */ +.task-running { + opacity: 0.7; + cursor: not-allowed !important; + position: relative; + overflow: hidden; +} + +.task-running::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(76, 175, 80, 0.1)); + animation: loadingShimmer 1.5s infinite; +} + +@keyframes loadingShimmer { + 0% { left: -100%; } + 50% { left: 100%; } + 100% { left: 100%; } +} + +/* App Header Enhancement */ +.app-header { + position: relative; +} + +.task-highlighted { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); + border: 2px solid #4CAF50; + border-radius: 8px; + transition: all 0.3s ease; +} + +.task-highlighted:hover { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.3), rgba(33, 150, 243, 0.3)); +} + +/* Clean scrollbar from scratch - higher specificity */ +.tabs-wrapper .tabs-list::-webkit-scrollbar { + height: 12px !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.08) !important; + border-radius: 9px !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.4) !important; + border-radius: 9px !important; + border: none !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.5) !important; +} + +/* Dynamic scrollbar enhancement for when tabs-list exists - higher specificity */ +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar { + height: 16px !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.1) !important; + border-radius: 10px !important; + margin: 15px 0 8px 0 !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.5) !important; + border-radius: 10px !important; + border: none !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.6) !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb:hover::-webkit-scrollbar { + height: 11px !important; /* 16px * 2/3 = ~11px */ +} + +.tab-emoji { + font-size: 14px; + /* Coerce the OS to render these as text-presentation glyphs rather + than colour-emoji bitmaps (⚙ instead of ⚙️ etc.). Result is a + monochrome glyph we can theme with `color`, which reads way + better against the dark cosmic gradient than the platform's + greyish emoji bitmaps did. */ + font-variant-emoji: text; + color: var(--accent); + line-height: 1; +} + +/* Active tab uses text-primary so the icon doesn't disappear behind + the accent-tinted pill background. */ +.tab-button.active .tab-emoji, +.tab-button.nav-active .tab-emoji, +.main-tab-button.active .tab-emoji, +.main-tab-button.nav-active .tab-emoji { + color: var(--text-primary); +} + +.tab-name { + font-weight: 500; +} + +.tabs-content { + display: block; + width: 100%; + background: var(--card-bg); + padding: 30px 10px 20px 10px; + border-radius: 0px 0px 12px 12px; +} + +.tab-panel { + display: none; + padding: 5px 24px 5px 24px; + min-height: auto; + animation: fadeIn 0.2s ease; +} + +.tab-panel.active { + display: block; +} + +.panel-header { + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); + display: none; /* Hide duplicate headers */ +} + +.panel-header h4 { + margin: 0 0 6px 0; + color: var(--text-primary, #fff); + font-size: 16px; + font-weight: 600; +} + +.panel-header p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.panel-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; +} + +@keyframes configDirtyIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.dep-required-card { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.30); + border-radius: 10px; + margin-bottom: 10px; +} +.dep-required-icon { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: contain; + background: rgba(var(--text-rgb), 0.04); + padding: 4px; + flex-shrink: 0; +} +.dep-required-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.dep-required-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.dep-required-reason { + font-size: 12px; + color: rgba(var(--text-rgb), 0.70); + line-height: 1.4; +} +.dep-required-action { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +@media (max-width: 560px) { + .dep-required-card { + flex-direction: column; + align-items: stretch; + text-align: center; + } + .dep-required-icon { align-self: center; } + .dep-required-action { justify-content: center; } +} + +.nav-button { + margin-left: auto; + padding: 4px 8px; + background: var(--primary-color); + color: var(--text-primary); + border: none; + border-radius: 4px; + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; +} + +.nav-button:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.nav-button svg { + flex-shrink: 0; +} + +.nav-button.install-button { + background: var(--status-success); +} + +.nav-button.install-button:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +/* Confirmation Dialog - Simple Working Version */ +.confirmation-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(var(--bg-rgb), 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 99999; + display: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.confirmation-overlay.active { + display: block; + opacity: 1; +} + +.confirmation-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--surface-bg-solid); + background: var(--bg-primary, #1a1a1a); + border: 2px solid var(--border-strong); + border: 2px solid var(--border-color, #444); + border-radius: 8px; + max-width: 400px; + width: 90%; + z-index: 100000; + opacity: 1; + transition: all 0.3s ease; + display: none; +} + +.confirmation-overlay.active .confirmation-dialog { + display: block; +} + +.confirmation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid var(--border-strong); + border-bottom: 1px solid var(--border-color, #444); +} + +.confirmation-header h3 { + margin: 0; + color: var(--text-primary); + color: var(--text-primary, #fff); + font-size: 16px; + font-weight: 600; +} + +.confirmation-close { + background: none; + border: none; + color: var(--text-secondary); + color: var(--text-secondary, #ccc); + font-size: 20px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.confirmation-close:hover { + color: var(--text-primary); + color: var(--text-primary, #fff); +} + +.confirmation-body { + padding: 20px; +} + +.confirmation-content { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 15px; +} + +.confirmation-icon { + font-size: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +.confirmation-text { + color: var(--text-primary); + color: var(--text-primary, #fff); + line-height: 1.4; + flex: 1; +} + +.confirmation-checkbox { + padding-top: 15px; + border-top: 1px solid var(--border-strong); + border-top: 1px solid var(--border-color, #444); + display: flex; + justify-content: space-between; + align-items: center; +} + +.confirmation-checkbox label { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + color: var(--text-primary, #fff); + cursor: pointer; + font-size: 14px; + order: 1; +} + +.confirmation-checkbox input { + width: 16px; + height: 16px; +} + +.confirmation-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + padding: 15px 20px; +} + +.confirmation-btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; +} + +.confirmation-btn-cancel { + background: var(--text-muted); + color: var(--text-primary); +} + +.confirmation-btn-cancel:hover { + background: var(--text-secondary); +} + +.confirmation-btn-ticked { + background: var(--status-success) !important; + color: #ffffff !important; +} + +.confirmation-btn-ticked:hover { + background: var(--status-success-hover) !important; +} + +.confirmation-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tab-panel#panel-advanced .panel-header { + background: rgba(255, 107, 53, 0.1); + border-left: 4px solid var(--status-warning); + border-bottom: 1px solid var(--border-color); +} + +.tab-panel#panel-advanced .panel-header h4 { + color: var(--status-warning); + display: flex; + align-items: center; + gap: 8px; +} + +.tab-panel#panel-advanced .panel-header p { + color: #d84315; + font-style: italic; + font-size: 12px; + margin: 4px 0 0 0; +} + +.no-fields { + text-align: center; + padding: 32px; + color: var(--text-secondary, #ccc); + font-style: italic; + background: var(--hover-bg); + border-radius: 6px; + border: 1px dashed var(--border-color); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .tab-button { + flex: 1; + min-width: 60px; + justify-content: center; + font-size: 11px; + padding: 8px 12px; + } + + .main-tab-button { + flex: 1; + min-width: 60px; + justify-content: center; + font-size: 11px; + padding: 8px 12px; + } + + .tab-emoji { + font-size: 12px; + } + + .tab-name { + display: none; + } + + .config-title { + padding: 16px; + } + + .tab-panel { + padding: 24px 16px 16px 16px; + min-height: auto; + } + + .panel-fields { + grid-template-columns: 1fr; + gap: 12px; + } + + .form-field { + gap: 4px; + } + + .form-input, + .form-select, + .form-textarea { + padding: 8px; + font-size: 16px; /* Prevent zoom on iOS */ + } +} + +.timeout { + font-weight: bold; +} + +/* App Configuration Page */ +.app-header { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 30px; +} + +.app-info { + display: flex; + align-items: center; + gap: 20px; +} + +.app-details h1 { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; + color: var(--text-color); +} + +.app-details h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + margin-bottom: 8px; +} + +.app-details .app-long-description { + font-size: 14px; + color: var(--text-secondary, #ccc); + line-height: 1.4; +} + +/* Responsive config field rules moved to config.css (where the base + .config-fields { repeat(3, 1fr) } lives — config.css loads after + style.css so keeping these here got overridden by the unscoped + base rule). */ + +/* Section Toggle Functionality */ +.hidden { + display: none !important; +} + +.section-content { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.section-content.disabled { + opacity: 0.5; + pointer-events: none; +} + +/* App Header with Actions */ +.app-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 30px; + padding: 20px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 12px; + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.app-info { + display: flex; + align-items: center; + flex: 1; +} + +.backup-btn, .uninstall-btn { + padding: 8px 16px; + border: 1px solid rgba(var(--text-rgb), 0.3); + background: transparent; + color: rgba(var(--text-rgb), 0.9); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.backup-btn:hover, .uninstall-btn:hover { + background: rgba(var(--text-rgb), 0.1); + transform: translateY(-1px); +} + +.password-field { + position: relative; + display: flex; + align-items: center; +} + +/* Old notification styles removed - using newer notification system */ + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +/* Info tooltip badge. Markup is ℹ️. + The ℹ️ emoji is drawn by the OS as a multi-color bitmap that doesn't + match our theme. We clip the host (overflow:hidden + text-indent to + push it off-screen) so the emoji is invisible no matter how the + platform fonts behave, then draw a clean italic 'i' via ::after + absolutely positioned inside the cyan circle. */ +.tooltip { + display: inline-block; + width: 16px; + height: 16px; + background: var(--primary-color, var(--accent)); + border-radius: 50%; + cursor: help; + position: relative; + margin-left: 4px; + flex-shrink: 0; + overflow: hidden; + text-indent: 100%; + white-space: nowrap; + font-size: 0; + color: transparent; + vertical-align: middle; + /* Sits below where it looks aligned with adjacent label text. */ + margin-top: -2px; + transition: background 0.18s ease, transform 0.18s ease; +} + +.tooltip::after { + content: 'i'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: Georgia, 'Times New Roman', serif; + font-style: italic; + font-weight: 700; + font-size: 11px; + line-height: 1; + color: var(--text-on-accent, #ffffff); + text-indent: 0; + /* Drops the 'i' inside the circle so it doesn't sit too high. + Tuned with the host's -2px margin-top above. */ + padding-bottom: 0; +} + +.tooltip:hover { + background: var(--accent-hover, var(--primary-color-hover, #4169e1)); + transform: scale(1.08); +} + +.tooltip::before { + content: attr(title); + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + background: rgba(var(--bg-rgb), 0.9); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + white-space: normal; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + max-width: 300px; + word-wrap: break-word; + min-width: 200px; +} + +.tooltip:hover::before { + opacity: 1; + visibility: visible; + z-index: 99999; +} + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +#hidden-options-content { + padding: 20px; +} + +#hidden-options-content h3 { + color: var(--text-secondary, #ccc); + font-size: 16px; + margin-bottom: 16px; + font-style: italic; +} + +.install-btn { + background: var(--status-success); + color: #ffffff; + border: 1px solid var(--status-success); + padding: 14px 28px; + border-radius: 12px; + font-weight: 600; +} + +.install-btn:hover { + background: var(--status-success-hover); + transform: translateY(-2px); +} + +/* Base Button Styles */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.log-entry { + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + margin-bottom: 5px; + padding: 5px; + border-radius: 4px; + background: rgba(var(--bg-rgb), 0.2); + color: rgba(var(--text-rgb), 0.8); + word-break: break-all; +} + +.log-entry.error { + background: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); +} + +.log-entry.success { + background: rgba(var(--status-success-rgb), 0.2); + color: #51cf66; +} + +.log-entry.warning { + background: rgba(var(--status-warning-rgb), 0.2); + color: #ffd43b; +} + +.log-entry.info { + background: rgba(var(--accent-rgb), 0.2); + color: #74c0fc; +} + +/* Error state */ +.error { + padding: 20px; + border-radius: 8px; + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + margin: 20px; +} + +.tab-content { +} + +.tab-pane { + display: none; + animation: fadeIn 0.3s ease-in-out; +} + +.tab-pane.active { + display: block; +} + +.tab-pane h4 { + margin: 0 0 20px 0; + color: rgba(var(--text-rgb), 0.9); + font-size: 16px; + font-weight: 600; + padding-bottom: 10px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.1); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .apps-section { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + +@media (max-width: 768px) { + /* Mobile menu toggle */ + .mobile-menu-toggle { + display: block; + } + + /* Sidebar mobile styles */ + .sidebar { + position: fixed; + top: 60px; + left: 0; + height: calc(100vh - 60px); + transform: translateX(-100%); + z-index: 100; + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + /* App Configuration Page Styles */ + .app-info { + display: flex; + align-items: flex-start; + gap: 24px; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + transition: all 0.2s ease; + } + + .app-info:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow-hover); + border-color: var(--primary-color); + } + + .app-icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + flex-shrink: 0; + transition: all 0.2s ease; + } + + .app-icon img { + width: 48px; + height: 48px; + object-fit: contain; + transition: transform 0.2s ease; + } + + .app-icon:hover img { + transform: scale(1.1); + } + + .app-details { + flex: 1; + min-width: 0; + } + + .app-details h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 8px 0; + line-height: 1.2; + } + + .app-description { + font-size: 16px; + color: var(--text-secondary, #ccc); + margin: 0 0 16px 0; + line-height: 1.5; + } + + .app-meta { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + } + + .category-tag { + background: var(--primary-color); + color: var(--text-primary); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + } + + .status-tag { + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + } + + .status-tag.installed { + background: var(--status-success); + color: var(--text-primary); + } + + .status-tag.available { + background: var(--text-muted); + color: var(--text-primary); + } + + .app-not-found { + text-align: center; + padding: 60px 20px; + color: var(--text-color); + } + + .app-not-found h2 { + font-size: 24px; + font-weight: 600; + margin: 0 0 16px 0; + } + + .config-section { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 22px; + margin-top: 24px; + } + + .config-placeholder { + text-align: center; + } + + .config-placeholder h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 16px 0; + } + + .config-placeholder p { + color: var(--text-secondary, #ccc); + margin: 0 0 24px 0; + line-height: 1.5; + } + + .config-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 24px; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .btn-primary { + background: var(--primary-color); + color: var(--text-primary); + } + + .btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); + } + + .btn-secondary { + color: var(--text-color); + border: 1px solid var(--border-color); + } + + .btn-secondary:hover { + background: var(--border-color); + transform: translateY(-1px); + } + + /* Main content mobile — go edge-to-edge so config/forms inside + don't get squeezed by stacked padding from .main + .config-section. */ + .main { + padding: 0; + } + + /* App cards mobile */ + .apps-section { + grid-template-columns: 1fr; + gap: 16px; + } + + .app-card { + flex-direction: column; + padding: 16px; + gap: 12px; + } + + .app-card-top { + flex-direction: column; + align-items: center; + gap: 12px; + } + + .app-card-icon { + width: 80px; + height: 80px; + align-self: center; + } + + .app-card-content { + text-align: center; + width: 100%; + } + + .app-card-actions { + width: 100%; + min-width: auto; + flex-direction: row; + } + + .app-card-actions button { + flex: 1; + width: 50%; + } + + .app-card-title { + font-size: 16px; + white-space: normal; + line-height: 1.3; + } + + .app-card-description { + font-size: 13px; + } + + .app-card button { + width: 100%; + padding: 14px 20px; + font-size: 15px; + min-height: 48px; + } + + /* Topbar mobile */ + .topbar { + padding: 0 16px; + } + + .topbar-controls { + gap: 8px; + } + + .topbar-nav { + display: none; + } + + .theme-selector { + font-size: 12px; + padding: 4px 8px; + } + + .donate-btn { + padding: 6px 12px; + font-size: 12px; + } + + /* Category tags mobile */ + .app-card-tags { + justify-content: center; + flex-wrap: wrap; + } + + .app-tag { + font-size: 11px; + padding: 3px 6px; + } +} + +@media (max-width: 480px) { + /* Extra small screens */ + .topbar { + padding: 0 12px; + } + + .main { + padding: 0; + } + + .app-card { + padding: 12px; + } + + .app-card-icon { + width: 50px; + height: 50px; + } + + .app-card-title { + font-size: 15px; + } + + .app-card-description { + font-size: 12px; + } + + .donate-btn { + display: none; + } + + .logo { + font-size: 18px; + } +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + padding: 22px; +} + +.dashboard-content { + /* Remove background - let main container show through */ + border-radius: 12px; + border: 1px solid var(--border-color); + padding: 24px; + margin-bottom: 16px; /* Reduced from 32px to reduce gap */ +} + +/* Dashboard Front Page Installed Apps */ +.frontpage-apps-section { + padding: 0 22px 22px; +} + +.frontpage-apps-grid { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.frontpage-app-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.frontpage-app-icon-wrap { + position: relative; + width: 132px; + height: 132px; + background: rgba(var(--text-rgb), 0.08); + border-radius: 22px; + border: 1px solid rgba(var(--text-rgb), 0.15); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + overflow: hidden; +} + +.frontpage-app-tile:hover .frontpage-app-icon-wrap { + transform: translateY(-4px); + border-color: rgba(var(--text-rgb), 0.25); +} + +.frontpage-app-icon-wrap img { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Overlay that appears on hover when services are available — frosted + veil so the icon underneath still reads through, not a black slab. */ +.frontpage-app-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(var(--bg-rgb), 0.45); + border-radius: 18px; + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); + flex-direction: column; + gap: 6px; + padding: 10px; + overflow-y: auto; + justify-content: space-between; +} + +.frontpage-app-icon-wrap:hover .frontpage-app-overlay { + display: flex; +} + +.frontpage-app-overlay a { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 5px; + background: rgba(var(--status-success-rgb), 0.15); + border: 1px solid rgba(var(--status-success-rgb), 0.3); + color: var(--text-primary); + text-decoration: none; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s ease; + flex-shrink: 0; +} + +.frontpage-app-overlay a:hover { + background: rgba(var(--status-success-rgb), 0.3); +} + +.frontpage-app-overlay a svg { + flex-shrink: 0; + opacity: 0.8; + width: 11px; + height: 11px; +} + +.frontpage-app-name { + display: none; +} + +.frontpage-app-manage-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 6px; + background: var(--accent); + border: 1px solid var(--accent); + color: var(--text-primary); + text-decoration: none; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s ease; + flex-shrink: 0; + cursor: pointer; + /* Pin to the bottom of the overlay even when no service buttons are above it. + With justify-content: space-between, a single child sticks to the top — + margin-top: auto consumes the free space and pushes the manage button down. */ + margin-top: auto; +} + +.frontpage-app-manage-btn:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.install-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--status-success); + color: #ffffff; + border: 1px solid var(--status-success); + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.install-btn:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +.stat-card { + background: var(--card-bg); + border: 1px solid rgba(var(--text-rgb), 0.15); + border-radius: 12px; + padding: 24px; + text-align: center; + transition: transform 0.2s ease; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.stat-number { + font-size: 36px; + font-weight: bold; + color: var(--primary-color); + margin-bottom: 8px; +} + +.stat-label { + font-size: 14px; + color: var(--text-color); + opacity: 0.8; +} + +.disk-chart { + position: relative; + display: inline-block; +} + +/* Remove old disk chart styles that might conflict */ +#disk-circle { + display: none; /* Hide old SVG circle */ +} + +.disk-circle-container { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(var(--text-rgb), 0.1); + position: relative; + overflow: hidden; + border: 2px solid rgba(var(--text-rgb), 0.2); +} + +.disk-circle-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--status-success); + transition: height 0.5s ease-in-out; + border-radius: 0 0 50% 50%; +} + +.disk-percentage { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + font-weight: bold; + color: var(--text-primary); + pointer-events: none; + z-index: 10; +} + +.chart-label { + position: relative; + text-align: center; + margin-top: 10px; + pointer-events: none; +} + +.chart-text { + font-size: 10px; + color: var(--text-color); + opacity: 0.7; + margin-top: 2px; +} + +.system-info-card { + text-align: left; + padding: 20px; + position: relative; +} + +.system-details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.system-item { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + font-size: 12px; +} + +.system-label { + color: var(--text-color); + opacity: 0.7; + font-weight: 500; +} + +.system-refresh-btn { + position: absolute; + top: 12px; + right: 12px; + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.15); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(var(--text-rgb), 0.7); + cursor: pointer; + transition: all 0.2s ease; +} + +.system-refresh-btn:hover { + background: rgba(var(--text-rgb), 0.15); + color: rgba(var(--text-rgb), 0.9); +} + +.system-refresh-btn svg { + width: 16px; + height: 16px; + stroke-width: 2; +} + +.system-refresh-tooltip { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: rgba(var(--bg-rgb), 0.8); + color: rgba(var(--text-rgb), 0.9); + padding: 6px 10px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 10; +} + +.system-refresh-btn:hover .system-refresh-tooltip { + opacity: 1; +} + +/* Category descriptions */ +.category-description { + font-size: 14px; + color: var(--text-secondary, #888); + margin: 8px 0 16px 0; + line-height: 1.4; + font-weight: 400; +} + +.git-section-content { + transition: all 0.3s ease; + border-radius: 8px; + overflow: hidden; + margin-top: 14px; +} + +.git-section-content.hidden { + max-height: 0; + padding: 0; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.info-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.info-card h5 { + margin: 0 0 12px 0; + color: var(--primary-color); + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.info-card p { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Domain Building Blocks */ +.domains-wrapper { + margin-bottom: 0px; +} + +.domains-header { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.domains-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #fff); + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.domains-divider { + width: 100%; + height: 2px; + background: var(--primary-color, var(--accent)); + margin-bottom: 20px; +} + +.add-domain-btn { + gap: 8px; + padding: 8px 20px; + margin-top: 12px; + margin-bottom: 12px; + min-width: 140px; + background: var(--primary-color); + color: var(--text-primary); + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +@media (max-width: 1600px) { + .domain-building-blocks { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .domain-building-blocks { + grid-template-columns: 1fr; + gap: 16px; + } +} + +.delete-domain-btn { + background: var(--status-danger); + color: #ffffff; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.delete-domain-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.05); +} + +.delete-domain-btn.disabled { + background: var(--text-muted); + color: #adb5bd; + cursor: not-allowed; + opacity: 0.6; + transform: none; +} + +.delete-domain-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +.delete-icon { + font-size: 16px; + font-weight: bold; + line-height: 1; +} + +/* Reusable spacer component */ +.spacer { + display: block; + width: 100%; +} +.spacer-lg { height: 22px; } + +/* Mail Configuration Master Toggle */ +.mail-master-toggle { + margin-bottom: 0; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +/* Generic Configuration Master Toggle */ +.generic-master-toggle { + margin-bottom: 20px; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +.add-domain-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + min-width: 140px; + background: var(--status-success); + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.add-domain-btn.disabled { + background: var(--text-muted); + cursor: not-allowed; + opacity: 0.7; +} + +.add-domain-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +.add-domain-btn:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +.add-icon { + font-size: 18px; + font-weight: bold; +} + +/* Flash animation for empty domain warning */ +@keyframes flash { + 0%, 100% { + background-color: transparent; + border-color: var(--border-color); + } + 25%, 75% { + background-color: rgba(var(--status-warning-rgb), 0.1); + border-color: var(--status-warning); + } + 50% { + background-color: rgba(var(--status-warning-rgb), 0.2); + border-color: var(--status-warning); + } +} + +/* Password input styling */ +.password-input { + display: flex; + gap: 8px; +} + +.password-input input { + flex: 1; +} + +.password-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--hover-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.password-toggle:hover { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.password-toggle:hover svg { + stroke: white; +} + +.password-field-wrapper { + position: relative; + display: block; +} + +.password-field-wrapper .password-field { + width: 100%; + padding-right: 40px; +} + +.password-field-wrapper .password-toggle { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 28px; + height: 28px; + background: transparent; + border: none; + padding: 0; +} + +.password-field-wrapper .password-toggle:hover { + background: rgba(var(--text-rgb), 0.08); + border: none; +} + +.password-toggle-icon { + font-size: 14px; + line-height: 1; + user-select: none; + color: var(--text-secondary, #a0a0a0); +} + +/* Responsive design */ +@media (max-width: 768px) { + .info-card { + padding: 16px; + margin-bottom: 12px; + } + + .group-header { + padding: 12px 16px; + } + + .group-fields { + padding: 16px; + } + + .field-group { + margin-bottom: 20px; + } + + .group-fields .form-field { + margin-bottom: 16px; + } +} + +.system-value { + color: var(--text-color); + font-weight: 600; +} + +.install-btn { + background: var(--status-success); + border: 1px solid var(--status-success); + border-radius: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.install-btn:hover { + background: var(--status-success-hover); + border-color: var(--status-success-hover); + transform: translateY(-1px); +} + +.action-btn { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + color: var(--text-color); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.action-btn:hover { + background: var(--hover-bg); + transform: translateY(-1px); +} + +.action-btn.primary { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.action-btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.action-btn.secondary { + background: rgba(var(--text-rgb), 0.1); + border-color: rgba(var(--text-rgb), 0.2); +} + +.action-btn.secondary:hover { + background: rgba(var(--text-rgb), 0.2); +} + +.installed-apps { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + /* Remove background - let main container show through */ +} + +.empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 60px 20px; + color: var(--text-color); +} + +.empty-state svg { + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state h3 { + font-size: 20px; + font-weight: 600; + margin-bottom: 12px; +} + +.empty-state p { + font-size: 16px; + opacity: 0.8; +} + +.empty-state a { + color: var(--primary-color); + text-decoration: none; + font-weight: 500; +} + +.empty-state a:hover { + text-decoration: underline; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-indicator.running { + background: var(--status-success); +} + +.status-indicator.stopped { + background: var(--status-danger); +} + +.status-text { + font-size: 12px; + font-weight: 500; +} + +/* Dashboard Mobile Responsive */ +@media (max-width: 768px) { + .dashboard-stats { + grid-template-columns: repeat(2, 1fr); + } + + .dashboard-actions { + flex-direction: column; + } + + .action-btn { + width: 100%; + justify-content: center; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .filter-controls { + width: 100%; + flex-direction: column; + } + + .search-input { + min-width: auto; + width: 100%; + } + + .installed-apps { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .dashboard-stats { + grid-template-columns: 1fr; + } + + .stat-number { + font-size: 24px; + } +} + +/* New disk circle chart styles */ +.disk-chart { + position: relative; + display: inline-block; + vertical-align: middle; +} + +.disk-circle-container { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(var(--text-rgb), 0.1); + position: relative; + overflow: hidden; + border: 2px solid rgba(var(--text-rgb), 0.2); + margin: 0 auto; /* Center horizontally */ +} + +.disk-circle-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--status-success); + transition: height 0.5s ease-in-out; + border-radius: 0 0 50% 50%; /* Rounded bottom only */ +} + +.disk-percentage { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + font-weight: bold; + color: var(--text-primary); + pointer-events: none; /* Prevent mouse interactions */ + z-index: 10; /* Ensure it's on top */ +} + +/* LibrePortal logo styles */ +.libreportal-logo { + display: flex; + align-items: center; + margin-right: 15px; + padding: 4px; + border-radius: 6px; + background: rgba(var(--text-rgb), 0.1); + transition: all 0.3s ease; +} + +.libreportal-logo:hover { + background: rgba(var(--text-rgb), 0.2); + transform: scale(1.05); +} + +.libreportal-logo img { + width: 32px; + height: 32px; +} + +/* Notification container positioning - ensure bottom-right */ +.notification-container { + position: fixed !important; + bottom: 20px !important; + right: 20px !important; + top: auto !important; + left: auto !important; + z-index: 10000 !important; + pointer-events: none; + display: flex; + flex-direction: column-reverse; + gap: 10px; +} + +.notification { + background: var(--card-bg, #2d3748); + border: 1px solid var(--border-color, #4a5568); + border-radius: 8px; + padding: 16px; + width: auto; + min-width: 350px; + max-width: 700px; + pointer-events: all; + transform: translateY(100%); + opacity: 0; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +/* Dynamic width based on content */ +.notification[data-has-app="true"] { + min-width: 450px; + max-width: 650px; +} + +.notification[data-has-action="true"] { + min-width: 500px; + max-width: 700px; +} + +.notification[data-has-app="true"][data-has-action="true"] { + min-width: 550px; + max-width: 750px; +} + +.notification-show { + transform: translateY(0); + opacity: 1; +} + +.notification-hide { + transform: translateY(100%); + opacity: 0; +} + +.notification-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.notification-app-icon { + flex-shrink: 0; + margin-right: 12px; + width: 36px; + height: 36px; + border-radius: 6px; + overflow: hidden; + background: rgba(var(--text-rgb), 0.15); + border: 1px solid rgba(var(--text-rgb), 0.25); + display: flex; + align-items: center; + justify-content: center; +} + +.notification-app-icon img { + width: 28px; + height: 28px; + object-fit: contain; + border-radius: 4px; +} + +.notification-icon { + flex-shrink: 0; + /* Container uses align-items: flex-start so the message stays top-aligned + for multi-line text. Override here so the status icon (20px) sits + vertically centered against the 36px app icon next to it. */ + align-self: center; +} + +.notification-icon svg { + display: block; +} + +.notification-message { + flex: 1; + color: var(--text-color, #e2e8f0); + font-size: 14px; + line-height: 1.4; + margin-top: 2px; +} + +.notification-action-btn { + background: var(--primary-color, var(--accent)); + color: #ffffff; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + margin-left: 12px; + /* Container is `align-items: flex-start` so multi-line message text stays + anchored at the top; override here so the button lines up with the + status / app icons next to it. */ + align-self: center; +} + +.notification-action-btn:hover { + background: var(--primary-hover, #3182ce); + transform: translateY(-1px); +} + +.notification-close { + background: none; + border: none; + color: var(--text-muted, #a0aec0); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; + flex-shrink: 0; + /* Same reasoning as `.notification-action-btn` above — center against the + icons rather than top-aligning with the message. */ + align-self: center; +} + +.notification-close:hover { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-color, #e2e8f0); +} + +.notification-success .notification-icon { + color: #48bb78; +} + +.notification-error .notification-icon { + color: var(--status-danger); +} + +.notification-warning .notification-icon { + color: var(--status-warning); +} + +.notification-info .notification-icon { + color: var(--accent); +} + +.notification-uninstall .notification-icon { + color: var(--status-danger); +} + +/* Mobile positioning */ +@media (max-width: 768px) { + .notification-container { + bottom: 10px !important; + right: 10px !important; + left: 10px !important; + } +} + +/* Tablet positioning */ +@media (max-width: 1024px) and (min-width: 769px) { + .notification-container { + bottom: 15px !important; + right: 15px !important; + top: auto !important; + } +} + +.notification-container .notification { + pointer-events: auto; +} + +/* Hide all content (icons, text, etc.) for install, manage, and uninstall buttons when loading */ +.btn-loading.btn-install, +.btn-loading.btn-manage, +.btn-loading.btn-uninstall, +.btn-loading.manage-btn { + color: transparent !important; +} + +.btn-loading.btn-install *, +.btn-loading.btn-manage *, +.btn-loading.btn-uninstall *, +.btn-loading.manage-btn * { + opacity: 0 !important; + visibility: hidden !important; +} + +/* Show only spinner for install, manage, and uninstall buttons */ +.btn-loading.btn-install::after, +.btn-loading.btn-manage::after, +.btn-loading.btn-uninstall::after, +.btn-loading.manage-btn::after, +.app-card-actions .btn-loading.manage-btn::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; + color: var(--text-primary) !important; + opacity: 1 !important; + visibility: visible !important; +} + +/* Loading text for non-install buttons */ +.btn-loading:not(.btn-install):not(.btn-manage):not(.btn-uninstall):not(.manage-btn)::before { + content: attr(data-loading-text); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: inherit; + font-size: inherit; + white-space: nowrap; +} + +.loading-initial .loading-spinner { + width: 60px; + height: 60px; + border: 4px solid rgba(var(--text-rgb), 0.1); + border-top: 4px solid var(--primary-color, var(--accent)); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 24px; +} + +.loading-initial .loading-subtitle { + font-size: 14px; + color: var(--text-secondary, #ccc); + font-weight: normal; + margin-top: 8px; + opacity: 0.8; +} + +/* Loading spinner styles */ +.loading-categories .loading-spinner, +.loading-apps .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e3e3e3; + border-top: 2px solid var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 10px; +} + +/* Loading containers */ +.loading-categories, +.loading-apps { + text-align: center; + padding: 20px; + color: var(--text-color, #666); +} + +.loading-categories p, +.loading-apps p { + margin: 0; + font-size: 14px; +} + +/* Update needed and warning styles */ +.warning-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin-bottom: 20px; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + color: #856404; + font-size: 14px; +} + +.warning-banner svg { + flex-shrink: 0; +} + +.warning-banner span { + flex: 1; +} + +.warning-banner .btn-small { + flex-shrink: 0; + padding: 4px 12px; + font-size: 12px; + background: var(--status-warning); + color: #212529; + border: 1px solid var(--status-warning); +} + +.warning-banner .btn-small:hover { + background: #e0a800; + border-color: #e0a800; +} + +.btn-copy.copied { + background: var(--status-success); + border-color: var(--status-success); +} + +.toggle-content { + display: flex; + flex-direction: column; + margin-left: 12px; + flex: 1; +} + +.toggle-section input[type="checkbox"] { + display: none; +} + +/* Section Dividers */ +.section-divider { + margin: 32px 0 24px 0; + padding: 16px 0; + border-bottom: 2px solid var(--border-color); +} + +.section-divider h3 { + margin: 0 0 8px 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.section-divider p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + font-style: italic; +} + +/* Advanced and Unused Sections */ +.advanced-sections, +.unused-sections { + margin-bottom: 24px; +} + +.advanced-section { + border-left: 4px solid #f39c12; + padding-left: 16px; + padding-right: 16px; + padding-top: 22px; +} + +.unused-section { + border-left: 4px solid #e74c3c; + padding-left: 16px; + padding-right: 16px; + opacity: 0.8; +} + +.advanced-section h3, +.unused-section h3 { + color: var(--text-primary); +} + +/* Mail Connection Test Button */ +.test-connection-btn { + background: var(--accent); + color: #ffffff; + border: 1px solid var(--accent); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + transition: all 0.2s ease; +} + +.test-connection-btn:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.test-connection-btn:disabled { + background: var(--text-muted); + border-color: var(--text-muted); + cursor: not-allowed; +} + +.test-icon { + font-size: 16px; +} + +.test-text { + font-weight: 500; +} + +/* Test Result Display */ +.test-result { + margin-top: 8px; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + display: block; + width: 100%; + box-sizing: border-box; +} + +.test-result.testing { + background: #fff3cd; + border: 1px solid #ffeaa7; + color: #856404; +} + +.test-result.success { + background: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.test-result.error { + background: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; +} + +/* ============================================== + TABBED INTERFACE STYLES + ============================================== */ + +/* Tabbed Interface Container */ +.tabbed-interface { + margin-top: 20px; +} + +/* Tab Navigation */ +.tab-navigation { + display: flex; + border-bottom: 1px solid rgba(var(--text-rgb), 0.10); + background: rgba(var(--text-rgb), 0.04); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 12px 12px 0px 0px; +} + +.tab-button { + background: transparent; + border: none; + padding: 12px 16px; + cursor: pointer; + border-radius: 8px 8px 0px 0px; + font-size: 14px; + font-weight: 500; + color: rgba(var(--text-rgb), 0.7); + transition: all 0.3s ease; + border-bottom: 2px solid transparent; +} + +.tab-button:hover { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.tab-button.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +/* Flatten the .tab-button default 8px-on-both-top-corners inside any + tab container — we want only the FIRST tab's top-left and LAST + tab's top-right to follow the strip's curve. Without the reset, + the inner corner of the active tab still picks up the default + 8px and looks rounded against an unrounded sibling. */ +.tab-navigation > .tab-button, +.tabs-wrapper .tabs-list .tab-button { + border-radius: 0; +} + +/* When the first / last tab is the active one, its filled background + should follow the parent strip's 12px top-corner radius. Without + this, the active tab squares off in the corner of the strip, + leaving an L-shaped notch against the rounded chrome. */ +.tab-navigation > .tab-button:first-child, +.tab-navigation > .main-tab-button:first-child, +.tabs-wrapper .tabs-list .tab-button:first-child { + border-top-left-radius: 12px; +} + +.tab-navigation > .tab-button:last-child, +.tab-navigation > .main-tab-button:last-child, +.tabs-wrapper .tabs-list .tab-button:last-child { + border-top-right-radius: 12px; +} + +.main-tab-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px 20px; + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary, #ccc); + transition: all 0.2s ease; + white-space: nowrap; + border-bottom: 2px solid transparent; + flex: 1; +} + +.main-tab-button:hover { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.main-tab-button.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.tab-button svg { + width: 16px; + height: 16px; +} + +/* Tab Content */ +.tab-content { + min-height: 400px; +} + +.tab-pane { + display: none; + padding: 20px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-top: none; + border-radius: 0px 0px 12px 12px; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); +} + +.tab-pane.active { + display: block; +} + +.backups-section h3 { + color: var(--accent); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; +} + +.backups-section p { + color: var(--text-muted); + font-style: italic; +} + +/* Padding stays at 0 so .tasks-title's own 20px provides the inset — + same recipe as .services-section / .config-section. */ +.tasks-section { + display: flex; + flex-direction: column; + padding: 0; +} + +.tasks-container { + /* Make app tasks look like main tasks page */ + flex: 1; + overflow-y: auto; + padding: 16px; + margin: 16px; + background: rgba(var(--bg-rgb), 0.2); + border-radius: 8px; +} + +/* Hide scrollbar when not needed, show only when scrolling */ +.tasks-container::-webkit-scrollbar { + width: 8px; +} + +.tasks-container::-webkit-scrollbar-track { + background: transparent; +} + +.tasks-container::-webkit-scrollbar-thumb { + background: var(--input-bg); + border-radius: 4px; +} + +.tasks-container::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* ============================================== + TASK ITEM AND DETAILS STYLING + ============================================== */ + +.task-item { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; + transition: all 0.2s ease; + padding: 0px; +} + +.task-item:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--text-rgb), 0.15); + transform: translateY(-1px); +} + +.task-details { + display: none; + background: rgba(var(--bg-rgb), 0.2); + border-top: 1px solid rgba(var(--text-rgb), 0.1); + padding: 16px; +} + +.task-details.task-details-open { + display: block; +} + +.task-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.1); +} + +.meta-item { + font-size: 12px; + color: var(--text-muted); +} + +.meta-item strong { + color: var(--text-primary); + font-weight: 500; +} + +.task-id-link, +.task-app-link { + color: inherit; + text-decoration: none; +} + +.task-id-link:hover, +.task-app-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.task-logs h4, +.task-output h4, +.task-error h4, +.task-running h4 { + color: var(--text-primary); + font-size: 14px; + margin: 16px 0 8px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.task-output .output-content, +.task-error .error-content { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + border-radius: 10px; + padding: 12px; + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + white-space: pre-wrap; + overflow-x: auto; +} + +.task-error .error-content { + color: var(--status-danger); + border-color: rgba(var(--status-danger-rgb), 0.3); +} + +.task-running .running-indicator { + display: flex; + align-items: center; + gap: 12px; + color: var(--status-info); + font-style: italic; +} + +.task-running .spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(23, 162, 184, 0.3); + border-top: 2px solid var(--status-info); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* ============================================== + TASK HEADER ENHANCEMENTS + ============================================== */ + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px; + cursor: pointer; +} + +.task-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.task-app-icon { + width: 32px; + height: 32px; + border-radius: 8px; + object-fit: cover; + border: 1px solid #f0f0f03d; + background: #f8f9fa24; + padding: 3px; +} + +.task-type-icon { + font-size: 16px; + margin-left: 6px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.task-title { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; + color: #c5c8ca; + font-size: 15px; + line-height: 1.2; +} + +.task-status { + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + text-transform: uppercase !important; +} + +.status-queued { + background: rgba(255, 189, 46, 0.2); + color: #ffbd2e; + border: 1px solid rgba(255, 189, 46, 0.3); + text-transform: uppercase !important; +} + +.status-running { + background: rgba(40, 202, 66, 0.2); + color: #28ca42; + border: 1px solid rgba(40, 202, 66, 0.3); + text-transform: uppercase !important; +} + +.status-completed { + background: rgba(0, 255, 0, 0.2); + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.3); + text-transform: uppercase !important; +} + +.status-failed { + background: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + text-transform: uppercase !important; +} + +.status-cancelled { + background: rgba(var(--text-rgb), 0.2); + color: var(--text-muted); + border: 1px solid rgba(var(--text-rgb), 0.3); + text-transform: uppercase !important; +} + +.task-time { + font-size: 11px; + color: var(--text-muted); + margin-left: auto; + margin-right: 8px; +} + +.task-duration { + font-size: 11px; + color: var(--text-muted); + margin-right: 8px; +} + +.task-title, +.task-time, +.task-duration { + color: var(--text-muted); +} + +.task-app-icon { + border-color: var(--border); + background: var(--surface-elevated); +} + +/* Mirrors .config-title — see config.css. */ +.tasks-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.tasks-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.tasks-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +/* ============================================== + TASK ACTIONS STYLING + ============================================== */ + +.task-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.task-btn { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + color: var(--text-muted); + padding: 6px 10px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 4px; +} + +.task-btn:hover { + background: rgba(var(--text-rgb), 0.2); + border-color: rgba(var(--text-rgb), 0.3); + transform: translateY(-1px); +} + +.task-btn.retry { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); +} + +.task-btn.retry:hover { + background: rgba(var(--status-danger-rgb), 0.2); + border-color: rgba(var(--status-danger-rgb), 0.3); +} + +.task-btn.view-logs { + background: rgba(var(--status-info-rgb), 0.12); + border-color: rgba(var(--status-info-rgb), 0.30); + color: var(--text-primary); +} + +.task-btn.view-logs:hover { + background: rgba(var(--status-info-rgb), 0.22); + border-color: rgba(var(--status-info-rgb), 0.50); +} + +.task-btn.toggle-details { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.18); + color: var(--text-secondary); +} + +.task-btn.toggle-details:hover { + background: rgba(var(--text-rgb), 0.12); + border-color: rgba(var(--text-rgb), 0.32); + color: var(--text-primary); +} + +.task-btn.toggle-details.expanded { + background: rgba(var(--text-rgb), 0.14); + border-color: rgba(var(--text-rgb), 0.36); + color: var(--text-primary); +} + +.task-btn.delete { + background: rgba(var(--status-danger-rgb), 0.14); + border-color: rgba(var(--status-danger-rgb), 0.40); + color: var(--text-primary); +} + +.task-btn.delete:hover { + background: rgba(var(--status-danger-rgb), 0.28); + border-color: rgba(var(--status-danger-rgb), 0.65); + color: var(--text-primary); +} + +/* Text label sitting next to the SVG inside any .task-btn (Restart, + Logs, Delete, etc.). Buttons that only carry an icon don't include + the span, so they stay icon-only. */ +.task-btn .task-btn-label { + margin-left: 4px; + font-size: 11px; + font-weight: 500; + line-height: 1; +} + +.task-btn:has(.task-btn-label) { + padding-right: 12px; +} + +/* ============================================== + TERMINAL STYLING FOR TASK LOGS + ============================================== */ + +.terminal-style { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + color: var(--text-primary); + font-family: 'Courier New', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 15px; + line-height: 1.3; + border-radius: 10px; + padding: 16px; + overflow: auto; + white-space: pre; + word-wrap: normal; + overflow-wrap: normal; + /* Browsers only honour `resize` when overflow != visible. Vertical + only — drag the bottom handle down to extend the log viewport. */ + resize: vertical; + min-height: 120px; + max-height: none; +} + +.terminal-style .log-line { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; + margin-bottom: 4px !important; +} + +.terminal-style .log-entry { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; + margin-bottom: 4px !important; +} + +.terminal-style div { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; +} + +/* Force remove any default spacing but allow gap */ +.terminal-style * { + margin: 0; + padding: 0; + line-height: 1.3; +} + +.terminal-style .log-line, +.terminal-style .log-entry { + margin-bottom: 4px !important; +} + +/* ANSI color styles for terminal */ +.terminal-style span[style*="color: green"] { + color: #00ff00 !important; +} + +.terminal-style span[style*="color: red"] { + color: var(--status-danger) !important; +} + +.terminal-style span[style*="color: yellow"] { + color: #ffd93d !important; +} + +.terminal-style span[style*="color: blue"] { + color: #6bb6ff !important; +} + +.terminal-style span[style*="color: cyan"] { + color: #4ecdc4 !important; +} + +.terminal-style span[style*="color: magenta"] { + color: #ff6ec7 !important; +} + +.terminal-style span[style*="color: white"] { + color: var(--text-primary) !important; +} + +.terminal-style span[style*="color: black"] { + color: #000000 !important; +} + +.terminal-style span[style*="background-color: black"] { + background-color: #000000 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: red"] { + background-color: #ff0000 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: green"] { + background-color: #00ff00 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: yellow"] { + background-color: #ffff00 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: blue"] { + background-color: #0000ff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: cyan"] { + background-color: #00ffff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: magenta"] { + background-color: #ff00ff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: white"] { + background-color: var(--text-primary) !important; + color: #000000 !important; + padding: 0 2px; +} + +/* Modal log viewer enhancements */ +.task-logs-modal .log-viewer.terminal-style { + max-height: 400px; + overflow-y: auto; + overflow-x: auto; + border: 1px solid var(--border-strong); + white-space: pre; + word-wrap: normal; + overflow-wrap: normal; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar { + width: 8px; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-track { + background: var(--surface-elevated); +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 4px; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-thumb:hover { + background: #777; +} + +/* Task preview log container styling — initial height is set inline + (200px) so the user lands on a familiar size, but `resize: vertical` + from .terminal-style lets them drag the bottom handle down to grow + it. We deliberately don't set a max-height here. */ +.task-logs .log-container.terminal-style { + overflow-y: auto; + border: 1px solid var(--border); + margin: 8px 0; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar { + width: 6px; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-track { + background: var(--surface-elevated); +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 3px; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-thumb:hover { + background: #777; +} + +.task-logs .log-entry { + margin-bottom: 0; + line-height: 1.2; +} + +.task-output .output-content.terminal-style { + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--border); + margin: 8px 0; +} + +/* Update Indicator Animations */ +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes requiredFlash { + 0%, 100% { box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.20); } + 50% { box-shadow: 0 0 0 6px rgba(var(--status-danger-rgb), 0.35); } +} + +/* Welcome chip styles live in css/service-buttons.css. */ + +/* ===== Mobile (≤768px) — safety net + app detail page ===== */ +@media (max-width: 768px) { + /* Keep the page from ever scrolling horizontally on mobile — + long URLs, wide tables, or rogue inline widths still scroll + inside their own containers if they need to. */ + html, body { + overflow-x: hidden; + max-width: 100%; + } + + /* Stack the app header so service buttons fall below app info. */ + .app-header { + flex-direction: column; + align-items: stretch; + padding: 16px; + gap: 16px; + } + + .app-header .app-info { + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 0; + background: transparent; + border: none; + } + + .app-header .app-info .app-card-icon { + align-self: center; + } + + .app-header .app-details { + width: 100%; + } + + .app-header .app-details h2 { + font-size: 20px; + } + + .app-header .app-description { + font-size: 14px; + } + + .app-header .app-meta { + justify-content: center; + flex-wrap: wrap; + gap: 8px; + } + + /* Service buttons stack vertically full-width below app info. */ + .service-buttons-container { + flex-direction: column; + width: 100%; + margin-top: 0; + align-items: stretch; + } + + .service-buttons-container .service-button { + width: 100%; + justify-content: center; + } + + .service-buttons-container .service-trigger, + .service-buttons-container .service-trigger-icon { + width: 100%; + } +} diff --git a/containers/libreportal/frontend/css/tasks.css b/containers/libreportal/frontend/css/tasks.css new file mode 100644 index 0000000..5a9b155 --- /dev/null +++ b/containers/libreportal/frontend/css/tasks.css @@ -0,0 +1,885 @@ +/* Tasks page styling. Extracted from tasks-content.html so theme + overrides and edits live alongside the rest of the CSS. All + colors reference theme variables — see themes//theme.css. */ + +/* Tasks Layout - Match Apps/Config Style */ +.tasks-layout { + display: flex; + height: calc(100vh - 60px); + background: transparent; +} + +/* Sidebar Styles - Match existing LibrePortal style */ +.sidebar-container { + width: 220px; + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.sidebar { + width: 220px; + height: 100%; + overflow-y: auto; +} + +.sidebar h2 { + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.sidebar-category { + margin-bottom: 24px; + padding: 0 20px; +} + +.sidebar-category h3 { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + color: var(--text-secondary); + text-decoration: none; + border-radius: 6px; + transition: all 0.2s; + font-size: 14px; +} + +.sidebar-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sidebar-item.active { + background: var(--accent-color); + color: white; +} + +.task-count { + margin-left: auto; + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +.sidebar-item.active .task-count { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +/* Main Content Area */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + background: transparent; + overflow: hidden; +} + +/* Status Bar — glassy strip matching the loading-screen system-card recipe. */ +.terminal-status-bar { + background: rgba(var(--text-rgb), 0.04); + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); + padding: 12px 20px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.status-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 11px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-queued { background: var(--status-warning); text-transform: uppercase !important; } +.status-running { background: var(--status-success); animation: pulse 1.5s infinite; text-transform: uppercase !important; } +.status-completed { background: var(--status-success); text-transform: uppercase !important; } +.status-failed { background: var(--status-danger); text-transform: uppercase !important; } + +/* Force uppercase on all task status elements */ +.task-status.status-queued, +.task-status.status-running, +.task-status.status-completed, +.task-status.status-failed, +.task-status.status-cancelled { + text-transform: uppercase !important; +} + +.status-installed { + background: var(--status-success); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.refresh-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + margin-left: auto; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.refresh-btn:hover { + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.45); + color: var(--accent); +} + +.clear-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + margin-left: 8px; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.clear-btn:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.50); + color: var(--status-danger); +} + +/* Tasks Terminal */ +.tasks-terminal { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tasks-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* Hide scrollbar when not needed, show only when scrolling */ +.tasks-list::-webkit-scrollbar { + width: 8px; +} + +.tasks-list::-webkit-scrollbar-track { + background: transparent; +} + +.tasks-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.tasks-list::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +/* Hide scrollbar by default, show only on hover or when content overflows */ +.task-highlighted { + border: 2px solid var(--accent); + background: var(--accent-soft); +} + +.task-details-open { + display: block !important; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* Task Items — glass tiles in the loading-screen system-card style: + light translucent fill, soft white border, inset top highlight, hover + lifts the card with a cyan glow. */ +.task-item { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 12px; + margin-bottom: 10px; + overflow: hidden; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); + transition: background 0.2s ease, border-color 0.2s ease, + transform 0.2s ease, box-shadow 0.2s ease; +} + +.task-item:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + transform: translateY(-2px); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.10); +} + +.task-header { + padding: 4px 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +} + +.task-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.task-title { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.3; +} + +.task-status { + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +/* Task-status PILL — glass tinted tag, matches the button language. + Running/completed use a bright mint (#86efac) instead of the theme + --status-success (#28a745) which reads as muddy olive against the + nebula gradient. Same treatment used on the setup wizard's valid + border + the apps "Installed" pill. */ +.task-status.status-queued { + background: rgba(var(--status-warning-rgb), 0.22); + border: 1px solid rgba(var(--status-warning-rgb), 0.60); + color: #fcd34d; +} +.task-status.status-running { + background: rgba(var(--status-success-rgb), 0.35); + border: 1px solid rgba(var(--status-success-rgb), 0.70); + color: #86efac; +} +.task-status.status-completed { + background: rgba(var(--status-success-rgb), 0.35); + border: 1px solid rgba(var(--status-success-rgb), 0.70); + color: #86efac; +} + +/* Services persist when they're running; the pulse only makes sense + for transient task state, so disable it on service rows. The .status-running + class (line ~134) sets animation: pulse on anything that wears it — this + overrides for service pills. Task pills still pulse. */ +.service-item .task-status.status-running { + animation: none; +} +.task-status.status-failed { + background: rgba(var(--status-danger-rgb), 0.22); + border: 1px solid rgba(var(--status-danger-rgb), 0.60); + color: #fca5a5; +} + +.task-command { + color: var(--status-success); + font-family: 'Courier New', monospace; + font-size: 11px; + flex: 1; +} + +.task-time { + color: var(--text-muted); + font-size: 10px; + margin-right: 8px; +} + +.task-actions { + display: flex; + gap: 6px; +} + +.task-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 3px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 10px; + display: flex; + align-items: center; + gap: 3px; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.task-btn:hover { + background: var(--surface-hover); + color: var(--status-success); + border-color: var(--status-success); +} + +.task-btn.retry:hover { + background: var(--status-warning); + color: #000; + border-color: var(--status-warning); +} + +.task-btn.delete:hover { + background: var(--status-danger); + color: var(--text-on-accent); + border-color: var(--status-danger); +} + +.task-details { + border-top: 1px solid rgba(var(--text-rgb), 0.10); + background: transparent; + padding: 14px 16px 4px; + display: none; +} + +.task-details.show { + display: block; +} + +.task-output { + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-muted); + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + max-height: 200px; + overflow-y: auto; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .sidebar-container { + position: fixed; + left: -220px; + top: 0; + height: 100vh; + z-index: 1000; + transition: left 0.3s ease; + } + + .sidebar-container.mobile-open { + left: 0; + } + + .main-content { + margin-left: 0; + } + + .mobile-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: none; + } + + .mobile-overlay.active { + display: block; + } +} + +/* Loading Categories */ +.loading-categories { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px; + color: var(--text-secondary); + font-size: 12px; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.3); + border-top: 2px solid var(--text-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Task metadata strip. style.css turns .task-meta into a grid of + auto-fit columns, so the items wrap horizontally instead of stacking. + Uses a dark-tint overlay (not a light-tint) so the white labels and + values pop against the strip on nebula's gradient — the previous + rgba(text, 0.10) lifted the strip towards white and washed out the + text it was supposed to highlight. */ +.task-meta { + background: rgba(var(--bg-rgb), 0.30); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 14px; + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +/* Bump label/value contrast inside the metadata strip — the global + .meta-item uses --text-muted (65% alpha on nebula) which reads as + dim grey. --text-secondary (82%) keeps the hierarchy vs the white + labels but is actually readable. */ +.task-meta .meta-item { + color: var(--text-secondary); +} + +/* Soften the log/output terminal box. The .log-container default is + var(--surface-sunken) — on nebula that's rgba(0,0,0,0.22) which on + top of the cosmic dark stack reads as pitch black and feels foreign + to the rest of the glass UI. Anchor it to nebula's navy chrome with + moderate opacity so the gradient still bleeds through faintly. */ +.task-logs .log-container.terminal-style, +.task-output .output-content.terminal-style { + background: rgba(15, 25, 50, 0.45); + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +.meta-item { + display: flex; + align-items: baseline; + justify-content: center; + gap: 6px; + padding: 2px 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta-item > strong { + flex-shrink: 0; +} + +.meta-item > a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.meta-item code { + background: var(--code-bg); + color: var(--code-text); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; +} + +.task-duration { + background: var(--accent-soft); + color: var(--accent); + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.task-logs { + margin-bottom: 16px; +} + +.task-logs h4 { + margin: 0 0 12px 0; + color: var(--accent); + font-size: 14px; + font-weight: 600; +} + +.log-container { + background: var(--surface-sunken); + border-radius: 8px; + padding: 12px; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-subtle); +} + +.log-entry { + display: flex; + align-items: flex-start; + padding: 4px 0; + border-bottom: 1px solid var(--border-subtle); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-timestamp { + color: var(--text-muted); + margin-right: 12px; + white-space: nowrap; + font-size: 11px; +} + +.task-output h4, +.task-error h4 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; +} + +.task-output h4 { + color: var(--status-success); +} + +.output-content, +.error-content { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + border-radius: 10px; + padding: 12px; + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + line-height: 1.4; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.error-content { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +/* Original "task is running…" placeholder panel styling. The + .task-running class is also used as a JS state marker on buttons + and tab buttons (see app-tabbed-manager.js / apps-manager.js); the + :not(...) chain keeps those out so they don't suddenly grow 20px + of padding (and visibly jump taller) when a task starts. */ +.task-running:not(button):not(.tab-button):not(.btn):not(.task-btn) { + text-align: center; + padding: 20px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(var(--status-warning-rgb), 0.3); + border-top: 2px solid var(--status-warning); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.info-content { + text-align: center; + color: var(--text-muted); + padding: 20px; + font-style: italic; +} + +/* Modal Styles */ +.task-logs-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +/* These rules are scoped to .task-logs-modal so they don't override the + generic modal styling in modal.css used by every other modal. */ +.task-logs-modal .modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.task-logs-modal .modal-content { + position: relative; + background: var(--bg-secondary); + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow: hidden; + box-shadow: var(--card-shadow-hover); + border: 1px solid var(--border-subtle); +} + +.task-logs-modal .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-subtle); + background: rgba(var(--text-rgb), 0.05); +} + +.task-logs-modal .modal-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; +} + +.task-logs-modal .modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; +} + +.task-logs-modal .modal-close:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.task-logs-modal .modal-body { + padding: 24px; + max-height: calc(80vh - 80px); + overflow-y: auto; +} + +.task-info-summary { + background: rgba(var(--text-rgb), 0.05); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; + border: 1px solid var(--border-subtle); +} + +.info-row { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row code { + background: var(--code-bg); + color: var(--code-text); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + margin-left: 8px; +} + +.logs-section, +.output-section, +.error-section { + margin-bottom: 20px; +} + +.logs-section h4, +.output-section h4, +.error-section h4 { + margin: 0 0 12px 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.log-viewer, +.output-viewer, +.error-viewer { + background: var(--surface-sunken); + border-radius: 8px; + padding: 16px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + line-height: 1.4; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border-subtle); + max-height: 300px; + overflow-y: auto; +} + +.log-viewer { + max-height: 400px; +} + +.log-line { + display: flex; + align-items: flex-start; + padding: 4px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.log-line:last-child { + border-bottom: none; +} + +.log-line .timestamp { + color: var(--text-muted); + margin-right: 12px; + white-space: nowrap; + font-size: 11px; + min-width: 140px; +} + +.log-line .message { + color: var(--text-primary); + flex: 1; + word-break: break-word; +} + +.error-viewer { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +/* Button enhancements */ +.task-btn.view-logs { + background: var(--accent-soft); + color: var(--accent); +} + +.task-btn.view-logs:hover { + background: rgba(var(--accent-rgb), 0.3); +} + +/* Responsive */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + max-height: 90vh; + } + + .modal-header, + .modal-body { + padding: 16px; + } + + .log-line { + flex-direction: column; + gap: 4px; + } + + .log-line .timestamp { + min-width: auto; + } + + /* Task + service rows: stack the row so info, status, and actions + no longer fight for horizontal space. */ + .task-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 12px; + } + + .task-info { + flex-wrap: wrap; + gap: 8px; + } + + .task-title { + flex: 1 1 100%; + word-break: break-word; + } + + .task-actions { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + } + + /* Status bar: compress padding, allow refresh/clear to wrap below. */ + .terminal-status-bar { + padding: 10px 12px; + gap: 8px; + } + + .refresh-btn, + .clear-btn { + margin-left: 0; + } + + /* Task metadata strip: stack key/value pairs vertically. */ + .task-meta { + padding: 10px 12px; + } + + /* Services row container: trim outer margins so cards reach edge. */ + .services-rows { + margin: 10px; + padding: 10px; + } +} diff --git a/containers/libreportal/frontend/css/themes.css b/containers/libreportal/frontend/css/themes.css new file mode 100644 index 0000000..f2e16ea --- /dev/null +++ b/containers/libreportal/frontend/css/themes.css @@ -0,0 +1,331 @@ +/* ============================================================ + Cross-component rules. + + Per-theme palettes live in frontend/themes//theme.css and + are loaded dynamically by js/system/theme-registry.js (plus the + inline bootstrap in index.html for the current theme on first + paint). Anything in this file should consume tokens via + var(--token) so it adapts to whichever theme is active. + ============================================================ */ + +/* Danger zone banner: red-tinted glass card with a solid red left edge. + Same recipe as the Traefik "not installed" notice but tinted with + var(--status-danger). Reads cleanly on every theme thanks to alpha + overlays driven by --status-danger-rgb. */ +.danger-zone-section { + margin-top: 14px; + margin-bottom: 14px; + padding: 20px 24px; + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.30); + border-left: 4px solid var(--status-danger); + border-radius: 12px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.danger-zone-header { + margin-bottom: 20px; + text-align: center; +} + +.danger-zone-header h3 { + margin: 0 0 8px 0; + color: var(--status-danger); + font-size: 20px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.danger-zone-header p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + font-style: italic; +} + +/* Header-only variant used as a page-level banner (e.g. config?=features). + Without inner content below the header, the header's bottom margin + is wasted whitespace, and the section needs its own bottom margin + to separate from whatever follows. */ +.danger-zone-section--header-only { + margin-bottom: 24px; +} + +.danger-zone-section--header-only .danger-zone-header { + margin-bottom: 0; +} + +.danger-zone-section--header-only .danger-zone-header p { + margin-top: 4px; +} + +/* Inline variant — same recipe as .danger-zone-section, smaller padding. */ +.danger-zone-banner { + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.30); + border-left: 4px solid var(--status-danger); + border-radius: 12px; + padding: 14px 18px; + margin-bottom: 18px; + display: flex; + align-items: flex-start; + gap: 12px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.danger-zone-content { + display: flex; + align-items: center; + gap: 12px; +} + +.danger-zone-icon { + color: var(--status-danger); + flex-shrink: 0; + font-size: 20px; +} + +.danger-zone-text { + color: var(--text-primary); + font-size: 14px; + line-height: 1.4; +} + +.danger-zone-text strong { + color: var(--status-danger); + align-items: flex-start; +} + +/* Solid status / accent buttons — the default look used by dark-blue + and light. Nebula overrides these below to get the welcome-button + gradient + glow recipe. */ +.install-btn, +.btn-install, +.app-card .install-btn { + background: var(--status-success) !important; + color: #ffffff !important; + border: 1px solid var(--status-success) !important; +} + +.install-btn:hover:not(:disabled), +.btn-install:hover:not(:disabled), +.app-card .install-btn:hover:not(:disabled) { + background: var(--status-success-hover) !important; + border-color: var(--status-success-hover) !important; +} + +.uninstall-btn, +.btn-uninstall { + background: var(--status-danger) !important; + color: #ffffff !important; + border: 1px solid var(--status-danger) !important; +} + +.uninstall-btn:hover:not(:disabled), +.btn-uninstall:hover:not(:disabled) { + background: var(--status-danger-hover) !important; + border-color: var(--status-danger-hover) !important; +} + +.manage-btn, +.btn-manage, +.btn-primary, +.app-card .manage-btn, +.app-card-actions .manage-btn { + background: var(--accent) !important; + color: var(--text-on-accent) !important; + border: 1px solid var(--accent) !important; +} + +.manage-btn:hover:not(:disabled), +.btn-manage:hover:not(:disabled), +.btn-primary:hover:not(:disabled), +.app-card .manage-btn:hover:not(:disabled), +.app-card-actions .manage-btn:hover:not(:disabled) { + background: var(--accent-hover) !important; + border-color: var(--accent-hover) !important; +} + +/* "Back to Apps" — same solid pill as Update Configuration (btn-manage), + but in amber so it reads as a distinct secondary action. */ +.config-actions .btn-secondary, +.console-actions .btn-secondary { + background: var(--status-warning) !important; + color: #1a1200 !important; + border: 1px solid var(--status-warning) !important; +} + +.config-actions .btn-secondary:hover:not(:disabled), +.console-actions .btn-secondary:hover:not(:disabled) { + background: #e0a800 !important; + border-color: #e0a800 !important; +} + +/* ------------------------------------------------------------------ + Nebula-only: outline + tint buttons — copied from the .service-button + "Welcome" recipe (rgba(, 0.10) bg + rgba(, 0.30) + border + neutral text). Topbar pills use that exact alpha for the + light feel; in-content CTAs (Install/Manage/Uninstall) bump the + alphas a bit and add a subtle coloured outer glow so they feel + weightier without becoming solid. + ------------------------------------------------------------------ */ + +/* --- Topbar pills (light, transparent) --------------------------- */ +[data-theme="nebula"] .topbar .donate-btn { + background: rgba(var(--accent-rgb), 0.10) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--accent-rgb), 0.30) !important; + box-shadow: none !important; + text-shadow: none; + font-weight: 600; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.2s ease !important; +} + +[data-theme="nebula"] .topbar .donate-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.20) !important; + border-color: rgba(var(--accent-rgb), 0.55) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .topbar .logout-btn { + background: rgba(var(--status-danger-rgb), 0.10) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.30) !important; + box-shadow: none !important; + text-shadow: none; + font-weight: 600; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.2s ease !important; +} + +[data-theme="nebula"] .topbar .logout-btn:hover:not(:disabled) { + background: rgba(var(--status-danger-rgb), 0.20) !important; + border-color: rgba(var(--status-danger-rgb), 0.55) !important; + transform: translateY(-1px); +} + +/* --- Topbar active nav (App Center / etc.) — translucent pill ---- + Replaces the solid var(--primary-color) fill so the active page + indicator matches the Donate / Logout pill style. */ +[data-theme="nebula"] .topbar-nav .nav-item.nav-active, +[data-theme="nebula"] .topbar-nav .nav-item.active { + background: rgba(var(--accent-rgb), 0.18) !important; + color: var(--text-primary) !important; + border-color: rgba(var(--accent-rgb), 0.50) !important; +} + +[data-theme="nebula"] .topbar-nav .nav-item.nav-active:hover, +[data-theme="nebula"] .topbar-nav .nav-item.active:hover { + background: rgba(var(--accent-rgb), 0.28) !important; + color: var(--text-primary) !important; + border-color: rgba(var(--accent-rgb), 0.70) !important; +} + +/* --- In-content CTAs (more solid than the topbar but still + transparent — alpha set above the body's accent-glow so the + brand colour reads clearly against Nebula's gradient.) ------- */ +[data-theme="nebula"] .install-btn, +[data-theme="nebula"] .btn-install, +[data-theme="nebula"] .app-card .install-btn { + background: rgba(var(--status-success-rgb), 0.55) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-success-rgb), 0.90) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .install-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-install:hover:not(:disabled), +[data-theme="nebula"] .app-card .install-btn:hover:not(:disabled) { + background: rgba(var(--status-success-rgb), 0.70) !important; + border-color: rgba(var(--status-success-rgb), 1.00) !important; + transform: translateY(-1px); +} + +/* "Open" button on installed app cards — same success recipe. */ +[data-theme="nebula"] .service-trigger-icon { + background: rgba(var(--status-success-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-success-rgb), 0.65) !important; +} + +[data-theme="nebula"] .service-trigger:hover .service-trigger-icon, +[data-theme="nebula"] .service-trigger.open .service-trigger-icon { + background: rgba(var(--status-success-rgb), 0.50) !important; + border-color: rgba(var(--status-success-rgb), 0.85) !important; +} + +[data-theme="nebula"] .uninstall-btn, +[data-theme="nebula"] .btn-uninstall, +[data-theme="nebula"] .btn-danger { + background: rgba(var(--status-danger-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.65) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .uninstall-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-uninstall:hover:not(:disabled), +[data-theme="nebula"] .btn-danger:hover:not(:disabled) { + background: rgba(var(--status-danger-rgb), 0.50) !important; + border-color: rgba(var(--status-danger-rgb), 0.85) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .manage-btn, +[data-theme="nebula"] .btn-manage, +[data-theme="nebula"] .btn-primary, +[data-theme="nebula"] .app-card .manage-btn, +[data-theme="nebula"] .app-card-actions .manage-btn { + background: rgba(var(--accent-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--accent-rgb), 0.65) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .manage-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-manage:hover:not(:disabled), +[data-theme="nebula"] .btn-primary:hover:not(:disabled), +[data-theme="nebula"] .app-card .manage-btn:hover:not(:disabled), +[data-theme="nebula"] .app-card-actions .manage-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.50) !important; + border-color: rgba(var(--accent-rgb), 0.85) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .install-btn:disabled, +[data-theme="nebula"] .btn-install:disabled, +[data-theme="nebula"] .uninstall-btn:disabled, +[data-theme="nebula"] .btn-uninstall:disabled, +[data-theme="nebula"] .manage-btn:disabled, +[data-theme="nebula"] .btn-manage:disabled, +[data-theme="nebula"] .btn-primary:disabled, +[data-theme="nebula"] .btn-danger:disabled, +[data-theme="nebula"] .topbar .donate-btn:disabled, +[data-theme="nebula"] .topbar .logout-btn:disabled { + opacity: 0.50; + cursor: not-allowed; +} + +/* Warning banner — amber tint that reads in every theme. */ +.warning-banner { + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.35); + color: var(--text-primary); +} + +/* Nebula: the cyan accent is bright enough that a dark glyph (the + theme's text-on-accent = #0a1426) still reads, but white reads + better with the rest of nebula's white-text-on-glass system. */ +[data-theme="nebula"] .tooltip::after { + color: #ffffff; +} diff --git a/containers/libreportal/frontend/css/tools.css b/containers/libreportal/frontend/css/tools.css new file mode 100644 index 0000000..c87df61 --- /dev/null +++ b/containers/libreportal/frontend/css/tools.css @@ -0,0 +1,632 @@ +/* + Tools tab — mirrors the Services tab visual structure (.task-item, + .task-header, .task-info, .task-actions) plus a generic input modal + for tools that need user inputs. +*/ + +.tools-section { padding: 0; } + +.tools-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.tools-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.tools-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.tools-list { display: flex; flex-direction: column; } + +.tools-rows { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem 1.25rem 2rem; +} + +.tools-cat-pane { display: none; } +.tools-cat-pane.active { display: flex; } + +.tools-tab-bar { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.75rem 1.25rem 0; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + margin-bottom: 0.25rem; +} + +.tools-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + color: var(--text-secondary, #a0a0a0); + padding: 0.45rem 0.85rem; + font-size: 13px; + font-weight: 500; + border-radius: 6px 6px 0 0; + cursor: pointer; + transition: color 120ms ease, background 120ms ease, border-color 120ms ease; + position: relative; + bottom: -1px; +} + +.tools-tab:hover { + color: var(--text-primary, #fff); + background: rgba(255, 255, 255, 0.04); +} + +.tools-tab.active { + color: var(--text-primary, #fff); + background: var(--surface-color, rgba(255, 255, 255, 0.06)); + border-color: var(--border-color, rgba(255, 255, 255, 0.08)); + border-bottom-color: var(--surface-color, rgba(255, 255, 255, 0.06)); +} + +.tools-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4em; + padding: 0 0.4em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #ccc); + font-size: 11px; + font-weight: 600; + line-height: 1.5; +} + +.tools-tab.active .tools-tab-count { + background: var(--accent-color, #6c63ff); + color: #fff; +} + +.tools-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 2rem; + color: var(--text-secondary, var(--text-muted)); +} + +.tools-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.15); + border-top-color: var(--accent-color, var(--accent)); + border-radius: 50%; + animation: tools-spin 0.7s linear infinite; +} + +@keyframes tools-spin { to { transform: rotate(360deg); } } + +.tools-empty { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, var(--text-muted)); +} + +.tools-empty-icon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; +} + +/* Tool row -------------------------------------------------------- */ + +/* Mirror .task-item shell from style.css so tool rows visually match + task rows, but use a horizontal flex layout so the action button + stays vertically centered across the whole row regardless of + description length. */ +.tool-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; +} + +.tool-text { flex: 1 1 auto; min-width: 0; } + +.tool-head { + display: flex; + align-items: center; + gap: 8px; +} + +.tool-icon { font-size: 18px; line-height: 1; } + +.tool-title { + color: var(--text-primary, #fff); + font-size: 14px; + font-weight: 600; +} + +.tool-desc { + margin: 4px 0 0 0; + color: var(--text-secondary, var(--text-muted)); + font-size: 12px; + line-height: 1.4; +} + +.tool-action { flex: 0 0 auto; display: flex; align-items: center; } + +/* Matches the .task-btn.delete look (translucent fill + colored border) + but bigger and green. The delete button uses bootstrap red var(--status-danger); + we use bootstrap green var(--status-success) for parity. */ +.tool-run-btn { + background: rgba(var(--status-success-rgb), 0.12); + border: 1px solid rgba(var(--status-success-rgb), 0.3); + color: var(--status-success); + padding: 10px 22px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.2px; + min-width: 96px; + cursor: pointer; + transition: all 0.2s ease; +} + +.tool-run-btn:hover { + background: rgba(var(--status-success-rgb), 0.22); + border-color: rgba(var(--status-success-rgb), 0.45); + transform: translateY(-1px); +} + +.tool-run-btn:active { transform: translateY(0); } + +.tool-run-btn.destructive { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +.tool-run-btn.destructive:hover { + background: rgba(var(--status-danger-rgb), 0.22); + border-color: rgba(var(--status-danger-rgb), 0.45); +} + +/* Tool modal ------------------------------------------------------ */ + +/* Center the modal vertically + horizontally. Mirrors the gluetun-modal + trick: the inline `style="display: block"` set in JS triggers this + selector, which overrides to flexbox so the content sits in the + middle of the viewport regardless of its height. */ +.tool-modal { + position: fixed; + inset: 0; + z-index: 1100; +} +.tool-modal[style*="display: block"] { + display: flex !important; + align-items: center; + justify-content: center; +} + +/* Global .modal-body sets padding: 0 (it's used for full-bleed + content like the readme iframe). The tool modal's form needs + real breathing room around it — match the gluetun modal's + padding so the picker cards don't sit flush against the edges. + overflow:hidden so any inner scrollable region (e.g. the URL + list in app_urls_multi) is the *only* thing that scrolls, + not the whole modal body. */ +.tool-modal .modal-body { + padding: 20px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} +.tool-modal .tool-form { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} +.tool-modal .tool-form .form-group-app-urls, +.tool-modal .tool-form .form-group:has(.app-urls-multi) { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + margin-bottom: 0; + gap: 0; +} +.tool-modal .app-urls-multi { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + gap: 0; +} + +/* Single bordered shell that holds the search row + URL list as one + visual unit, mirroring the framing the rest of the WebUI uses. */ +.app-urls-container { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + background: rgba(var(--text-rgb), 0.02); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + overflow: hidden; +} +/* Title bar at the top of the picker container — replaces the old + floating .form-label so the field reads as one cohesive unit. */ +.app-urls-title { + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + background: rgba(var(--accent-rgb), 0.08); + border-bottom: 1px solid rgba(var(--accent-rgb), 0.20); + letter-spacing: 0.2px; + flex-shrink: 0; +} +.app-urls-title .required-mark { color: var(--status-danger); margin-left: 2px; } + +.app-urls-header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); + background: rgba(var(--bg-rgb), 0.12); + flex-shrink: 0; +} +.tool-modal .modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: stretch; + gap: 12px; +} +.tool-modal .modal-footer .btn { + flex: 1 1 0; +} + +.tool-modal-confirm { + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.4); + color: var(--status-warning); + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 14px; +} + +.tool-form .form-group { margin-bottom: 14px; } +.tool-form .form-group:last-child { margin-bottom: 0; } + +.tool-form .form-label { + display: block; + margin-bottom: 6px; + color: var(--text-primary, #fff); + font-size: 13px; + font-weight: 500; +} + +.tool-form .required-mark { color: var(--status-danger); } + +/* installed_apps_multi — visually mirrors gluetun country picker. */ +.installed-apps-multi { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Framed card holding the search input + bulk-action buttons, same + treatment as .gluetun-search-card. */ +.installed-apps-search-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; +} +.installed-apps-search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.installed-apps-search-row:focus-within { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); +} +.installed-apps-search-icon { + color: rgba(var(--text-rgb), 0.55); + flex-shrink: 0; +} +.installed-apps-search { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-size: 14px; + padding: 2px 0; +} +.installed-apps-actions { + display: flex; + gap: 8px; +} +.installed-apps-actions .btn { flex: 1 1 0; } + +/* Grid of selectable apps, matching .gluetun-country-list. */ +.installed-apps-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px 14px; + max-height: 45vh; + overflow-y: auto; + padding: 4px 2px; + background: transparent; + border: none; +} +.installed-apps-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.installed-apps-item:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} +.installed-apps-item:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.installed-apps-item input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + border-radius: 5px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; + margin: 0; +} +.installed-apps-item:hover input[type="checkbox"] { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08); +} +.installed-apps-item input[type="checkbox"]:focus-visible { + outline: none; + border-color: rgba(var(--accent-rgb), 0.85); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.20); +} +.installed-apps-item input[type="checkbox"]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.35); +} +.installed-apps-item input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 13px 13px; + animation: installedAppsCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes installedAppsCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.installed-apps-icon { + width: 22px; + height: 22px; + border-radius: 5px; + object-fit: contain; + flex-shrink: 0; +} +.installed-apps-name { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} +.installed-apps-multi-empty { + padding: 24px; + text-align: center; + color: rgba(var(--text-rgb), 0.6); + font-size: 13px; +} + +/* app_urls_multi — flat task-style list. One URL per row, no per-app + grouping. Each row is slim (icon + label + URL inline + checkbox) + and visually echoes the .task-item shell from style.css. */ +.app-urls-list { + display: flex !important; + flex-direction: column; + gap: 4px; + grid-template-columns: none !important; + padding: 8px 10px; + background: transparent; + border: none; + /* Fill available space inside the container and scroll only this + region — overrides the inherited .installed-apps-list max-height + which was sized for the wide-grid layout. */ + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} +.app-urls-loading { + padding: 18px; + text-align: center; + color: rgba(var(--text-rgb), 0.55); + font-size: 13px; + font-style: italic; +} + +.app-url-row.installed-apps-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + min-height: 34px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.06); + border-radius: 6px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; + line-height: 1; +} +.app-url-row:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} +.app-url-row:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.40); +} + +/* Slim flat row checkbox — ~⅓ smaller than the wide-grid gluetun + style, no glow. */ +.app-url-row input[type="checkbox"] { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + align-self: center; +} +.app-url-row input[type="checkbox"]:checked { + box-shadow: none; +} +.app-url-row input[type="checkbox"]:checked::after { + background-size: 8px 8px; +} + +.app-url-icon { + width: 28px; + height: 28px; + border-radius: 6px; + object-fit: contain; + flex-shrink: 0; + align-self: center; +} + +.app-url-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} +.app-url-sep { + color: rgba(var(--text-rgb), 0.35); + font-weight: 400; + margin: 0 2px; +} + +/* User list modal — opens after a list_users tool task completes. */ +.user-list { display: flex; flex-direction: column; gap: 6px; } +.user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; +} +.user-row:hover { background: rgba(var(--text-rgb), 0.05); } +.user-row-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.user-row-primary { font-size: 14px; font-weight: 500; color: var(--text-primary); } +.user-row-secondary { font-size: 12px; color: rgba(var(--text-rgb), 0.55); font-family: ui-monospace, "SF Mono", Menlo, monospace; } +.user-row-roles { + display: inline-flex; + align-self: flex-start; + margin-top: 2px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px solid rgba(var(--accent-rgb), 0.25); + color: var(--accent); +} + +.user-row-actions { display: flex; gap: 4px; flex-shrink: 0; } +.user-row-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: transparent; + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 5px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + color: rgba(var(--text-rgb), 0.85); +} +.user-row-btn:hover { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.40); +} +.user-row-btn.danger:hover { + background: rgba(var(--status-danger-rgb), 0.12); + border-color: rgba(var(--status-danger-rgb), 0.45); +} +.user-row-roles.is-admin { + background: rgba(var(--status-warning-rgb), 0.12); + border-color: rgba(var(--status-warning-rgb), 0.30); + color: var(--status-warning); +} + +/* Toggle inside a tool form — match form-group spacing so it sits flush + with siblings. .form-group:last-child rule already handles the bottom. */ +.tool-form > .tool-form-toggle { margin-bottom: 14px; } +.tool-form > .tool-form-toggle:last-child { margin-bottom: 0; } diff --git a/containers/libreportal/frontend/css/topbar.css b/containers/libreportal/frontend/css/topbar.css new file mode 100644 index 0000000..b7bbf02 --- /dev/null +++ b/containers/libreportal/frontend/css/topbar.css @@ -0,0 +1,257 @@ + + +/* Topbar layout, nav pills, donate/logout buttons. Extracted from style.css. Theme via var(--*) tokens. */ + +/* Topbar */ +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 24px; + height: 60px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: var(--bg-primary, #1a1a1a); + border-bottom: 1px solid var(--border-color, #444); + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); +} + +.topbar-left { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +.topbar .logo { + font-size: 20px; + font-weight: 600; +} + +.topbar .donate-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: var(--primary-color); + color: var(--text-primary); + cursor: pointer; + transition: background 0.2s; + font-weight: 600; +} + +.topbar .logout-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + border-radius: 6px; + background: rgba(var(--status-danger-rgb), 0.08); + color: var(--status-danger); + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.topbar .logout-btn:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.5); +} + +.topbar-nav { + display: flex; + gap: 8px; + align-items: center; +} + +.topbar-nav .nav-item { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + cursor: pointer; + min-height: 44px; + white-space: nowrap; +} + +.topbar-nav .nav-item:hover { + background: rgba(var(--text-rgb), 0.2); + transform: translateY(-1px); +} + +.topbar-nav .nav-item.active { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.topbar-nav .nav-item.nav-active { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.topbar-nav .nav-item.nav-active:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.topbar-nav .nav-item svg { + width: 16px; + height: 16px; +} + +/* Disabled state used while a system-wide task (e.g. config_update) is running + so the user can't navigate to App Center / Config mid-flight and act on + stale data. */ +.topbar-nav .nav-item.nav-item-disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: auto; /* keep cursor styling, but the JS click handler returns early */ +} + +.topbar-nav .nav-item.nav-item-disabled:hover { + background: transparent; +} + +.topbar-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.mobile-drawer { + display: flex; + align-items: center; + flex: 1; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.mobile-drawer-page-section { + display: none; +} + +@media (max-width: 768px) { + .topbar { + padding: 0 12px; + gap: 8px; + justify-content: flex-start; + } + + .topbar-left { + flex: 0 0 auto; + } + + .mobile-drawer { + position: fixed; + top: 60px; + left: 0; + width: 100vw; + height: calc(100vh - 60px); + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0; + padding: 16px; + background: var(--surface-bg-solid, #1a1a1a); + border-right: 1px solid var(--border-color, #444); + box-shadow: 6px 0 24px rgba(0, 0, 0, 0.35); + overflow-y: auto; + overscroll-behavior: contain; + transform: translateX(-100%); + transition: transform 0.3s ease; + z-index: 101; + } + + .mobile-drawer.mobile-open { + transform: translateX(0); + } + + .mobile-drawer .topbar-nav { + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; + } + + .mobile-drawer .topbar-nav .nav-item { + width: 100%; + justify-content: flex-start; + padding: 12px 14px; + } + + .mobile-drawer-page-section { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(var(--text-rgb), 0.12); + } + + .mobile-drawer-page-section:empty { + display: none; + } + + .mobile-drawer .topbar-controls { + flex-direction: column; + align-items: stretch; + gap: 8px; + width: 100%; + margin-top: auto; + padding-top: 16px; + padding-bottom: 16px; + border-top: 1px solid rgba(var(--text-rgb), 0.12); + position: sticky; + bottom: -16px; + flex-shrink: 0; + background: var(--surface-bg-solid, #1a1a1a); + z-index: 1; + } + + .mobile-drawer .topbar-controls .custom-select, + .mobile-drawer .topbar-controls .donate-btn, + .mobile-drawer .topbar-controls .logout-btn { + width: 100%; + justify-content: center; + } + + .mobile-drawer .topbar-controls .custom-select-button { + width: 100%; + justify-content: space-between; + } +} + +/* Compact custom-select for the topbar theme switcher. The default + .custom-select-button is form-input sized (12px 16px padding, + 14px font), too tall for the 60px topbar. */ +.topbar-controls .custom-select { + width: auto; + min-width: 110px; +} + +.topbar-controls .custom-select-button { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; + min-height: 0; + line-height: 1.2; +} + +.topbar-controls .custom-select-popup { + min-width: 140px; +} diff --git a/containers/libreportal/frontend/html/app-content.html b/containers/libreportal/frontend/html/app-content.html new file mode 100755 index 0000000..d451637 --- /dev/null +++ b/containers/libreportal/frontend/html/app-content.html @@ -0,0 +1,96 @@ + +
+ +
+ + + + + +
+
+ +
+ + +
+
+ + + + + +
+ + +
+ +
+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
Loading…
+
+ + Open backup center → +
+
+
+
+
+ +
+
+
+
diff --git a/containers/libreportal/frontend/html/apps-content.html b/containers/libreportal/frontend/html/apps-content.html new file mode 100755 index 0000000..16d0004 --- /dev/null +++ b/containers/libreportal/frontend/html/apps-content.html @@ -0,0 +1,71 @@ + +
+ +
+ + +
+ + + + + +
+
+ +
+
+
+ Loading applications... +
+
+ Discovering the perfect applications for you... +
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/apps-unified-layout.html b/containers/libreportal/frontend/html/apps-unified-layout.html new file mode 100755 index 0000000..25fea05 --- /dev/null +++ b/containers/libreportal/frontend/html/apps-unified-layout.html @@ -0,0 +1,240 @@ + +
+ +
+ + + + + +
+ +
+
+ +
+
+
+ Loading applications... +
+
+ Discovering the perfect applications for you... +
+
+
+
+ + +
+
+ +
+ + +
+
+ + + + + + +
+ + +
+ +
+
+ +
+
+

Loading configuration...

+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+

💾 Backups

+

Snapshots for this app across all configured repositories.

+
+
+
Loading…
+
+ + Open backup center → +
+
+
+
+
+ + +
+
+
+

📋 Task Management

+

Tasks for this application - Monitor and manage application tasks

+
+
+ +
+
+

Loading tasks...

+
+ +
+
+
+
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/backup-content.html b/containers/libreportal/frontend/html/backup-content.html new file mode 100644 index 0000000..5967183 --- /dev/null +++ b/containers/libreportal/frontend/html/backup-content.html @@ -0,0 +1,173 @@ +
+
+ + + +
+
+
+ +
+ + +
+
+
+
+
+

Per-app status

+ Latest backup per app on this host +
+
+
+
+
+

Locations

+ Active destinations +
+
+
+
+
+ +
+
+ + +
+
+ + + + + + + + + + + + +
AppHostLocationWhenIDActions
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+

Restore backup

+ +
+
+ +
+
+ +
+
+
+

Delete backup

+ +
+
+ +
+
+ +
+
+
+

Backup engine details

+ +
+
+ +
+
+ +
+
+
+

Add a backup location

+ +
+
+ +
+
diff --git a/containers/libreportal/frontend/html/config-content.html b/containers/libreportal/frontend/html/config-content.html new file mode 100755 index 0000000..ab1d25f --- /dev/null +++ b/containers/libreportal/frontend/html/config-content.html @@ -0,0 +1,21 @@ + +
+ +
+ + + + + +
+
+ +
+
+
diff --git a/containers/libreportal/frontend/html/dashboard-content.html b/containers/libreportal/frontend/html/dashboard-content.html new file mode 100755 index 0000000..5501f81 --- /dev/null +++ b/containers/libreportal/frontend/html/dashboard-content.html @@ -0,0 +1,53 @@ + + +
+ + +
+
+
+
0
+
Installed Apps
+
+
+
+
+
+
0%
+
+
+
+
Disk Used
+
+
+
+
+
+ OS: + Loading... +
+
+ Uptime: + Loading... +
+
+ Memory: + Loading... +
+
+ +
+
+ + + +
diff --git a/containers/libreportal/frontend/html/tasks-content.html b/containers/libreportal/frontend/html/tasks-content.html new file mode 100755 index 0000000..ba364c5 --- /dev/null +++ b/containers/libreportal/frontend/html/tasks-content.html @@ -0,0 +1,198 @@ + +
+ +
+ + + + + +
+ +
+
+ + Queued: 0 +
+
+ + Running: 0 +
+
+ + Completed: 0 +
+
+ + Failed: 0 +
+
+ + +
+
+ + +
+
+ +
+
+

Loading tasks...

+

Please wait while we fetch your tasks

+
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/topbar.html b/containers/libreportal/frontend/html/topbar.html new file mode 100755 index 0000000..3a1380a --- /dev/null +++ b/containers/libreportal/frontend/html/topbar.html @@ -0,0 +1,71 @@ + +
+ +
+ +
+ +
diff --git a/containers/libreportal/frontend/icons/apps/adguard.svg b/containers/libreportal/frontend/icons/apps/adguard.svg new file mode 100644 index 0000000..f6118fc --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/adguard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/authelia.svg b/containers/libreportal/frontend/icons/apps/authelia.svg new file mode 100644 index 0000000..9880b3b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/authelia.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/bookstack.svg b/containers/libreportal/frontend/icons/apps/bookstack.svg new file mode 100644 index 0000000..a6ad581 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/bookstack.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/crowdsec.svg b/containers/libreportal/frontend/icons/apps/crowdsec.svg new file mode 100644 index 0000000..fd4ffac --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/crowdsec.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/dashy.svg b/containers/libreportal/frontend/icons/apps/dashy.svg new file mode 100644 index 0000000..ce68744 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/dashy.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/default.svg b/containers/libreportal/frontend/icons/apps/default.svg new file mode 100644 index 0000000..343d2de --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/default.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/focalboard.svg b/containers/libreportal/frontend/icons/apps/focalboard.svg new file mode 100644 index 0000000..b78e7e3 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/focalboard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/gitea.svg b/containers/libreportal/frontend/icons/apps/gitea.svg new file mode 100644 index 0000000..11c6df8 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/gitea.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/apps/gluetun.svg b/containers/libreportal/frontend/icons/apps/gluetun.svg new file mode 100644 index 0000000..a39521c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/gluetun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/grafana.svg b/containers/libreportal/frontend/icons/apps/grafana.svg new file mode 100644 index 0000000..54be1e2 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/grafana.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/headscale.svg b/containers/libreportal/frontend/icons/apps/headscale.svg new file mode 100644 index 0000000..06f406a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/headscale.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/invidious.svg b/containers/libreportal/frontend/icons/apps/invidious.svg new file mode 100644 index 0000000..80e78a4 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/invidious.svg @@ -0,0 +1,2 @@ + + diff --git a/containers/libreportal/frontend/icons/apps/ipinfo.svg b/containers/libreportal/frontend/icons/apps/ipinfo.svg new file mode 100644 index 0000000..656169c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/ipinfo.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/jellyfin.svg b/containers/libreportal/frontend/icons/apps/jellyfin.svg new file mode 100644 index 0000000..0e56a50 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/jellyfin.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/jitsimeet.svg b/containers/libreportal/frontend/icons/apps/jitsimeet.svg new file mode 100644 index 0000000..5a3526a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/jitsimeet.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/libreportal.svg b/containers/libreportal/frontend/icons/apps/libreportal.svg new file mode 100644 index 0000000..a476796 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/libreportal.svg @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/apps/linkding.svg b/containers/libreportal/frontend/icons/apps/linkding.svg new file mode 100644 index 0000000..089630d --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/linkding.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/mastodon.svg b/containers/libreportal/frontend/icons/apps/mastodon.svg new file mode 100644 index 0000000..dd5075e --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/mastodon.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/nextcloud.svg b/containers/libreportal/frontend/icons/apps/nextcloud.svg new file mode 100644 index 0000000..336aff5 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/nextcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/apps/ollama.svg b/containers/libreportal/frontend/icons/apps/ollama.svg new file mode 100644 index 0000000..6bba73a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/ollama.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/onlyoffice.svg b/containers/libreportal/frontend/icons/apps/onlyoffice.svg new file mode 100644 index 0000000..364522c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/onlyoffice.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/owncloud.svg b/containers/libreportal/frontend/icons/apps/owncloud.svg new file mode 100644 index 0000000..cf650c7 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/owncloud.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/pihole.svg b/containers/libreportal/frontend/icons/apps/pihole.svg new file mode 100644 index 0000000..5bda461 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/pihole.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/portainer.svg b/containers/libreportal/frontend/icons/apps/portainer.svg new file mode 100644 index 0000000..45cf83a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/portainer.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/prometheus.svg b/containers/libreportal/frontend/icons/apps/prometheus.svg new file mode 100644 index 0000000..309d704 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/prometheus.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/searxng.svg b/containers/libreportal/frontend/icons/apps/searxng.svg new file mode 100644 index 0000000..2ddf53b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/searxng.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/speedtest.svg b/containers/libreportal/frontend/icons/apps/speedtest.svg new file mode 100644 index 0000000..2fd0d2b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/speedtest.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/traefik.svg b/containers/libreportal/frontend/icons/apps/traefik.svg new file mode 100644 index 0000000..a86b9b7 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/traefik.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/trilium.svg b/containers/libreportal/frontend/icons/apps/trilium.svg new file mode 100644 index 0000000..2ecb6e4 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/trilium.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/unbound.svg b/containers/libreportal/frontend/icons/apps/unbound.svg new file mode 100644 index 0000000..cfc5d8d --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/unbound.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/vaultwarden.svg b/containers/libreportal/frontend/icons/apps/vaultwarden.svg new file mode 100644 index 0000000..41ca105 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/vaultwarden.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/wireguard.svg b/containers/libreportal/frontend/icons/apps/wireguard.svg new file mode 100644 index 0000000..b778001 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/wireguard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/categories/all.svg b/containers/libreportal/frontend/icons/categories/all.svg new file mode 100755 index 0000000..c54a5c3 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/all.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/communication.svg b/containers/libreportal/frontend/icons/categories/communication.svg new file mode 100755 index 0000000..6cf99aa --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/communication.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/development.svg b/containers/libreportal/frontend/icons/categories/development.svg new file mode 100755 index 0000000..c3edcd6 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/development.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/installed.svg b/containers/libreportal/frontend/icons/categories/installed.svg new file mode 100755 index 0000000..0f77db6 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/installed.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/knowledge.svg b/containers/libreportal/frontend/icons/categories/knowledge.svg new file mode 100755 index 0000000..3942809 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/knowledge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/media.svg b/containers/libreportal/frontend/icons/categories/media.svg new file mode 100755 index 0000000..b4e81a3 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/media.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/misc.svg b/containers/libreportal/frontend/icons/categories/misc.svg new file mode 100755 index 0000000..4036d79 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/misc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/monitoring.svg b/containers/libreportal/frontend/icons/categories/monitoring.svg new file mode 100644 index 0000000..d8cb64b --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/monitoring.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/networking.svg b/containers/libreportal/frontend/icons/categories/networking.svg new file mode 100755 index 0000000..705ad1d --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/networking.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/productivity.svg b/containers/libreportal/frontend/icons/categories/productivity.svg new file mode 100755 index 0000000..42e8225 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/productivity.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/recommended.svg b/containers/libreportal/frontend/icons/categories/recommended.svg new file mode 100644 index 0000000..66262ad --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/recommended.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/security.svg b/containers/libreportal/frontend/icons/categories/security.svg new file mode 100755 index 0000000..ee389fb --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/security.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/storage.svg b/containers/libreportal/frontend/icons/categories/storage.svg new file mode 100755 index 0000000..d798892 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/storage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/system.svg b/containers/libreportal/frontend/icons/categories/system.svg new file mode 100755 index 0000000..5aaaef4 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/system.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/utils.svg b/containers/libreportal/frontend/icons/categories/utils.svg new file mode 100755 index 0000000..b6a135a --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/utils.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/config/backup.svg b/containers/libreportal/frontend/icons/config/backup.svg new file mode 100755 index 0000000..78447f5 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/backup.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/features.svg b/containers/libreportal/frontend/icons/config/features.svg new file mode 100755 index 0000000..42e8225 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/features.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/general.svg b/containers/libreportal/frontend/icons/config/general.svg new file mode 100755 index 0000000..79c2536 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/general.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/config/network.svg b/containers/libreportal/frontend/icons/config/network.svg new file mode 100755 index 0000000..705ad1d --- /dev/null +++ b/containers/libreportal/frontend/icons/config/network.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/security.svg b/containers/libreportal/frontend/icons/config/security.svg new file mode 100644 index 0000000..58f460c --- /dev/null +++ b/containers/libreportal/frontend/icons/config/security.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/config/webui.svg b/containers/libreportal/frontend/icons/config/webui.svg new file mode 100755 index 0000000..69d15b4 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/webui.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/containers/libreportal/frontend/icons/favicon.ico b/containers/libreportal/frontend/icons/favicon.ico new file mode 100644 index 0000000..622f2d3 Binary files /dev/null and b/containers/libreportal/frontend/icons/favicon.ico differ diff --git a/containers/libreportal/frontend/icons/libreportal.svg b/containers/libreportal/frontend/icons/libreportal.svg new file mode 100755 index 0000000..a476796 --- /dev/null +++ b/containers/libreportal/frontend/icons/libreportal.svg @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/airvpn.svg b/containers/libreportal/frontend/icons/vpn/airvpn.svg new file mode 100644 index 0000000..4d92e2b --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/airvpn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/cyberghost.png b/containers/libreportal/frontend/icons/vpn/cyberghost.png new file mode 100644 index 0000000..eed763b Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/cyberghost.png differ diff --git a/containers/libreportal/frontend/icons/vpn/expressvpn.svg b/containers/libreportal/frontend/icons/vpn/expressvpn.svg new file mode 100644 index 0000000..508f1a9 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/expressvpn.svg @@ -0,0 +1 @@ +ExpressVPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/fastestvpn.png b/containers/libreportal/frontend/icons/vpn/fastestvpn.png new file mode 100644 index 0000000..96719ef Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/fastestvpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/giganews.png b/containers/libreportal/frontend/icons/vpn/giganews.png new file mode 100644 index 0000000..182556c Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/giganews.png differ diff --git a/containers/libreportal/frontend/icons/vpn/hidemyass.png b/containers/libreportal/frontend/icons/vpn/hidemyass.png new file mode 100644 index 0000000..17a5ad8 Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/hidemyass.png differ diff --git a/containers/libreportal/frontend/icons/vpn/ipvanish.svg b/containers/libreportal/frontend/icons/vpn/ipvanish.svg new file mode 100644 index 0000000..68270e3 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/ipvanish.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/ivpn.png b/containers/libreportal/frontend/icons/vpn/ivpn.png new file mode 100644 index 0000000..b6ca7cd Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/ivpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/mullvad.svg b/containers/libreportal/frontend/icons/vpn/mullvad.svg new file mode 100644 index 0000000..1e17241 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/mullvad.svg @@ -0,0 +1 @@ +Mullvad \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/nordvpn.svg b/containers/libreportal/frontend/icons/vpn/nordvpn.svg new file mode 100644 index 0000000..76b5df5 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/nordvpn.svg @@ -0,0 +1 @@ +NordVPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/privado.png b/containers/libreportal/frontend/icons/vpn/privado.png new file mode 100644 index 0000000..25eee7a Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/privado.png differ diff --git a/containers/libreportal/frontend/icons/vpn/private-internet-access.svg b/containers/libreportal/frontend/icons/vpn/private-internet-access.svg new file mode 100644 index 0000000..d48eca5 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/private-internet-access.svg @@ -0,0 +1 @@ +Private Internet Access \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/privatevpn.png b/containers/libreportal/frontend/icons/vpn/privatevpn.png new file mode 100644 index 0000000..afc14d2 Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/privatevpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/protonvpn.svg b/containers/libreportal/frontend/icons/vpn/protonvpn.svg new file mode 100644 index 0000000..7d3c936 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/protonvpn.svg @@ -0,0 +1 @@ +Proton VPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/purevpn.png b/containers/libreportal/frontend/icons/vpn/purevpn.png new file mode 100644 index 0000000..1d07f73 Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/purevpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/slickvpn.png b/containers/libreportal/frontend/icons/vpn/slickvpn.png new file mode 100644 index 0000000..e88efed Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/slickvpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/surfshark.svg b/containers/libreportal/frontend/icons/vpn/surfshark.svg new file mode 100644 index 0000000..a0bcd93 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/surfshark.svg @@ -0,0 +1 @@ +Surfshark \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/torguard.png b/containers/libreportal/frontend/icons/vpn/torguard.png new file mode 100644 index 0000000..2fa2282 Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/torguard.png differ diff --git a/containers/libreportal/frontend/icons/vpn/vpn-unlimited.png b/containers/libreportal/frontend/icons/vpn/vpn-unlimited.png new file mode 100644 index 0000000..7aaeeb4 Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/vpn-unlimited.png differ diff --git a/containers/libreportal/frontend/icons/vpn/vpnsecure.png b/containers/libreportal/frontend/icons/vpn/vpnsecure.png new file mode 100644 index 0000000..ccde9df Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/vpnsecure.png differ diff --git a/containers/libreportal/frontend/icons/vpn/vyprvpn.png b/containers/libreportal/frontend/icons/vpn/vyprvpn.png new file mode 100644 index 0000000..b1d452b Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/vyprvpn.png differ diff --git a/containers/libreportal/frontend/icons/vpn/windscribe.png b/containers/libreportal/frontend/icons/vpn/windscribe.png new file mode 100644 index 0000000..df54bce Binary files /dev/null and b/containers/libreportal/frontend/icons/vpn/windscribe.png differ diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html new file mode 100755 index 0000000..f09389e --- /dev/null +++ b/containers/libreportal/frontend/index.html @@ -0,0 +1,100 @@ + + + + + + LibrePortal - Modern Docker Management + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/js/components/app/app-manager.js b/containers/libreportal/frontend/js/components/app/app-manager.js new file mode 100755 index 0000000..1398ecc --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/app-manager.js @@ -0,0 +1,363 @@ +// App Manager - Dynamic app loading with beautiful styling +class AppManager { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your application settings...", + "Gathering application information...", + "Loading application configuration...", + "Setting up your app management panel...", + "Loading the perfect app settings...", + "Crafting your application experience...", + "Preparing your app control panel...", + "Loading application details...", + "Setting up your app workspace...", + "Configuring your application environment..." + ]; + + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadApp(appName) { + //console.log(`AppManager: Loading ${appName} app...`); + + // Check cache first + if (this.cache.has(appName)) { + //console.log(`AppManager: Using cached ${appName} app`); + return this.cache.get(appName); + } + + try { + // Load app data from apps.json + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to load apps.json: ${response.status}`); + } + + const appsData = await response.json(); + + // Try multiple ways to find the app + let app = appsData.apps.find(app => + app.name.toLowerCase().includes(appName.toLowerCase()) || + app.command.toLowerCase().includes(appName.toLowerCase()) || + app.name === appName || + app.name.toLowerCase() === appName.toLowerCase() + ); + + if (!app) { + // Try case-insensitive exact match + app = appsData.apps.find(app => + app.name.toLowerCase() === appName.toLowerCase() || + app.command.toLowerCase().includes(appName.toLowerCase()) + ); + } + + if (!app) { + //console.log(`Available apps:`, appsData.apps.map(a => ({ name: a.name, command: a.command }))); + throw new Error(`App ${appName} not found`); + } + + //console.log(`AppManager: Loaded ${appName} app:`, app); + + // Cache the result + this.cache.set(appName, app); + + return app; + } catch (error) { + console.error(`AppManager: Error loading ${appName} app:`, error); + return null; + } + } + + async renderApp(appName) { + //console.log(`AppManager: Rendering ${appName} app...`); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('AppManager: config-section element not found'); + return; + } + + // Show loading with enhanced visual + configSection.innerHTML = ` +
+
+
+
+ Loading application... +
+
+ ${this.getRandomLoadingMessage()} +
+
+ +
+ `; + + // Update loading bar if available + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + try { + // Load app data + const app = await this.loadApp(appName); + + // Update loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(70); + } + + if (!app) { + configSection.innerHTML = '
Application not found
'; + return; + } + + // App config comes from apps.json (window.apps), not a separate + // per-app JSON. Pass null — the renderer's config section is gated + // on appConfig?.config keys so it just skips that section. + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + await this.renderWithOriginalStyling(appName, app, null); + + // Final progress update + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + //console.log(`AppManager: Successfully rendered ${appName} app`); + + } catch (error) { + console.error(`AppManager: Error rendering ${appName} app:`, error); + configSection.innerHTML = `
Failed to load ${appName} application: ${error.message}
`; + } + } + + async renderWithOriginalStyling(appName, app, appConfig) { + const configSection = document.getElementById('config-section'); + + // Render using the original app-config system + let formHTML = ` +
+

${app.displayName || app.name} Application

+

${app.description || 'Manage settings and configuration for ' + (app.displayName || app.name)}

+
+
+
+ `; + + // App information section + formHTML += ` +
+

Application Information

+

Basic information about this application

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ `; + + // Configuration section if available + if (appConfig && appConfig.config && Object.keys(appConfig.config).length > 0) { + // Use ConfigShared if available for beautiful rendering + if (typeof ConfigShared !== 'undefined') { + const groupedConfigs = ConfigShared.groupConfigKeys(appConfig.config); + const categoryOrder = ConfigShared.extractCategoryOrder(appConfig.config); + + for (const category of categoryOrder) { + const keys = groupedConfigs[category]; + if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') { + const displayCategory = ConfigShared.formatCategoryName(category); + const categoryDescription = await ConfigShared.getCategoryDescription(category); + + formHTML += ` +
+

${displayCategory}

+

${categoryDescription}

+
+ ${ConfigShared.generateFieldsForCategory(keys, category, appConfig.config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))} +
+
+ `; + } + } + } else { + // Fallback simple rendering + formHTML += ` +
+

Configuration

+

Application-specific settings

+
+
+ +
+ +
+
+
+
+ `; + } + } else { + // No configuration available + formHTML += ` +
+

Configuration

+

No specific configuration available for this application

+
+
+ +
+ +
+
+
+
+ `; + } + + formHTML += ` +
+
+ + +
+
+ `; + + configSection.innerHTML = formHTML; + } + + static async saveAppConfig(appName) { + //console.log(`AppManager: Saving ${appName} config...`); + + const form = document.getElementById(`app-form-${appName}`); + if (!form) { + console.error('AppManager: Form not found'); + return; + } + + // Show success message + if (typeof ConfigShared !== 'undefined' && ConfigShared.showNotification) { + ConfigShared.showNotification('Application configuration saved successfully!', 'success'); + } else { + // Fallback message + const message = document.createElement('div'); + message.className = 'config-message success'; + message.textContent = 'Application configuration saved successfully!'; + + const actionsDiv = form.parentElement.querySelector('.config-actions'); + actionsDiv.insertBefore(message, actionsDiv.firstChild); + + // Remove message after 3 seconds + setTimeout(() => { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 3000); + } + } + + static async resetAppConfig(appName) { + //console.log(`AppManager: Resetting ${appName} config...`); + + if (confirm('Are you sure you want to reset all settings to their default values?')) { + // Reload the page to reset + window.location.reload(); + } + } + + async loadScript(src) { + // Check if script is already loaded + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + //console.log(`Script ${src} already loaded, skipping`); + return; + } + + //console.log(`Loading script: ${src}`); + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = () => { + //console.log(`Script loaded successfully: ${src}`); + resolve(); + }; + script.onerror = (error) => { + console.error(`Script failed to load: ${src}`, error); + reject(new Error(`Failed to load script: ${src}`)); + }; + document.head.appendChild(script); + }); + } +} + +// Global instance +window.appManager = new AppManager(); diff --git a/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js new file mode 100755 index 0000000..6d7d1d1 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js @@ -0,0 +1,1138 @@ +// Enhanced App Manager with Tabbed Interface +// Integrates app management with task history + +class AppTabbedManager { + constructor() { + // console.log('🔍 AppTabbedManager constructor called'); + // console.log('🔍 URL in constructor:', window.location.href); + // console.log('🔍 Search params in constructor:', window.location.search); + + // Store original URL for task parameter detection + this.originalUrl = window.location.href; + this.originalSearch = window.location.search; + + // Check sessionStorage for task parameter (fallback) + const sessionTaskId = sessionStorage.getItem('pendingTaskId'); + // console.log('🔍 Session storage task ID:', sessionTaskId); + + // Debug: Check if task parameter exists in original URL + const originalParams = new URLSearchParams(this.originalSearch); + const originalTaskId = originalParams.get('task'); + // console.log('🔍 Original task ID in constructor:', originalTaskId); + + // Try to get task parameter from performance navigation if available + if (performance && performance.getEntriesByType) { + const navigationEntries = performance.getEntriesByType('navigation'); + if (navigationEntries.length > 0) { + const navEntry = navigationEntries[0]; + // console.log('🔍 Navigation entry URL:', navEntry.name); + const navParams = new URLSearchParams(new URL(navEntry.name).search); + const navTaskId = navParams.get('task'); + // console.log('🔍 Navigation task ID:', navTaskId); + } + } + + this.currentApp = this.getAppFromURL(); + this.currentTab = this.getTabFromURL(); + this.tasksManager = new TasksManager(); + this.appsManager = new AppsManager(); + this.initialized = false; + + // Button state management + this.disabledButtons = new Set(); + this.activeTaskId = null; + // Track running tasks. Key is `${appName}|${action}` (using `|` so app names with + // hyphens don't collide). Value is { taskId, appName, action } so callers never + // have to parse the key. + this.runningTasks = new Map(); + } + + // Build the runningTasks key for an (app, action) pair. Use `|` since `-` and `_` + // appear in app/action strings (e.g. 'delete_all'). + taskKey(appName, action) { + return `${appName}|${action}`; + } + + // Find the most recent running task for the given app, or null. + getRunningTaskForApp(appName) { + if (!appName) return null; + for (const info of this.runningTasks.values()) { + if (info.appName === appName) return info; + } + return null; + } + + // Switch the manager to a new app, clearing DOM-bound state from the previous app + // and re-evaluating tab/button state for the new one. Callers (e.g. apps-manager) + // should use this instead of mutating `currentApp` directly so disabled tabs from + // app A don't bleed into app B's view. + setCurrentApp(appName) { + if (this.currentApp === appName) return; + + // console.log('🔄 setCurrentApp: switching from %s to %s', this.currentApp, appName); + this.currentApp = appName; + + // Before clearing disabled button references, restore any static backup action buttons + // that may have spinners/disabled state from a previous app + const backupActions = document.querySelectorAll('.backup-actions button'); + backupActions.forEach(button => { + if (button && button.dataset.originalContent) { + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + }); + + this.disabledButtons.clear(); + this.activeTaskId = null; + this.enableTabs(); + + const running = this.getRunningTaskForApp(appName); + // console.log('🔍 setCurrentApp: running task for %s = %o', appName, running); + if (running) { + this.activeTaskId = running.taskId; + } + } + + // Get app name from URL parameter + getAppFromURL() { + const urlParams = new URLSearchParams(window.location.search); + let appName = urlParams.get('app'); + + // Fallback to old format if app param not found + if (!appName) { + const fullPath = window.location.search; + if (fullPath.includes('?=')) { + const [basePath, query] = fullPath.split('?='); + appName = query.split('&')[0]; // Get only the app name, ignore other params + } + } + + // console.log('🔍 Original app name from URL:', appName); + + // Convert full app name to slug for task filtering + if (appName && window.apps) { + const appData = window.apps.find(app => { + // Extract slug from command + const command = app.command || ''; + const parts = command.split(' '); + return parts[parts.length - 1] === appName; + }); + + if (appData) { + const command = appData.command || ''; + const parts = command.split(' '); + const slug = parts[parts.length - 1]; // Return the slug + // console.log('🔄 Converted to slug:', slug, 'from appData:', appData.name); + return slug; + } else { + // console.log('⚠️ No app data found for:', appName); + } + } + + // console.log('🔄 Returning original app name:', appName); + return appName; + } + + // Get tab from URL parameter + getTabFromURL() { + const currentUrl = window.location.href; + const urlParams = new URLSearchParams(window.location.search); + const tab = urlParams.get('tab') || 'config'; + // console.log('🔍 getTabFromURL debug:', { + //currentUrl: currentUrl, + //search: window.location.search, + //tabParam: urlParams.get('tab'), + //defaultTab: 'config', + //finalTab: tab === 'logs' ? 'tasks' : tab + //}); + // Convert "logs" to "tasks" for backward compatibility + return tab === 'logs' ? 'tasks' : tab; + } + + // Check if we're on an app page before doing anything + isAppPage() { + const pathname = window.location.pathname; + const search = window.location.search; + + // Only individual app pages (/app?=appname), NOT the apps listing page (/apps) + return (pathname.startsWith('/app') && !pathname.startsWith('/apps') || + pathname.endsWith('/index.html') || pathname === '/index.html' || + search.includes('app=') || + search.includes('?=')); // Old format app pages + } + + // Update URL with app and tab + updateURL(app = null, tab = null) { + // console.log('🔍 updateURL called with:', { app, tab }); + // console.log('🔍 Current URL before update:', window.location.href); + + // Only update URLs on app pages - prevent interference with other pages + if (!this.isAppPage()) { + // console.log('🚫 Not on app page, skipping URL update'); + return; + } + + const url = new URL(window.location); + const params = new URLSearchParams(url.search); + const fullPath = window.location.search; // Define here for both blocks + + // Handle both old format (?=appname) and new format (?app=appname) + if (app) { + // Check if we're using the old format + if (fullPath.includes('?=')) { + // Update old format: /app?=appname&tab=tabname + const newURL = `/app?=${app}`; + if (tab) { + // console.log('🔄 Updating URL to:', `${newURL}&tab=${tab}`); + window.history.replaceState({}, '', `${newURL}&tab=${tab}`); + } else { + // console.log('🔄 Updating URL to:', newURL); + window.history.replaceState({}, '', newURL); + } + } else { + // Update new format: /app?app=appname&tab=tabname + if (tab) { + params.set('app', app); + params.set('tab', tab); + } else { + params.set('app', app); + } + const newSearch = params.toString(); + // console.log('🔄 Updating URL to:', `${window.location.pathname}?${newSearch}`); + window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); + } + } else { + // Only updating tab, preserve existing app and task parameters + if (fullPath.includes('?=')) { + // Old format: preserve app and task, update tab + const currentApp = params.get('=') || this.currentApp; + const currentTask = params.get('task'); + let newURL = `/app?=${currentApp}&tab=${tab}`; + if (currentTask) { + newURL += `&task=${currentTask}`; + } + // console.log('🔄 Updating URL (old format) to:', newURL); + window.history.replaceState({}, '', newURL); + } else { + // New format: preserve app and task, update tab + const currentApp = params.get('app') || this.currentApp; + const currentTask = params.get('task'); + params.set('app', currentApp); + params.set('tab', tab); + if (currentTask) { + params.set('task', currentTask); + } + const newSearch = params.toString(); + // console.log('🔄 Updating URL (new format) to:', `${window.location.pathname}?${newSearch}`); + window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); + } + } + } + + // Update current app and refresh content + updateApp(newAppName) { + this.setCurrentApp(newAppName); + + // Reset URL to config tab + const currentUrl = window.location.href; + let newUrl; + if (currentUrl.includes('tab=')) { + newUrl = currentUrl.replace(/tab=[^&]*/, 'tab=config'); + } else { + newUrl = `${currentUrl}&tab=config`; + } + history.replaceState({}, '', newUrl); + + this.switchTab('config'); + } + + // Switch between tabs + switchTab(tabId) { + // console.log('🔄 switchTab called with:', tabId); + // console.log('🔍 Current currentApp before switch:', this.currentApp); + // console.log('🔍 Current URL when switching:', window.location.href); + // console.log('🔍 URL search when switching:', window.location.search); + + // Remove active class from all main navigation tabs + document.querySelectorAll('.main-tab-button').forEach(btn => { + btn.classList.remove('active'); + }); + + // Hide all tab panes + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('active'); + }); + + // Add active class to selected main navigation tab + const selectedTab = document.querySelector(`.main-tab-button[data-tab="${tabId}"]`); + if (selectedTab) { + selectedTab.classList.add('active'); + //// // console.log('✅ Tab button activated:', tabId); + } else { + console.warn('⚠️ Main navigation tab button not found:', tabId); + } + + // Add active class to selected tab pane + const selectedPane = document.getElementById(`${tabId}-tab`); + if (selectedPane) { + selectedPane.classList.add('active'); + //// // console.log('✅ Tab pane activated:', tabId); + } else { + console.warn('⚠️ Tab pane not found:', tabId); + } + + // Update URL (only tab, not app) - but only on app pages + if (this.isAppPage()) { + // console.log('🔄 About to updateURL with tab:', tabId); + this.updateURL(null, tabId); + } + + // Load tab-specific content + // console.log('🔄 About to load tab content for tab:', tabId, 'with currentApp:', this.currentApp); + this.loadTabContent(tabId); + } + + // Load content for specific tab + async loadTabContent(tabId) { + const actualTabId = tabId === 'logs' ? 'tasks' : tabId; + + const currentAppFromUrl = this.getAppFromURL(); + // console.log('📂 loadTabContent: tabId=%s, currentApp=%s, fromUrl=%s', + // tabId, this.currentApp, currentAppFromUrl); + + // Update currentApp if URL has different app name. Route through setCurrentApp + // so any disable state from the previous app gets cleared before we render. + if (currentAppFromUrl && currentAppFromUrl !== this.currentApp) { + this.setCurrentApp(currentAppFromUrl); + } + + // Ensure app detail view is shown and app is loaded before loading tab content + if (!this.currentApp || this.currentApp === 'null') { + console.warn('⚠️ No current app set, cannot load tab content'); + return; + } + + // Toggle the Tools tab button visibility based on whether this app has + // any tools. Tools-less apps simply don't see the tab. If the user + // landed on the tools tab via a deep link for such an app, redirect + // them to config so they're not staring at an empty pane. + if (window.toolsManager) { + const toolsResult = await window.toolsManager.prepare(this.currentApp); + if (actualTabId === 'tools' && (!toolsResult || toolsResult.tools.length === 0)) { + return this.switchTab('config'); + } + } + + // Routing tab is Traefik-only — show on Traefik, hide everywhere else. + const isTraefik = this.currentApp === 'traefik'; + document.querySelectorAll('[data-tab="routing"]').forEach(btn => { + btn.style.display = isTraefik ? '' : 'none'; + }); + if (actualTabId === 'routing' && !isTraefik) { + return this.switchTab('config'); + } + + // Make sure app detail view is visible and app is loaded + if (window.appsManager) { + // Use showAppDetail to ensure proper initialization (same as config tab) + // console.log('🔄 Ensuring app detail is loaded for:', this.currentApp); + window.appsManager.showAppDetail(this.currentApp); + + // Wait a bit for DOM to be ready after app detail is rendered + await new Promise(resolve => setTimeout(resolve, 200)); + } + + switch (actualTabId) { + case 'tasks': + // console.log('🔄 loadTabContent: Loading tasks for app:', this.currentApp); + await this.loadAppTasks(); + break; + case 'backups': + // console.log('🔄 loadTabContent: Loading backups for app:', this.currentApp); + await this.loadAppBackups(); + // IMPORTANT: Re-apply button state if there are running tasks + this.restoreButtonState(); + break; + case 'services': + if (window.servicesManager) { + await window.servicesManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'tools': + if (window.toolsManager) { + await window.toolsManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'routing': + if (window.routingManager) { + await window.routingManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'config': + // Config is already handled by showAppDetail above + // console.log('🔧 Config content already loaded by showAppDetail'); + // IMPORTANT: Re-apply button state if there are running tasks + this.restoreButtonState(); + break; + default: + // Config is handled by existing app management system + break; + } + + // Tear down the services tab (timers + SSE) when switching away. + if (actualTabId !== 'services' && window.servicesManager) { + window.servicesManager.unload(); + } + if (actualTabId !== 'tools' && window.toolsManager) { + window.toolsManager.unload(); + } + } + + // Load tasks specific to current app + async loadAppTasks() { + // console.log('🔄 loadAppTasks called, currentApp:', this.currentApp); + + // Show loading spinner by showing the initial loading state + const tasksContainer = document.getElementById('app-tasks'); + if (tasksContainer) { + tasksContainer.innerHTML = ` +
+
+

Loading tasks...

+
+ `; + } + + if (!this.currentApp) { + if (tasksContainer) { + tasksContainer.innerHTML = '

No app selected.

'; + } + return; + } + + try { + // Load all tasks + // console.log('🔄 Loading tasks...'); + // console.log('🔍 Using currentApp for filtering:', this.currentApp); + await this.tasksManager.loadTasks(); + const allTasks = this.tasksManager.tasks || []; + // console.log('📊 All tasks loaded:', allTasks.length); + // console.log('📋 All tasks data:', allTasks); + // console.log('📋 Sample task app names:', allTasks.slice(0, 3).map(t => t.app)); + + // Filter tasks for current app + const appTasks = allTasks.filter(task => task.app === this.currentApp); + // console.log('🎯 Filtering tasks for app:', this.currentApp); + // console.log('📋 Available task.app values:', [...new Set(allTasks.map(t => t.app))]); + // console.log('🎯 Filtered tasks for', this.currentApp, ':', appTasks.length); + + // Debug: Show what would match if we used different app names + // console.log('🔍 Debug - Testing different app names:'); + ['libreportal', 'fail2ban', 'LibrePortal', 'Fail2Ban'].forEach(testApp => { + const testTasks = allTasks.filter(task => task.app === testApp); + // console.log(` - "${testApp}": ${testTasks.length} tasks`); + }); + + if (appTasks.length === 0) { + // console.log('⚠️ No tasks found for', this.currentApp, '- checking if tasks have different app names'); + // Show some task details for debugging + if (allTasks.length > 0) { + // console.log('📋 Sample tasks:', allTasks.slice(0, 3).map(t => ({ id: t.id, app: t.app, command: t.command }))); + } + tasksContainer.innerHTML = `

No tasks found for ${this.currentApp}.

`; + return; + } + + // Setup global functions for task interactions + this.tasksManager.setupGlobalFunctions(); + + // Render app-specific tasks + const tasksHtml = appTasks.map(task => this.tasksManager.renderTask(task)).join(''); + tasksContainer.innerHTML = tasksHtml; + + // Setup app-specific task interactions (separate from main tasks system) + this.setupAppTaskFunctions(); + + // Handle pending task ID from URL parameter + if (this.pendingTaskId) { + // console.log('🔍 Handling pending task ID after tasks loaded:', this.pendingTaskId); + setTimeout(() => { + if (typeof window.toggleAppTaskDetails === 'function') { + // console.log('🔍 Opening task details for pending task:', this.pendingTaskId); + window.toggleAppTaskDetails(this.pendingTaskId); + + // Scroll to the task element after opening details + this.scrollToTask(this.pendingTaskId); + + this.pendingTaskId = null; // Clear pending task ID + } + }, 500); // Wait a bit for DOM to be ready + } + + // Task events are handled by individual task components + // No additional initialization needed + + } catch (error) { + console.error('AppTabbedManager: Error loading app tasks:', error); + tasksContainer.innerHTML = `

Error loading tasks: ${error.message}

`; + } + } + + // Scroll to specific task element with smooth animation + scrollToTask(taskId) { + // console.log('🔍 Scrolling to task:', taskId); + + // Find the task element by ID or data attribute + let taskElement = document.getElementById(`task-${taskId}`); + + // If not found by ID, try to find by data-task-id attribute + if (!taskElement) { + taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + } + + // If still not found, try to find the task details element + if (!taskElement) { + const detailsElement = document.getElementById(`details-${taskId}`); + if (detailsElement) { + taskElement = detailsElement.closest('.task-item'); + } + } + + if (taskElement) { + // console.log('🔍 Found task element, scrolling to it:', taskElement); + + // Smooth scroll to the task element + taskElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', // Center the task in the viewport + inline: 'nearest' + }); + + // Add a highlight effect to make the task more visible + taskElement.classList.add('task-highlighted'); + + // Remove the highlight after 3 seconds + setTimeout(() => { + taskElement.classList.remove('task-highlighted'); + }, 3000); + + } else { + console.warn('⚠️ Task element not found for scrolling:', taskId); + } + } + + // Setup app-specific task functions to avoid conflicts with main tasks page + setupAppTaskFunctions() { + // Create app-specific toggleTaskDetails function + window.toggleAppTaskDetails = (taskId) => { + // console.log('🔍 App-specific toggleTaskDetails called for:', taskId); + + const details = document.getElementById(`details-${taskId}`); + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + + if (details) { + const isOpen = details.style.display === 'block'; + + // Close all other task details and reset their buttons + document.querySelectorAll('.task-details').forEach(d => { + if (d.id !== `details-${taskId}`) { + d.style.display = 'none'; + d.classList.remove('task-details-open'); + } + }); + + document.querySelectorAll('.task-btn.toggle-details').forEach(btn => { + btn.classList.remove('expanded'); + }); + + if (isOpen) { + details.style.display = 'none'; + details.classList.remove('task-details-open'); + if (toggleBtn) toggleBtn.classList.remove('expanded'); + } else { + details.style.display = 'block'; + details.classList.add('task-details-open'); + if (toggleBtn) toggleBtn.classList.add('expanded'); + + // Auto-load logs when opened + if (this.tasksManager && this.tasksManager.loadTaskLogs) { + this.tasksManager.loadTaskLogs(taskId); + } + + // Update URL to include task parameter + const currentUrl = window.location.href; + const urlParams = new URLSearchParams(currentUrl.search); + + // Get current app from AppTabbedManager + const currentApp = this.currentApp || ''; + + // Construct proper URL with correct parameter order + const newUrl = `/app?=${currentApp}&tab=tasks&task=${taskId}`; + // console.log('🔍 Updating URL with task parameter:', newUrl); + history.pushState({}, '', newUrl); + } + } else { + console.warn('⚠️ App task details not found for:', taskId); + } + }; + + // Override global toggleTaskDetails to use app-specific version when on app page + const originalToggleTaskDetails = window.toggleTaskDetails; + window.toggleTaskDetails = (taskId) => { + if (window.appTabbedManager && window.location.pathname.includes('/app')) { + // Use app-specific version + window.toggleAppTaskDetails(taskId); + } else { + // Use original version for main tasks page + originalToggleTaskDetails(taskId); + } + }; + } + + // Load app backups + async loadAppBackups() { + const backupAppNameElement = document.getElementById('backup-app-name'); + if (backupAppNameElement) { + const formattedAppName = this.currentApp + ? (window.getAppDisplayName ? window.getAppDisplayName(this.currentApp) : (this.currentApp.charAt(0).toUpperCase() + this.currentApp.slice(1))) + : 'Unknown App'; + backupAppNameElement.textContent = formattedAppName; + } + + if (!this.currentApp || typeof BackupAppCard === 'undefined') { + const status = document.getElementById('backup-app-card-status'); + if (status) status.textContent = 'No app selected.'; + return; + } + + if (!this.backupAppCard || this.backupAppCard.appName !== this.currentApp) { + this.backupAppCard = new BackupAppCard(this.currentApp); + window.backupAppCard = this.backupAppCard; + } + await this.backupAppCard.render(); + } + + // Initialize the tabbed manager + async initialize() { + // Prevent double initialization + if (this.initialized) { + // console.log('⚠️ AppTabbedManager already initialized, skipping'); + return; + } + + // console.log('🚀 AppTabbedManager initializing, currentApp:', this.currentApp); + + // Initialize task system if not already done (with retry) + if (this.tasksManager && !this.tasksManager.commands) { + let initialized = false; + let attempts = 0; + const maxAttempts = 5; + + while (!initialized && attempts < maxAttempts) { + // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); + try { + initialized = this.tasksManager.initializeTaskSystem(); + if (initialized) { + // console.log('✅ Task system initialized successfully'); + } + } catch (error) { + console.error('❌ Task system initialization error:', error); + } + + if (!initialized) { + attempts++; + await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms + } + } + + if (!initialized) { + console.warn('⚠️ Task system initialization failed after retries'); + } + } + + // Stale .task-running from a prior session won't survive a reload, but the + // DOM might still carry it from a re-render — clear it up front. + this.enableTabs(); + document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') + .forEach(button => this.restoreButton(button)); + + // SSE drives task lifecycle events; the reconcile pass below is a + // safety net for the cases where they don't reach us — bus disconnect, + // missed event during reconnect, throttled background tab. Several + // triggers so a stuck "running" state self-heals quickly: + // * a faster periodic tick (5s) instead of the previous 30s + // * whenever the SSE bus reconnects (`taskBusReady`) + // * whenever the page becomes visible again (`visibilitychange`) + // * whenever the window regains focus + if (!this._watchdogStarted) { + this._watchdogStarted = true; + const reconcile = () => this.reconcileRunningTasks().catch(() => {}); + // Adaptive cadence: when a task is actively running, poll every + // 1.5s so a missed SSE event surfaces quickly. When idle, fall back + // to 5s. Net: worst-case lag drops from ~10s to ~1.5s while keeping + // background load minimal. + const tick = () => { + reconcile(); + const next = (this.runningTasks && this.runningTasks.size > 0) ? 1500 : 5000; + clearTimeout(this._reconcileTimer); + this._reconcileTimer = setTimeout(tick, next); + }; + this._reconcileTimer = setTimeout(tick, 1500); + window.addEventListener('taskBusReady', reconcile); + window.addEventListener('focus', reconcile); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') reconcile(); + }); + } + + // Set current app from URL BEFORE setting up URL monitoring + const urlAppName = this.getAppFromURL(); + // console.log('🔍 Setting initial currentApp from URL:', urlAppName); + this.currentApp = urlAppName; + + // Check for running tasks for this app and auto-switch to tasks tab if found + if (this.currentApp) { + const running = this.getRunningTaskForApp(this.currentApp); + if (running) { + this.switchTab('tasks'); + this.disableTabs(); + this.activeTaskId = running.taskId; + } + } + + // Check for task parameter and handle it AFTER tasks are loaded + // Use original URL since the current URL might have been modified + const urlParams = new URLSearchParams(this.originalSearch); + // console.log('🔍 Original URL search during init:', this.originalSearch); + // console.log('🔍 Original URL params during init:', Object.fromEntries(urlParams.entries())); + let taskId = urlParams.get('task'); + // console.log('🔍 Task ID from original params:', taskId); + + // Fallback: Check sessionStorage if URL doesn't have task parameter + if (!taskId) { + taskId = sessionStorage.getItem('pendingTaskId'); + // console.log('🔍 Task ID from sessionStorage fallback:', taskId); + // Clear sessionStorage after using it + if (taskId) { + sessionStorage.removeItem('pendingTaskId'); + } + } + if (taskId) { + // console.log('🔍 Task parameter found:', taskId); + // Store the task ID to handle after tasks are loaded + this.pendingTaskId = taskId; + // Force tasks tab + this.switchTab('tasks'); + } + + // Monitor URL changes for app navigation + this.setupURLMonitoring(); + + // Listen for task creation events + this.setupTaskEventListeners(); + + // Set initial active tab (only if no task parameter) + if (!taskId) { + const initialTab = this.getTabFromURL(); + // console.log('🔄 Setting initial tab:', initialTab, 'with currentApp:', this.currentApp); + this.switchTab(initialTab); + } + + // Set global reference for other components + window.appTabbedManager = this; + } + + // Monitor URL changes for app navigation + setupURLMonitoring() { + // Listen for popstate events (browser back/forward) + window.addEventListener('popstate', () => { + if (!this.isAppPage()) return; // Only monitor on app pages + + const newAppName = this.getAppFromURL(); + // Only update if currentApp is already set and app actually changed + if (this.currentApp && newAppName !== this.currentApp) { + // console.log('🔄 URL changed, updating app from', this.currentApp, 'to', newAppName); + this.updateApp(newAppName); + } + }); + } + + // Watch for new tasks and switch to logs tab + watchForTaskCreation() { + // Auto-switch to Tasks tab when a fresh task appears for the current app. + setInterval(async () => { + if (this.currentApp && this.currentTab !== 'tasks') { + // Skip if a recent uninstall asked us not to auto-switch. + const until = this._suppressTaskAutoSwitch?.get(this.currentApp); + if (until && Date.now() < until) return; + if (until) this._suppressTaskAutoSwitch.delete(this.currentApp); + try { + await this.tasksManager.loadTasks(); + const allTasks = this.tasksManager.tasks || []; + // Only switch on RUNNING/QUEUED tasks created recently — completed + // ones don't need watching, and would otherwise bounce the user + // back to Tasks right after they've been switched away. + const recentTasks = allTasks.filter(task => + task.app === this.currentApp && + (task.status === 'running' || task.status === 'queued' || task.status === 'pending') && + new Date(task.createdAt) > new Date(Date.now() - 5000) + ); + if (recentTasks.length > 0) this.switchTab('tasks'); + } catch (error) { + console.error('Error watching for tasks:', error); + } + } + }, 5000); + } + + // Create backup (placeholder function) + async createBackup(appName) { + // Placeholder - will be implemented with actual backup logic + // console.log(`Creating backup for ${appName}...`); + } + + // Setup task event listeners for button state management + setupTaskEventListeners() { + window.addEventListener('taskCreated', (event) => { + const { taskId, appName, action } = event.detail; + const key = this.taskKey(appName, action); + + // console.log('📌 taskCreated: appName=%s, currentApp=%s, action=%s, key=%s', + // appName, this.currentApp, action, key); + + if (this.runningTasks.has(key)) { + const existing = this.runningTasks.get(key); + // Same task firing twice — `createAndExecuteTask` dispatches taskCreated + // synchronously, and the SSE bus also dispatches it when the new task + // file shows up. Both events carry the same taskId; treat as a no-op. + if (existing && existing.taskId === taskId) return; + + if (window.notificationSystem) { + // Match the per-task-type icon used everywhere else (install ✅, + // backup 💾, etc.) so the user sees *what kind* of task is in + // progress, not just a generic warning triangle. + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Task Already Running
+ A ${action} task for ${appName} is already in progress.
+ Please wait for the current task to complete.`, + 'warning', + null, + null, + null, + customIcon + ); + } + return; + } + + this.runningTasks.set(key, { taskId, appName, action }); + // console.log('📌 taskCreated: stored in runningTasks, will disable=%s', appName === this.currentApp); + + if (appName === this.currentApp) { + this.disableAppButtons(appName, action); + this.activeTaskId = taskId; + } + }); + + window.addEventListener('taskCompleted', (event) => { + const { taskId, appName, action } = event.detail; + const key = this.taskKey(appName, action); + + // Primary path: delete by exact (app, action) key. + this.runningTasks.delete(key); + // Belt-and-braces: also remove any entry that matches the taskId. If + // `action` ever differs between the original `taskCreated` and this + // `taskCompleted` event (different code paths produce slightly + // different action strings), the key-based delete above is a no-op + // and the row would stay "running" forever. Match on id too. + for (const [k, info] of this.runningTasks) { + if (info && info.taskId === taskId) this.runningTasks.delete(k); + } + + const stillRunning = this.getRunningTaskForApp(appName); + if (stillRunning) { + if (appName === this.currentApp) this.activeTaskId = stillRunning.taskId; + return; + } + + // Always run the DOM cleanup; enableAppButtons is idempotent. + this.enableAppButtons(appName); + if (appName === this.currentApp) this.activeTaskId = null; + + if ((action === 'backup' || action === 'delete' || action === 'delete_all') && this.backupAppCard) { + this.backupAppCard.render(); + } + }); + + // Extra safety net: any `taskUpdated` whose status is terminal should + // also clear our local tracking. The bus normally dispatches a + // dedicated `taskCompleted` instead — but if a single task file write + // jumps a status straight from queued/pending to completed in a way + // that the bus classifies as "updated, !isNew", we'd miss it otherwise. + window.addEventListener('taskUpdated', (event) => { + const t = event.detail && event.detail.task; + if (!t || !t.id) return; + const terminal = t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'; + if (!terminal) return; + let removed = false; + for (const [k, info] of this.runningTasks) { + if (info && info.taskId === t.id) { this.runningTasks.delete(k); removed = true; } + } + if (!removed) return; + const appName = t.app || null; + if (appName && !this.getRunningTaskForApp(appName)) { + this.enableAppButtons(appName); + if (appName === this.currentApp) this.activeTaskId = null; + } + }); + } + + // Helper method to get action for a task (used for duplicate detection) + getActionForTask(taskId) { + // Prefer our own runningTasks map — it knows the action by source of truth. + for (const info of this.runningTasks.values()) { + if (info.taskId === taskId) return info.action; + } + if (this.tasksManager && this.tasksManager.tasks) { + const task = this.tasksManager.tasks.find(t => t.id === taskId); + return task ? task.type : 'unknown'; + } + return 'unknown'; + } + + // Disable config, services and backup tabs when task is running + disableTabs() { + const tabs = ['config', 'services', 'tools', 'backups'] + .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) + .filter(Boolean); + + for (const tab of tabs) { + tab.disabled = true; + tab.classList.add('disabled', 'task-running'); + tab.style.opacity = '0.5'; + tab.style.pointerEvents = 'none'; + tab.title = 'Disabled due to task running'; + } + } + + // Enable config, services and backup tabs when task completes + enableTabs() { + const tabs = ['config', 'services', 'tools', 'backups'] + .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) + .filter(Boolean); + + for (const tab of tabs) { + tab.disabled = false; + tab.classList.remove('disabled', 'task-running'); + tab.style.opacity = ''; + tab.style.pointerEvents = ''; + tab.title = ''; + } + } + + // Disable app buttons during task execution + disableAppButtons(appName, action) { + // console.log('🚫 disableAppButtons called: appName=%s, action=%s, currentApp=%s', + // appName, action, this.currentApp); + + // Also disable config and backup tabs + this.disableTabs(); + + // Find ALL action buttons in the app content section (config, backup, etc.) + // This includes install, uninstall, update, backup, and any other action buttons + const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); + if (!appContent) { + console.warn('⚠️ App content section not found'); + return; + } + + // Disable ALL buttons in the app content section + const allButtons = appContent.querySelectorAll('button:not([disabled]):not(.tab-button)'); + // console.log('🚫 disableAppButtons found %d buttons to disable', allButtons.length); + + allButtons.forEach(button => { + // Skip tab buttons (config, backup, tasks tabs) + if (button.classList.contains('tab-button')) { + return; + } + // Logs toggle buttons stay clickable while a task runs — viewing + // log output is read-only and the whole point during a long task. + if (button.dataset.action === 'toggle-logs' || + button.classList.contains('service-logs') || + button.classList.contains('toggle-details')) { + return; + } + + // Hide download buttons permanently as they're not needed + if (button.textContent && button.textContent.toLowerCase().includes('download')) { + button.style.display = 'none'; + return; + } + + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner to buttons that don't already have one + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Replace entire content with spinner + text (no icons) + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + // console.log('🔃 Adding spinner to button:', button.textContent.trim(), 'for app:', appName); + button.innerHTML = ` + + ${textContent.trim()} + `; + } + }); + + // Track which buttons were disabled + allButtons.forEach(button => { + this.disabledButtons.add(button); + }); + + // console.log(`🔍 Disabled ${allButtons.length} buttons for ${appName} during ${action}`); + } + + // Restore button state when switching tabs. Only disable if the *current* app + // has a running task — without this check, switching tabs on app B while a task + // is running for app A would disable app B's buttons and tabs. + restoreButtonState() { + const running = this.getRunningTaskForApp(this.currentApp); + if (running) { + this.activeTaskId = running.taskId; + this.disableAppButtons(this.currentApp, running.action); + } else { + this.enableTabs(); + this.enableAppButtons(this.currentApp); + } + } + + // SSE safety-net. The bus normally delivers terminal transitions in + // milliseconds; this re-syncs from the API if the bus has been disconnected. + async reconcileRunningTasks() { + if (this.runningTasks.size === 0) { + // Nothing tracked but the DOM still says disabled — that's a stale + // leftover from an earlier run whose `taskCompleted` we missed. Just + // force-enable so the user isn't stuck. + const configTab = document.querySelector('.main-tab-button[data-tab="config"], .tab-button[data-tab="config"]'); + if (configTab && (configTab.classList.contains('task-running') || configTab.disabled)) { + this.enableTabs(); + if (this.currentApp) this.enableAppButtons(this.currentApp); + } + return; + } + for (const info of Array.from(this.runningTasks.values())) { + let task = null; + if (window.taskEventBus) task = window.taskEventBus.getTask(info.taskId); + if (!task) { + try { + const res = await fetch(`/api/tasks/${info.taskId}`); + if (res.ok) task = await res.json(); + else if (res.status === 404) { + // Task file gone — fire one synthetic completion + stop polling + // so we don't loop forever on a deleted task. + this.runningTasks.delete(info.taskId); + window.dispatchEvent(new CustomEvent('taskCompleted', { + detail: { taskId: info.taskId, appName: info.appName, action: info.action, status: 'completed', task: null, timestamp: Date.now() } + })); + continue; + } + } catch { continue; } + } + if (!task) continue; + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + window.dispatchEvent(new CustomEvent('taskCompleted', { + detail: { taskId: task.id, appName: info.appName, action: info.action, status: task.status, task, timestamp: Date.now() } + })); + } + } + } + + restoreButton(button) { + if (!button) return; + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + button.querySelectorAll('.spinner').forEach(s => s.remove()); + } + + enableAppButtons(appName) { + this.enableTabs(); + + this.disabledButtons.forEach(button => this.restoreButton(button)); + this.disabledButtons.clear(); + + document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') + .forEach(button => this.restoreButton(button)); + + const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); + if (appContent) { + appContent.querySelectorAll('button.disabled, button[disabled]:not(.tab-button)') + .forEach(button => this.restoreButton(button)); + } + } +} + +// Export for use +window.AppTabbedManager = AppTabbedManager; + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', async () => { + // console.log('🔍 DOMContentLoaded: Skipping automatic initialization - SPA will handle it'); + // Don't initialize here - let SPA handle it +}); + +// Also initialize when scripts are loaded (for SPA navigation) +window.addEventListener('load', async () => { + // console.log('🔍 Window load: Skipping automatic initialization - SPA will handle it'); + // Don't initialize here - let SPA handle it +}); diff --git a/containers/libreportal/frontend/js/components/app/apps-manager.js b/containers/libreportal/frontend/js/components/app/apps-manager.js new file mode 100755 index 0000000..e87f6bd --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/apps-manager.js @@ -0,0 +1,4073 @@ +// Single source of truth for "given an apps-services.json entry, what +// clickable links should we render for it?" Returns an array of +// { url, label } pairs. +// +// New schema: each service entry carries a `links: [{label, externalURL, +// internalURL}, ...]` array (built from the comma-separated label/path +// pairs in CFG__PORT_N cols 9 and 10). +// +// Legacy fallback: if `links` is missing, build a single entry from +// externalURL (or serverIP+externalPort) + buttonText, matching the +// original single-button behaviour for snapshots that pre-date the +// multi-button generator. +// +// Used by every UI surface that renders Open buttons: +// - dashboard.js (dashboard app-card hover overlay) +// - apps-manager.js (apps list popup + app-header buttons) +// - services-manager.js (Services tab Open buttons) +window.expandServiceLinks = function(s) { + const proto = ['http', 'https'].includes((s.protocol || '').toLowerCase()) + ? s.protocol.toLowerCase() + : 'http'; + if (Array.isArray(s.links) && s.links.length > 0) { + return s.links + .map(l => ({ + url: l.externalURL || l.internalURL, + label: l.label || s.buttonText || s.name + })) + .filter(l => !!l.url); + } + const fallbackUrl = s.externalURL || + (s.serverIP + ? `${proto}://${s.serverIP}:${s.externalPort}` + : `${proto}://localhost:${s.externalPort}`); + return fallbackUrl ? [{ url: fallbackUrl, label: s.buttonText || s.name }] : []; +}; + +// Apps Manager - Manages the apps list page and app details +class AppsManager { + constructor() { + this.cache = new Map(); + this.setupTaskCompletionListener(); + } + + setupTaskCompletionListener() { + // Listen for task completion events to reload apps data + window.addEventListener('taskCompleted', async (event) => { + const { action, appName, status } = event.detail; + + // Tool tasks mutate per-app config — refresh cache silently for next read. + if (action === 'tool' && status === 'completed') { + this.clearCache(); + await this.reloadAppsData(); + // If the user is viewing this app's detail page, re-render the + // config section in place so updated CFG_* values (e.g. a freshly + // reset password) show without needing a page refresh. Don't + // switch tabs — they may be reading the tool's task log. + const url = new URL(window.location.href); + const currentAppFromUrl = url.searchParams.get('app') || url.searchParams.get(''); + const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/'); + if (onAppDetail && appName && currentAppFromUrl === appName) { + this.displayConfigForm?.((window.apps || []).find(a => + (a.command || '').endsWith(' ' + appName) + )); + } + return; + } + + // First-install welcome modal — only on the very first successful install per app per browser. + if (action === 'install' && status === 'completed' && appName) { + const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`; + try { + if (!localStorage.getItem(key)) { + setTimeout(() => this.showInstallWelcome(appName), 600); + } + } catch (_) {} + } + + // Only reload on successful install or uninstall + if ((action === 'install' || action === 'uninstall') && status === 'completed') { + // Skip duplicate events for the same task id — the reconcile + // loop can synthesise a 404-fallback completed event after we've + // already handled the real one, which would re-trigger the + // heavy re-render + tab switch and visually flash the page. + const _taskId = event.detail.taskId || event.detail.id; + this._handledTaskIds = this._handledTaskIds || new Set(); + if (_taskId && this._handledTaskIds.has(_taskId)) return; + if (_taskId) this._handledTaskIds.add(_taskId); + + try { + // If this uninstall asked to delete its own task, do it now — + // the bash side skipped the in-flight task on purpose to + // avoid racing with the processor's final status write. + const taskId = event.detail.taskId || event.detail.id; + if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) { + this._pendingTaskCleanup.delete(taskId); + try { + if (window.tasksManager?.taskManager?.deleteTask) { + await window.tasksManager.taskManager.deleteTask(taskId, { force: true }); + } + // Re-render Tasks tab so the empty state ("No tasks found for X") shows. + if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks(); + if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks(); + // If the user is parked on the Tasks tab and now there's + // nothing to look at, bounce them to Config. + if (window.appTabbedManager?.currentTab === 'tasks') { + window.appTabbedManager.switchTab('config'); + } + } catch (e) { console.error('post-uninstall task cleanup failed:', e); } + } + + this.clearCache(); + await this.reloadAppsData(); + if (window.serviceButtons) { + try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); } + } + + const currentUrl = new URL(window.location.href); + const currentAppFromUrl = currentUrl.searchParams.get('app') || currentUrl.searchParams.get(''); + const pathname = window.location.pathname; + const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/'); + const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/'); + + if (isAppsPage && !isAppDetailPage) { + const category = window.appsCategory || 'all'; + this.renderApps(category); + } else if (isAppDetailPage && currentAppFromUrl === appName) { + // Defer + isolate the heavy re-render so a throw inside + // displayConfigForm / port-manager init can't lock up the + // post-task UI cleanup. Fires on the next tick — gives the + // task spinners + button enables a chance to repaint first. + setTimeout(() => { + // _skipReload flag tells renderAppDetail not to re-fetch + // apps.json again (we already just did, line above). + this.renderAppDetail(appName, null, true, { skipReload: true }) + .catch(err => console.error('renderAppDetail failed:', err)); + }, 0); + } + + // After uninstall, bounce off the Tasks tab — there's nothing + // to watch any more. Mark the app as "recently uninstalled" + // so the 5s watchForTaskCreation poll doesn't bounce back. + if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) { + window.appTabbedManager = window.appTabbedManager || null; + if (window.appTabbedManager) { + window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map(); + window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000); + setTimeout(() => { + if (window.appTabbedManager?.currentTab === 'tasks') { + window.appTabbedManager.switchTab('config'); + } + }, 50); + } + } + + if (typeof window.renderInstalledApps === 'function') { + window.renderInstalledApps(); + } + } catch (err) { + console.error('Post-task handler failed for', action, appName, ':', err); + } + } + }); + } + + clearCache() { + this.cache.clear(); + console.log('🗑️ Apps cache cleared'); + } + + async reloadAppsData() { + try { + // Reload global apps data + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (response.ok) { + const appsData = await response.json(); + window.apps = appsData.apps || []; + // console.log(`✅ Reloaded ${window.apps.length} apps`); + } + } catch (error) { + console.error('❌ Failed to reload apps data:', error); + } + } + + async loadApps(category = 'all') { + // Check cache first + if (this.cache.has(category)) { + return this.cache.get(category); + } + + try { + // Load apps data directly + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to load apps.json: ${response.status}`); + } + + const appsData = await response.json(); + + // Filter apps by category + let filteredApps = appsData.apps || []; + + if (category === 'installed') { + filteredApps = filteredApps.filter(app => app.installed); + } else if (category !== 'all') { + // Apps may live in multiple categories (e.g. "Security,Recommended" + // in their .config). apps.json emits BOTH `categories[]` and a + // singular `category` for back-compat; prefer the array. + filteredApps = filteredApps.filter(app => { + if (Array.isArray(app.categories)) return app.categories.includes(category); + return app.category === category; + }); + } + + // Sort installed apps first + filteredApps.sort((a, b) => { + if (a.installed && !b.installed) return -1; + if (!a.installed && b.installed) return 1; + return 0; + }); + + // Cache the result + this.cache.set(category, filteredApps); + + return filteredApps; + } catch (error) { + console.error(`AppsManager: Error loading ${category} apps:`, error); + return []; + } + } + + async loadCategories() { + try { + const response = await fetch('/data/apps/apps-categories.json'); + if (!response.ok) { + throw new Error(`Failed to load apps-categories.json: ${response.status}`); + } + + const categoriesData = await response.json(); + + return categoriesData.categories || []; + } catch (error) { + console.error('AppsManager: Error loading categories:', error); + return []; + } + } + + async initialize() { + // Don't load data here - SPA handles it + // Just setup page based on URL + const path = window.location.pathname; + const searchParams = new URLSearchParams(window.location.search); + + if (path === '/app' || path.startsWith('/app/') || searchParams.has('app')) { + const appName = searchParams.get('app') || window.appName || ''; + this.showAppDetail(appName); + } else { + // Use the category parsed by SPA + let category = window.appsCategory || 'all'; + this.showAppsList(category); + } + } + + showAppsList(category) { + this.currentView = 'apps'; + this.currentApp = null; + + // Update URL only for specific categories, not for 'all' + if (category && category !== 'all') { + history.pushState({}, '', `/apps?=${category}`); + } + // For 'all' category, keep URL as /apps to avoid redirect loops + + // Switch to apps view + this.showView('apps'); + + // Render apps + this.renderApps(category); + + // Setup sidebar + this.setupSidebar(category); + } + + showAppDetail(appName, forceConfigTab = false) { + // console.log('🔍 showAppDetail called with:', { appName, forceConfigTab }); + //// // console.log(`AppsManager: Showing app detail: ${appName}`); + + // Don't proceed if appName is empty - redirect to apps list instead + if (!appName || appName.trim() === '') { + //// // console.log('AppsManager: Empty app name, redirecting to apps list'); + this.showAppsList('all'); + return; + } + + // Check if app has changed - only re-render header if app changed + const appChanged = this.currentApp !== appName; + + // Set current view first + this.currentView = 'app-detail'; + this.currentApp = appName; + + // Update URL to reflect current state + let targetTab; + if (forceConfigTab) { + // Force config tab for install/manage buttons + targetTab = 'config'; + // console.log('🔍 Forcing config tab due to forceConfigTab=true'); + } else { + // Preserve existing tab or default to config for direct navigation + const currentUrl = new URL(window.location.href); + targetTab = currentUrl.searchParams.get('tab') || 'config'; + // console.log('🔍 Preserving existing tab:', targetTab); + } + + const newUrl = `/app?=${appName}&tab=${targetTab}`; + // console.log('🔍 Setting URL to:', newUrl); + history.pushState({}, '', newUrl); + + // Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or + // any code it triggers calls switchTab → loadTabContent → restoreButtonState, + // we need this.currentApp to already be updated so restoreButtonState checks + // the right app for running tasks. + if (window.appTabbedManager) { + if (typeof window.appTabbedManager.setCurrentApp === 'function') { + window.appTabbedManager.setCurrentApp(appName); + } else { + window.appTabbedManager.currentApp = appName; + } + } + + // Switch to app detail view + this.showView('app-detail'); + + // Render app detail (async) - only re-render header if app changed + this.renderAppDetail(appName, null, appChanged); + + // Find app category and setup sidebar + const app = window.apps?.find(a => + a.name === appName || + a.id === appName || + a.slug === appName + ); + + if (app && app.category) { + this.setupSidebar(app.category); + } else { + this.setupSidebar('all'); + } + + const appCategory = app ? app.category : 'all'; + //// // console.log(`🎯 App category: "${appCategory}"`); + this.setupSidebar(appCategory); + } + + // Show app detail with config tab (for install/manage buttons) + showAppDetailWithConfig(appName) { + // console.log('🔍 showAppDetailWithConfig called with:', appName); + // console.log('🔍 Forcing config tab for button click'); + + // Check if there's a running task for this app — switch straight to the tasks + // tab if so, instead of landing on config (whose buttons would be disabled). + let targetTab = 'config'; + let runningTaskId = null; + if (window.appTabbedManager && typeof window.appTabbedManager.getRunningTaskForApp === 'function') { + const running = window.appTabbedManager.getRunningTaskForApp(appName); + if (running) { + targetTab = 'tasks'; + runningTaskId = running.taskId; + } + } + + // Set URL to target tab (config or tasks) + const newUrl = `/app?=${appName}&tab=${targetTab}`; + history.pushState({}, '', newUrl); + + // Update app-tabbed-manager. setCurrentApp clears stale disable state from + // whichever app the user came from before showing the new app's content. + if (window.appTabbedManager) { + if (typeof window.appTabbedManager.setCurrentApp === 'function') { + window.appTabbedManager.setCurrentApp(appName); + } else { + window.appTabbedManager.currentApp = appName; + } + + // Simulate clicking target tab functionally + // console.log('🔄 Simulating target tab click:', targetTab); + setTimeout(() => { + window.appTabbedManager.switchTab(targetTab); + // Highlight the running task if switching to tasks tab + if (targetTab === 'tasks' && runningTaskId && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = runningTaskId; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 100); // Small delay to ensure app is loaded + } + + // Continue with normal app detail loading + this.showAppDetail(appName, true); + } + + showView(viewType) { + // Get both view containers + const appsView = document.getElementById('apps-view'); + const appDetailView = document.getElementById('app-detail-view'); + + if (viewType === 'apps') { + // Show apps view, hide app detail view + if (appsView) appsView.style.display = 'block'; + if (appDetailView) appDetailView.style.display = 'none'; + } else if (viewType === 'app-detail') { + // Show app detail view, hide apps view + if (appsView) appsView.style.display = 'none'; + if (appDetailView) appDetailView.style.display = 'block'; + } + } + + setupSidebar(activeCategory = 'all') { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + + // Clear sidebar + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + container.innerHTML = ''; + + // Hide loading + const loading = document.querySelector('.loading-categories'); + if (loading) loading.style.display = 'none'; + + // Add categories + this.addCategory('All', 'all'); + this.addCategory('Installed', 'installed'); + + // Add dynamic categories + if (window.sidebarCategories) { + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value })); + + categoriesArray.forEach(cat => { + this.addCategory(cat.name, cat.id, cat.icon); + }); + } + + // Add back button for app detail + if (this.currentView === 'app-detail') { + this.addBackButton(); + } + + // Set active category + this.setActiveCategory(activeCategory); + } + + addCategory(name, id, icon) { + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + const div = document.createElement('div'); + div.className = 'category'; + div.setAttribute('data-category', id); + + let iconHtml; + if (!icon && id === 'all') { + iconHtml = ''; + } else if (!icon && id === 'installed') { + iconHtml = ''; + } else { + let iconPath = icon || `/icons/categories/${id}.svg`; + if (!iconPath.startsWith('/')) iconPath = '/' + iconPath; + iconHtml = `${name}`; + } + + div.innerHTML = `${iconHtml} ${name}`; + + div.addEventListener('click', (e) => { + e.preventDefault(); + this.switchCategory(id); + }); + + container.appendChild(div); + } + + addBackButton() { + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + const div = document.createElement('div'); + div.className = 'category'; + div.innerHTML = '← Back to Apps'; + div.addEventListener('click', () => { + this.showAppsList('all'); + }); + + container.appendChild(div); + } + + setActiveCategory(categoryId) { + document.querySelectorAll('.category').forEach(cat => { + cat.classList.remove('active'); + }); + + const active = document.querySelector(`[data-category="${categoryId}"]`); + if (active) active.classList.add('active'); + } + + switchCategory(categoryId) { + //// // console.log(`AppsManager: Switching to category: ${categoryId}`); + + // Update URL to reflect current state + if (categoryId === 'all') { + history.pushState({}, '', '/apps'); + } else { + history.pushState({}, '', `/apps?=${categoryId}`); + } + + // Direct view update without URL change to avoid conflicts + this.currentView = 'apps'; + this.currentApp = null; + + // Switch to apps view + this.showView('apps'); + + // Render apps for new category + this.renderApps(categoryId); + + // Update sidebar for new category + this.setupSidebar(categoryId); + } + + renderApps(category) { + const container = document.getElementById('apps-section'); + if (!container) return; + + container.innerHTML = ''; + + // Load and render apps + this.loadApps(category).then(apps => { + apps.forEach(app => { + const card = this.createAppCard(app); + container.appendChild(card); + }); + this.populateInlineServiceButtons(); + // Re-apply any active sidebar search so changing category + // doesn't reveal apps that should be filtered out. + if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery); + }).catch(error => { + console.error('Error rendering apps:', error); + }); + } + + // Client-side substring filter wired to the sidebar search box. + // Cards carry data-search (built in createAppCard) so this stays + // a single querySelectorAll + display toggle. + filterAppsByQuery(query) { + const q = (query || '').trim().toLowerCase(); + this.appsSearchQuery = q; + const wrap = document.querySelector('.apps-search'); + if (wrap) wrap.classList.toggle('has-value', !!q); + + const cards = document.querySelectorAll('#apps-section .app-card'); + cards.forEach(card => { + const hay = card.dataset.search || ''; + card.style.display = (!q || hay.includes(q)) ? '' : 'none'; + }); + } + + clearAppsSearch() { + const input = document.getElementById('apps-search-input'); + if (input) input.value = ''; + this.filterAppsByQuery(''); + if (input) input.focus(); + } + + async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) { + //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`); + //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command }))); + + // Use global preferred category if not provided + if (!preferredCategory && window.preferredConfigCategory) { + preferredCategory = window.preferredConfigCategory; + // console.log('🎯 Using global preferred category:', preferredCategory); + } + + const container = document.getElementById('app-detail-view'); + if (!container) return; + + // Don't clear the entire container - update specific sections + // This preserves the original console-section styling + + // Get current installed status from DOM before reloading + const serviceButtonsContainer = document.getElementById('service-buttons-container'); + const wasInstalled = serviceButtonsContainer !== null; + + // Reload fresh app data if app has changed (ensures installed status is current after uninstall). + // Caller can pass skipReload when they've just refreshed (post-task listener). + if (appChanged && !opts.skipReload) { + await this.reloadAppsData(); + } + + // Find app data + const app = window.apps?.find(a => + a.name === appName || + a.command === `libreportal app install ${appName}` || + a.command.endsWith(` ${appName}`) + ); + //// // console.log(`🎯 Found app in renderAppDetail:`, app); + if (!app) { + container.innerHTML = '
App not found
'; + return; + } + + // Check if installed status changed (e.g., after uninstall) and force header re-render + const isNowInstalled = app.installed; + const installedStatusChanged = wasInstalled !== isNowInstalled; + const shouldRenderHeader = appChanged || installedStatusChanged; + //// // console.log(`🔍 Installed status: was=${wasInstalled}, now=${isNowInstalled}, changed=${installedStatusChanged}`); + + // Get app details - extract only the app name (before any dash) + const shortName = app.name.split(' - ')[0].trim(); // Only take text before the first dash + const cleanAppName = app.command.split(' ').pop(); + + document.title = `${shortName} - LibrePortal`; + + // Hide the Backups + Services tabs for not-yet-installed apps — there's + // nothing to back up and no docker compose services running yet. + const backupTab = document.querySelector('.main-tab-button[data-tab="backups"], .tab-button[data-tab="backups"]'); + const backupPane = document.getElementById('backups-tab'); + if (backupTab) backupTab.style.display = isNowInstalled ? '' : 'none'; + if (backupPane && !isNowInstalled) backupPane.classList.remove('active'); + + const servicesTab = document.querySelector('.main-tab-button[data-tab="services"], .tab-button[data-tab="services"]'); + const servicesPane = document.getElementById('services-tab'); + if (servicesTab) servicesTab.style.display = isNowInstalled ? '' : 'none'; + if (servicesPane && !isNowInstalled) servicesPane.classList.remove('active'); + + // Tools tab: hide for not-installed AND for installed-but-has-no-tools + // apps. Without the second check, clicking the visible tab triggers + // app-tabbed-manager's "no tools → bounce to config" path which + // looks like a broken redirect. + const toolsTab = document.querySelector('.main-tab-button[data-tab="tools"], .tab-button[data-tab="tools"]'); + const toolsPane = document.getElementById('tools-tab'); + const catalogEntry = window.toolsCatalog?.apps?.[cleanAppName]; + const hasTools = Array.isArray(catalogEntry?.tools) && catalogEntry.tools.length > 0; + const showToolsTab = isNowInstalled && hasTools; + if (toolsTab) toolsTab.style.display = showToolsTab ? '' : 'none'; + if (toolsPane && !showToolsTab) toolsPane.classList.remove('active'); + + const onHiddenTab = + (!isNowInstalled && (window.appTabbedManager?.currentTab === 'backups' + || window.appTabbedManager?.currentTab === 'services' + || window.appTabbedManager?.currentTab === 'tools')) + || (window.appTabbedManager?.currentTab === 'tools' && !showToolsTab); + if (onHiddenTab) { + window.appTabbedManager.switchTab('config'); + } + let icon = app.icon || 'icons/apps/default.svg'; + + // Ensure absolute path from root + if (!icon.startsWith('/')) { + icon = '/' + icon; + } + + const status = app.installed ? 'Installed' : 'Not Installed'; + const categoryName = this.getCategoryName(app.category); + const categoryIcon = this.getCategoryIcon(app.category); + + // Create tags matching app center style + const installedTag = app.installed + ? `${status}` + : `${status}`; + const categoryTag = ` ${categoryName}`; + // Render app header section (always define, but only update DOM if app changed) + const headerHTML = ` +
+
+ ${shortName} +
+
+

${shortName}

+

${app.description || 'No description available'}

+ ${app.longDescription ? `

${app.longDescription}

` : ''} +
+ ${categoryTag} + ${installedTag} +
+
+
+ ${app.installed ? ` +
+ +
+ ` : ''} + `; + + // Render config section with working app-config-original.js approach + // Use the working displayConfigForm from app-config-original.js + await this.displayConfigForm(app, preferredCategory); + + const configHTML = document.getElementById('config-section')?.innerHTML || ''; + + // Initialize port managers after config form is rendered + setTimeout(async () => { + await this.initializePortManagers(); + if (typeof ConfigOptions !== 'undefined') { + ConfigOptions.refreshGluetunCredentialVisibility?.(); + } + this.wireShowWhenListeners(); + this.wireConfigDirtyTracking(cleanAppName); + // Only update service buttons if app has changed or installed status changed + if (shouldRenderHeader) { + this.updateServiceButtonsSidebar(app, cleanAppName); + } + }, 100); + + // Render console section with original styling + const consoleHTML = ` +
+
+

Installation Console

+

Monitor the installation and configuration process

+
+ +
+
+ [${new Date().toLocaleTimeString()}] + Ready to install ${app.name} +
+
+ +
+ + +
+
+ `; + + // Update specific sections instead of overwriting entire container + // This preserves the original console-section styling + + // Only update app header if app has changed or installed status changed + if (shouldRenderHeader) { + const appHeader = document.getElementById('app-header'); + //// // console.log('app-header element found:', !!appHeader); + if (appHeader) { + appHeader.innerHTML = headerHTML; + // Explicitly remove service-buttons-container if app is not installed (prevents space) + if (!app.installed) { + const serviceButtonsContainer = document.getElementById('service-buttons-container'); + if (serviceButtonsContainer) { + serviceButtonsContainer.remove(); + } + } + //// // console.log('App header updated successfully'); + } + } + + // Update config section + const configSection = document.getElementById('config-section'); + //// // console.log('config-section element found:', !!configSection); + if (configSection) { + configSection.innerHTML = configHTML; + //// // console.log('Config section updated successfully, innerHTML length:', configHTML.length); + } else { + console.error('config-section element not found in DOM'); + } + + // Update console section (preserve original structure) + const consoleSection = container.querySelector('.console-section'); + if (consoleSection) { + // Update console output within the existing console-section + const messageLog = consoleSection.querySelector('#message-log'); + + if (messageLog) { + messageLog.innerHTML = ` +
+ [${new Date().toLocaleTimeString()}] + Ready to install ${app.name} +
+ `; + } + } + + // Initialize console to start at top + this.initializeConsole(); + + // Load actual app configuration if available + this.loadAppConfig(cleanAppName); + } + + async loadAppConfig(appName) { + //// // console.log(`AppsManager: Loading config for ${appName}...`); + + try { + // Get app data from global apps array (like original app-config-original.js) + const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + + if (appData && appData.config) { + //// // console.log(`AppsManager: Loaded config for ${appName}:`, appData.config); + + // Update form with actual configuration values from app.config + this.updateConfigForm(appName, appData.config); + } else { + //// // console.log(`AppsManager: No config found for ${appName}, showing default configuration`); + + // Show default configuration when no config exists + this.updateConfigForm(appName, { + CFG_APP_NAME: appData?.name || appName, + CFG_VERSION: appData?.version || '1.0.0', + CFG_PORT: '8080', + CFG_DOMAIN: '', + CFG_USERNAME: 'admin', + CFG_PASSWORD: '', + CFG_DEBUG: 'false', + CFG_LOG_LEVEL: 'INFO' + }); + } + } catch (error) { + //// // console.log(`AppsManager: Error loading config for ${appName}:`, error); + + // Get app data for defaults + const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + + // Show default configuration on error + this.updateConfigForm(appName, { + CFG_APP_NAME: appData?.name || appName, + CFG_VERSION: appData?.version || '1.0.0', + CFG_PORT: '8080', + CFG_DOMAIN: '', + CFG_USERNAME: 'admin', + CFG_PASSWORD: '', + CFG_DEBUG: 'false', + CFG_LOG_LEVEL: 'INFO' + }); + } + } + + updateConfigForm(appName, appConfig) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + const appData = window.apps?.find(a => a.name === appName || a.command?.includes(appName)); + + Object.entries(appConfig).forEach(([key, value]) => { + const field = form.querySelector(`[name="${key}"]`); + if (!field) return; + let nextValue = value; + if (key.endsWith('_NETWORK')) { + nextValue = this.applyContextualDefault('NETWORK', value, appData); + } + if (field.type === 'checkbox') { + field.checked = nextValue === 'true' || nextValue === 'yes'; + } else { + field.value = nextValue; + } + }); + } + + // Display configuration form (working method from app-config-original.js) + async displayConfigForm(appData, preferredCategory = null) { + //// // console.log('displayConfigForm called with:', appData, 'preferredCategory:', preferredCategory); + const configSection = document.getElementById('config-section'); + if (!configSection) { + return; + } + + const cleanAppName = appData.command.split(' ').pop(); + + const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE')); + const requiredService = requiresKey ? (appData.config[requiresKey] || '').trim() : ''; + if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) { + const slug = requiredService.toLowerCase(); + const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1); + const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`; + configSection.innerHTML = ` +
+

🛠️ Configuration Settings

+

Configure ${this.escHtml(appData.name)} to match your requirements

+
+
+ +
+
${this.escHtml(serviceLabel)} required
+
${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.
+
+ +
+ `; + return; + } + + //// // console.log('Setting config form HTML for:', appData.name); + + // Generate simple tabbed interface with preferred category + //// // console.log('🎨 Generating config HTML with working approach...'); + const tabsContent = await this.generateSimpleTabsAndContent(appData, preferredCategory); + + const configHTML = ` +
+

🛠️ Configuration Settings

+

Configure ${appData.name} to match your requirements

+
+ +
+
+
+
+ ${tabsContent.tabsHTML} +
+ +
+ ${tabsContent.contentHTML} +
+
+
+ +
+ + + + + ${appData.installed && cleanAppName !== 'libreportal' ? ` + + ` : ''} + + ${appData.installed && cleanAppName === 'gluetun' ? ` + + ` : ''} +
+
+ `; + + configSection.innerHTML = configHTML; + + // Initialize tab functionality + this.initializeSimpleTabs(); + + // Enhance scrollbar dynamically + this.enhanceTabsScrollbar(); + + //// // console.log('Config form HTML set successfully'); + } + + // Generate simple tabs and content together (clean, reliable approach) + async generateSimpleTabsAndContent(appData, preferredCategory = null) { + //// // console.log('🏷️📄 generateSimpleTabsAndContent called'); + const categories = await this.getConfigCategories(); + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + + //// // console.log(`🏷️ Config categories loaded:`, categories); + //// // console.log(`🏷️ Field mappings loaded:`, fieldMappings); + //// // console.log(`🏷️ App config:`, appConfig); + //// // console.log(`🏷️ Preferred category:`, preferredCategory); + + //// // console.log('📂 Available categories:', Object.keys(categories)); + //// // console.log('🗂️ Available field mappings:', Object.keys(fieldMappings)); + //// // console.log('🔍 Looking for PORT_MANAGER in field mappings:', 'PORT_MANAGER' in fieldMappings); + if ('PORT_MANAGER' in fieldMappings) { + //// // console.log('🔍 PORT_MANAGER field config:', fieldMappings['PORT_MANAGER']); + } + + let tabsHTML = ''; + let contentHTML = ''; + let firstTab = null; + + // Sort categories by order + const sortedCategories = Object.entries(categories) + .sort(([,a], [,b]) => a.order - b.order); + + // Find first tab with fields + for (const [key, category] of sortedCategories) { + + const hasFields = Object.entries(fieldMappings).some(([fieldKey, fieldConfig]) => { + if (fieldConfig.category === key) { + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + return cfgKey && appConfig.hasOwnProperty(cfgKey); + } + return false; + }); + + if (hasFields) { + firstTab = key; + break; + } + } + + // Use preferred category if available and valid, otherwise use firstTab + const activeTab = preferredCategory && categories[preferredCategory] ? preferredCategory : firstTab; + + // Generate tabs and content together + for (const [key, category] of sortedCategories) { + const hasFields = Object.entries(fieldMappings).some(([fieldKey, fieldConfig]) => { + if (fieldConfig.category === key) { + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + return cfgKey && appConfig.hasOwnProperty(cfgKey); + } + return false; + }); + + if (hasFields) { + const isActive = key === activeTab ? 'active' : ''; + + // Generate tab button + tabsHTML += ` + + `; + + // Generate content panel + //// // console.log(`🔧 Generating content for category: ${key}`); + const categoryContent = await this.generateConfigFields(key, appData); + + contentHTML += ` +
+
+

${category.icon} ${category.name}

+

${category.description}

+
+
+ ${categoryContent} +
+
+ `; + } + } + + //// // console.log('✅ Tabs and content generated successfully'); + return { tabsHTML, contentHTML }; + } + + // Initialize tab functionality + initializeSimpleTabs() { + //// // console.log('Simple tabs initialized'); + } + + // Generate simple fields (working method from app-config-original.js) + async generateSimpleFields(categoryKey, appData) { + //// // console.log(`🔧 Generating fields for category: ${categoryKey}`); + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + let fieldsHTML = ''; + let hiddenFieldsHTML = ''; + + // Find fields that belong to this category + for (const [fieldKey, fieldConfig] of Object.entries(fieldMappings)) { + if (fieldConfig.category === categoryKey) { + //// // console.log(`🔧 Processing field: ${fieldKey} with config:`, fieldConfig); + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + + // Skip generic mappings when a longer/more-specific one binds to the same cfgKey. + if (cfgKey) { + const moreSpecific = Object.keys(fieldMappings).some(otherKey => + otherKey !== fieldKey + && otherKey.length > fieldKey.length + && this.findMatchingCFGKey(otherKey, appConfig) === cfgKey + ); + if (moreSpecific) continue; + } + //// // console.log(`🔧 Found CFG key: ${cfgKey} with value:`, appConfig[cfgKey]); + + // Special debug for PORT_1 + if (fieldKey === 'PORT_1') { + //// // console.log(`🔧 PORT_1 DEBUG: fieldKey=${fieldKey}, cfgKey=${cfgKey}, hasValue=${!!appConfig[cfgKey]}, value="${appConfig[cfgKey]}"`); + //// // console.log(`🔧 PORT_1 DEBUG: All app config keys:`, Object.keys(appConfig)); + } + + // For advanced tab, only show advanced fields + if (categoryKey === 'advanced' && !fieldConfig.advanced) { + continue; // Skip non-advanced fields in advanced tab + } + + // For regular tabs, skip advanced fields + if (categoryKey !== 'advanced' && fieldConfig.advanced) { + continue; // Skip advanced fields in regular tabs + } + + // Skip fields gated by category allowlist when this app's category + // isn't in the list AND the override requirement isn't enabled. + if (Array.isArray(fieldConfig.categoryAllowlist)) { + const appCategory = String(appData?.category || '').toLowerCase(); + const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); + const override = fieldConfig.requirementOverride + ? this.checkRequirementEnabled(fieldConfig.requirementOverride) + : false; + if (!inList && !override) continue; + } + + // Generic requiresService gating from field-mapping JSON. + if (fieldConfig.requiresService) { + if (!this.checkServiceInstalled(fieldConfig.requiresService)) { + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`); + continue; + } + } + + // Gate fields that depend on the global mail config being on. + // Used for per-app email-notification toggles so a user can't + // enable Email here without configuring SMTP under General first. + if (fieldConfig.requiresGlobalMail) { + const mailEnabled = await this.isGlobalMailEnabled(); + if (!mailEnabled) { + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || 'Configure mail in General settings first (CFG_MAIL_ENABLED=true).'); + continue; + } + } + + // Check conditional requirements for certain fields + if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { + let serviceName; + let isServiceInstalled; + let disabledReason; + + if (fieldKey === 'AUTHELIA') { + serviceName = 'authelia'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Authelia needs to be installed'; + } else if (fieldKey === 'HEADSCALE') { + serviceName = 'headscale'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Headscale needs to be installed'; + } else if (fieldKey === 'WHITELIST') { + serviceName = 'traefik'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Traefik needs to be installed.'; + } + + if (!isServiceInstalled) { + // Force off-state so a stored "true" can't render checked when the dep is missing. + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason); + continue; + } + } + + // Get current value or use default + let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); + const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); + if (fieldConfig.hideByDefault) { + hiddenFieldsHTML += fieldHTML; + } else { + fieldsHTML += fieldHTML; + } + } + } + + if (hiddenFieldsHTML) { + fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); + } + + if (!fieldsHTML) { + fieldsHTML = '
No configuration options available for this category.
'; + } + + return fieldsHTML; + } + + // Wrap each hidden field as a direct grid sibling tagged .advanced-field so + // they participate in the parent .panel-fields grid (continuing on the right + // of the toggle) rather than reflowing into a nested grid. + applyContextualDefault(fieldKey, value, appData) { + if (fieldKey === 'NETWORK' + && !appData?.installed + && (value === 'default' || value === '') + && this.checkServiceInstalled('gluetun')) { + return 'gluetun'; + } + return value; + } + + renderAdvancedToggleAndFields(hiddenFieldsHTML) { + const tagged = hiddenFieldsHTML.replace(/
+ + + Reveal less-common configuration options for power users. +
+ ${tagged} + `; + } + + // Generate field (working method from app-config-original.js) + async generateField(fieldKey, cfgKey, value, fieldConfig) { + const fieldId = fieldKey; // Use fieldKey to ensure unique IDs + const required = fieldConfig.required ? '*' : ''; + const helpIcon = fieldConfig.tooltip ? `?` : ''; + + let inputHTML = ''; + + // Special handling for DOMAIN fields - show domain dropdown + if (fieldKey === 'DOMAIN') { + //// // console.log('🎯 DOMAIN field detected, generating dropdown...'); + try { + const domainOptions = await this.getDomainOptions(); + //// // console.log('📊 Domain options received:', domainOptions); + let optionsHTML = ''; + + domainOptions.forEach(option => { + const isSelected = option.value === value.toString() ? 'selected' : ''; + optionsHTML += ``; + }); + + inputHTML = ``; + //// // console.log('✅ Domain dropdown generated successfully'); + } catch (error) { + console.error('❌ Error loading domain options, falling back to number input:', error); + // Fallback to regular number input if domain loading fails + inputHTML = ``; + } + } else if (fieldKey === 'GLUETUN_VPN_COUNTRIES') { + const selected = (typeof value === 'string' ? value : '').split(',').map(s => s.trim()).filter(Boolean); + const chips = selected.length + ? selected.map(c => `${this.countryFlagEmoji(c)}${c}`).join('') + : `Any`; + inputHTML = ` +
+
${chips}
+ + +
`; + } else { + // Regular field handling for all other types + // Auto-detect PORT fields and use port manager + if (fieldKey.startsWith('PORT_') || fieldConfig.type === 'port-manager') { + // Special handling for port manager - will be initialized after DOM is ready + //// // console.log(`🔌 Creating port-manager field: ${fieldKey} for app: ${this.getCurrentAppName()}`); + inputHTML = `
Loading port manager...
`; + } else { + switch (fieldConfig.type) { + case 'text': + inputHTML = ``; + break; + case 'password': { + const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value); + if (randomMatch) { + const placeholderToken = value; + inputHTML = ` +
+ +
+ + + +
+
`; + } else { + inputHTML = ` +
+ + +
`; + } + break; + } + case 'number': + inputHTML = ``; + break; + case 'select': + let optionsHTML = ''; + let selectOptions = fieldConfig.options; + if (!selectOptions && typeof ConfigOptions !== 'undefined' && ConfigOptions.isDropdownKey?.(cfgKey)) { + selectOptions = ConfigOptions.getSelectOptions(cfgKey); + } + // Fall back to default if stored value isn't in the option list. + let effectiveValue = value; + if (selectOptions && selectOptions.length > 0) { + const hasMatch = selectOptions.some(o => String(o.value) === String(value)); + if (!hasMatch) { + effectiveValue = (fieldConfig.default !== undefined && fieldConfig.default !== null) + ? fieldConfig.default + : selectOptions[0].value; + } + } + if (selectOptions) { + selectOptions.forEach(option => { + const isSelected = String(option.value) === String(effectiveValue) ? 'selected' : ''; + optionsHTML += ``; + }); + } + inputHTML = ``; + break; + case 'checkbox': + const isChecked = value === 'true' || value === true ? 'checked' : ''; + inputHTML = ` + + `; + break; + case 'textarea': + inputHTML = ``; + break; + default: + inputHTML = ``; + } + } + } + + // Generic conditional field: only render-visible when another field's + // current value matches. The post-render `wireShowWhenListeners` keeps + // visibility in sync as the watched field changes. Schema: + // showWhen: { "": "" } + // can be either a full CFG_ name or a bare suffix like + // "NOTIFY_EMAIL"; bare keys auto-resolve against the current field's + // app prefix so the same field-mapping is reusable across apps. + // For checkboxes the expected value is "true" or "false". + let showWhenAttrs = ''; + let showWhenStyle = ''; + if (fieldConfig.showWhen && typeof fieldConfig.showWhen === 'object') { + const entries = Object.entries(fieldConfig.showWhen); + if (entries.length > 0) { + let [watchKey, expected] = entries[0]; + // Auto-prefix bare keys with the current field's CFG__ prefix. + if (cfgKey && !String(watchKey).startsWith('CFG_')) { + const m = String(cfgKey).match(/^(CFG_[A-Z0-9]+_)/); + if (m) watchKey = `${m[1]}${watchKey}`; + } + const currentValue = this._readWatchedValue(watchKey); + const visible = String(currentValue) === String(expected); + showWhenAttrs = ` data-show-when-key="${watchKey}" data-show-when-equals="${String(expected)}"`; + if (!visible) showWhenStyle = ' style="display: none;"'; + } + } + + return ` +
+ + ${inputHTML} + ${fieldConfig.tooltip ? `${this.escHtml(fieldConfig.tooltip)}` : ''} +
+ `; + } + + // Best-effort lookup of a watched field's current value during render. + // Reads from the in-flight form (already-rendered fields above this one) + // OR from the cached app config so the initial visibility is right even + // for forward references. + _readWatchedValue(cfgKey) { + const live = document.querySelector(`[name="${cfgKey}"]`); + if (live) { + if (live.type === 'checkbox') return live.checked ? 'true' : 'false'; + return live.value; + } + const cached = this.currentAppConfig || {}; + if (Object.prototype.hasOwnProperty.call(cached, cfgKey)) { + const v = cached[cfgKey]; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + return String(v); + } + return ''; + } + + // Hook change events on every watched CFG_KEY and toggle dependent + // .form-field[data-show-when-key=...] elements when the watched value + // changes. Called after the config form is rendered. + wireShowWhenListeners() { + const dependents = document.querySelectorAll('.form-field[data-show-when-key]'); + if (dependents.length === 0) return; + + // Build a map: watchedKey -> [{element, expected}] + const watch = new Map(); + dependents.forEach((el) => { + const key = el.getAttribute('data-show-when-key'); + const expected = el.getAttribute('data-show-when-equals'); + if (!key) return; + if (!watch.has(key)) watch.set(key, []); + watch.get(key).push({ element: el, expected }); + }); + + const evalKey = (key) => { + const entry = watch.get(key); + if (!entry) return; + const input = document.querySelector(`[name="${key}"]`); + let val = ''; + if (input) { + val = input.type === 'checkbox' ? (input.checked ? 'true' : 'false') : input.value; + } + entry.forEach(({ element, expected }) => { + element.style.display = String(val) === String(expected) ? '' : 'none'; + }); + }; + + watch.forEach((_v, key) => { + const input = document.querySelector(`[name="${key}"]`); + if (!input || input.dataset.showWhenWired === '1') return; + input.dataset.showWhenWired = '1'; + input.addEventListener('change', () => evalKey(key)); + input.addEventListener('input', () => evalKey(key)); + // Run once on init so any forward-reference defaults reconcile. + evalKey(key); + }); + + // showWhen dependents render as the grid cell immediately after their + // controller (generateConfigFields reorders them there), so revealing one + // drops the input in the slot right next to its toggle. + } + + // Generate configuration field HTML (from old file - needed for tab content) + // Are any CFG_DOMAIN_N configured? The per-app DOMAIN field is just an + // index into that list, so showing it when the list is empty is noise. + // Refetched on every form render so changes on the config page are + // reflected the next time an app's config tab opens. + async hasConfiguredDomains() { + try { + const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); + if (!res.ok) return false; + const json = await res.json(); + const flat = JSON.stringify(json); + for (let i = 1; i <= 9; i++) { + const m = flat.match(new RegExp(`"CFG_DOMAIN_${i}"\\s*:\\s*\\{[^}]*"value"\\s*:\\s*"([^"]*)"`)); + if (m && m[1].trim()) return true; + } + return false; + } catch { return false; } + } + + // Returns true if the user has switched on the global mail config. + // Used by `requiresGlobalMail` field gating so per-app email-notification + // toggles can refuse to enable until SMTP is configured once globally. + async isGlobalMailEnabled() { + try { + const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); + if (!res.ok) return false; + const json = await res.json(); + const v = json?.config?.CFG_MAIL_ENABLED?.value; + return String(v).toLowerCase() === 'true'; + } catch { return false; } + } + + async generateConfigFields(categoryKey, appData) { + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + const domainsAvailable = await this.hasConfiguredDomains(); + let fieldsHTML = ''; + let hiddenFieldsHTML = ''; + + // Collect every field that belongs to this category. + const categoryFields = []; + Object.entries(fieldMappings).forEach(([fieldKey, fieldConfig]) => { + if (fieldConfig.category !== categoryKey) return; + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + + // Advanced fields only on the advanced tab, and vice versa. + if (categoryKey === 'advanced' && !fieldConfig.advanced) return; + if (categoryKey !== 'advanced' && fieldConfig.advanced) return; + + // Only show a field if this app actually has the CFG_ variable. + if (!cfgKey || !appConfig.hasOwnProperty(cfgKey)) return; + + // The DOMAIN selector is just an index into the domain list — hide it + // entirely when no CFG_DOMAIN_N is configured. + if (fieldKey === 'DOMAIN' && !domainsAvailable) return; + + // BACKUP gets priority -1 so "Enable Backups?" is always first; other + // inputs are 0, remaining checkboxes 1. + const isBackup = fieldKey === 'BACKUP'; + categoryFields.push({ + fieldKey, + fieldConfig, + cfgKey, + priority: isBackup ? -1 : (fieldConfig.type === 'checkbox' ? 1 : 0) + }); + }); + + categoryFields.sort((a, b) => a.priority - b.priority); + + // The sort above orders by type (inputs before checkboxes), which can + // separate a showWhen field from its controlling toggle. Reorder so each + // dependent sits immediately after its controller — then its conditional + // input reveals in the grid cell right next to the toggle. + const byCfgKey = new Map(categoryFields.map(f => [f.cfgKey, f])); + const resolveWatchKey = (entry) => { + const sw = entry.fieldConfig.showWhen; + if (!sw || typeof sw !== 'object') return null; + const swEntries = Object.entries(sw); + if (!swEntries.length) return null; + let [watchKey] = swEntries[0]; + if (!String(watchKey).startsWith('CFG_')) { + const m = String(entry.cfgKey).match(/^(CFG_[A-Z0-9]+_)/); + if (m) watchKey = `${m[1]}${watchKey}`; + } + return watchKey; + }; + + const ordered = []; + const placed = new Set(); + for (const entry of categoryFields) { + if (placed.has(entry.cfgKey)) continue; + // Dependents whose controller is in this category are placed alongside + // their controller below — skip them in this outer pass. + const watchKey = resolveWatchKey(entry); + if (watchKey && byCfgKey.has(watchKey)) continue; + + ordered.push(entry); + placed.add(entry.cfgKey); + for (const dep of categoryFields) { + if (placed.has(dep.cfgKey)) continue; + if (resolveWatchKey(dep) === entry.cfgKey) { + ordered.push(dep); + placed.add(dep.cfgKey); + } + } + } + // Safety net: anything still unplaced (e.g. a dependent whose controller + // lives in another category) keeps its original sorted position. + for (const entry of categoryFields) { + if (!placed.has(entry.cfgKey)) { ordered.push(entry); placed.add(entry.cfgKey); } + } + + for (const entry of ordered) { + const rendered = await this._renderCategoryField(entry, appData, appConfig); + if (!rendered) continue; + if (rendered.hidden) hiddenFieldsHTML += rendered.html; + else fieldsHTML += rendered.html; + } + + if (hiddenFieldsHTML) { + fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); + } + + if (!fieldsHTML) { + fieldsHTML = '
No configuration options available for this category.
'; + } + + return fieldsHTML; + } + + // Render a single collected category field: runs the dependency/service + // gating, then produces the .form-field HTML. Returns { html, hidden } or + // null when the field should be skipped entirely. + async _renderCategoryField(entry, appData, appConfig) { + const { fieldKey, fieldConfig, cfgKey } = entry; + + // Skip categoryAllowlist fields when this app's category isn't listed + // AND the override requirement isn't enabled. + if (Array.isArray(fieldConfig.categoryAllowlist)) { + const appCategory = String(appData?.category || '').toLowerCase(); + const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); + const override = fieldConfig.requirementOverride + ? this.checkRequirementEnabled(fieldConfig.requirementOverride) + : false; + if (!inList && !override) return null; + } + + // Generic requiresService gating from the field-mapping JSON. + if (fieldConfig.requiresService && !this.checkServiceInstalled(fieldConfig.requiresService)) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { + html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`), + hidden: false + }; + } + + // requiresServices: ALL listed services must be installed (e.g. the + // MONITORING toggle needs both prometheus and grafana). + if (Array.isArray(fieldConfig.requiresServices)) { + const missing = fieldConfig.requiresServices.filter(s => !this.checkServiceInstalled(s)); + if (missing.length) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { + html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${missing.join(' + ')} needs to be installed.`), + hidden: false + }; + } + } + + // Legacy hardcoded service checks for fields not yet migrated. + if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { + let serviceName, disabledReason; + if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; disabledReason = 'Authelia needs to be installed'; } + else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; disabledReason = 'Headscale needs to be installed'; } + else { serviceName = 'traefik'; disabledReason = 'Traefik needs to be installed.'; } + + if (!this.checkServiceInstalled(serviceName)) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason), hidden: false }; + } + } + + let fieldValue = appConfig[cfgKey] || (fieldConfig.default || ''); + fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); + const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); + return { html: fieldHTML, hidden: !!fieldConfig.hideByDefault }; + } + + // Generate configuration field HTML + generateConfigField(cfgKey, value, fieldConfig) { + const description = fieldConfig.description || ''; + let fieldHTML = ` +
+ + `; + + const type = fieldConfig.type || 'text'; + const options = fieldConfig.options; + + switch (type) { + case 'text': + fieldHTML += ``; + break; + case 'number': + fieldHTML += ``; + break; + case 'password': + fieldHTML += ` +
+ + +
`; + break; + case 'checkbox': + const checked = value === 'true' || value === 'yes' ? 'checked' : ''; + fieldHTML += ``; + break; + case 'select': + fieldHTML += ``; + break; + default: + fieldHTML += ``; + } + + fieldHTML += ` +
+ ${description ? `

${description}

` : ''} + + `; + + return fieldHTML; + } + + createAppCard(app) { + const card = document.createElement('div'); + card.className = 'app-card'; + if (app.installed) card.classList.add('installed'); + + // Searchable text for the sidebar search box. Combined name + + // description + long description + category, lowercased once here + // so filterAppsByQuery is a cheap substring match. + const searchHaystack = [ + app.name, + app.description, + app.longDescription, + app.category, + this.getCategoryName ? this.getCategoryName(app.category) : '' + ].filter(Boolean).join(' ').toLowerCase(); + card.dataset.search = searchHaystack; + + const appName = app.command.split(' ').pop(); + let icon = app.icon || 'icons/apps/default.svg'; + + // Ensure absolute path from root + if (!icon.startsWith('/')) { + icon = '/' + icon; + } + + const status = app.installed ? 'Installed' : 'Not Installed'; + + // Get category icon and name + const categoryIcon = this.getCategoryIcon(app.category); + const categoryName = this.getCategoryName(app.category); + + // Create rich tags like original + const descriptionTag = app.description ? ` ${app.description}` : ''; + const categoryTag = ` ${categoryName}`; + + // Format long description with period if missing + let formattedLongDescription = ''; + if (app.longDescription) { + formattedLongDescription = app.longDescription; + if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) { + formattedLongDescription += '.'; + } + } + + // Service trigger icon (only for installed apps - visibility controlled after services load) + const serviceTrigger = app.installed ? ` + ` : ''; + + card.innerHTML = ` +
+
+ ${app.name} +
+
+
${app.name.split(' - ')[0].trim()}
+
+ ${descriptionTag} + ${categoryTag} + ${status} +
+
+
+ ${formattedLongDescription ? `
${formattedLongDescription}
` : ''} +
+ + ${serviceTrigger} +
+ `; + + return card; + } + + // Populate inline service trigger popups for installed apps + async populateInlineServiceButtons() { + if (!window.serviceButtons) return; + + if (window.serviceButtons.services.length === 0) { + await window.serviceButtons.loadServices(); + } + + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + + const popupContents = document.querySelectorAll('[id^="service-popup-content-"]'); + for (const content of popupContents) { + const appName = content.id.replace('service-popup-content-', ''); + const trigger = document.getElementById(`service-trigger-${appName}`); + + const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true); + if (appServices.length === 0) continue; + + // Multi-button render via the shared expandServiceLinks() helper. + const buttons = appServices.flatMap(s => { + const protectedClass = s.loginRequired ? ' protected' : ''; + const lockIcon = s.loginRequired + ? `` + : ''; + return window.expandServiceLinks(s).map(({ url, label }) => ` + + + + + + + ${label} + ${lockIcon} + + `); + }).filter(Boolean).join(''); + + if (buttons) { + content.innerHTML = buttons; + if (trigger) trigger.style.display = ''; + } + } + } + + getCategoryIcon(categoryId) { + if (!categoryId || categoryId === 'all') return null; + + // Convert sidebar categories object to array with id field + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); + + // Find category in categories array (case-insensitive) + const category = categoriesArray.find(cat => + cat.id === categoryId.toLowerCase() || + cat.name.toLowerCase() === categoryId.toLowerCase() + ); + + let iconPath = category ? category.icon : `/icons/categories/${categoryId}.svg`; + + // Ensure absolute path from root + if (iconPath && !iconPath.startsWith('/')) { + iconPath = '/' + iconPath; + } + + return iconPath; + } + + getCategoryName(categoryId) { + //// // console.log(`🏷️ Getting category name for: ${categoryId}`); + //// // console.log(`🏷️ window.sidebarCategories type: ${typeof window.sidebarCategories}`, window.sidebarCategories); + + if (!categoryId || categoryId === 'all') return 'All'; + + // Check if sidebar categories is available + if (!window.sidebarCategories) { + console.warn(`🏷️ window.sidebarCategories is not available, returning categoryId: ${categoryId}`); + return categoryId; + } + + // Convert sidebar categories object to array with id field + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); + //// // console.log(`🏷️ Categories array:`, categoriesArray); + + // Find category in categories array (case-insensitive) + const category = categoriesArray.find(cat => + cat.id === categoryId.toLowerCase() || + cat.name.toLowerCase() === categoryId.toLowerCase() + ); + //// // console.log(`🏷️ Found category:`, category); + + return category ? category.name : categoryId; + } + + // Show tab (working method from app-config-original.js) + showTab(tabKey) { + // Hide all panels + const allPanels = document.querySelectorAll('.tab-panel'); + allPanels.forEach(panel => panel.classList.remove('active')); + + // Remove active from all config category tabs (not main navigation tabs) + const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button'); + allButtons.forEach(button => button.classList.remove('active')); + + // Show selected panel + const targetPanel = document.getElementById(`panel-${tabKey}`); + if (targetPanel) { + targetPanel.classList.add('active'); + } + + // Add active to clicked config category button + const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`); + if (targetButton) { + targetButton.classList.add('active'); + } + } + + // Initialize simple tabs (working method from app-config-original.js) + initializeSimpleTabs() { + //// // console.log('Simple tabs initialized'); + } + + // Check if a service is installed + checkServiceInstalled(serviceName) { + if (!window.apps || window.apps.length === 0) { + return false; + } + + const serviceApp = window.apps.find(app => + app.command && app.command.endsWith(`libreportal app install ${serviceName}`) + ); + + return serviceApp && serviceApp.installed === true; + } + + checkRequirementEnabled(suffix) { + if (!suffix) return false; + const sysCfg = window.systemConfig || window.configs || {}; + const v = sysCfg[`CFG_REQUIREMENT_${suffix}`]; + return v === true || v === 'true'; + } + + // Get navigation button for installing required services + getNavigationButton(fieldKey) { + const servicePages = { + 'AUTHELIA': 'app.html?app=authelia', + 'HEADSCALE': 'app.html?app=headscale', + 'WHITELIST': 'app.html?app=traefik', + 'TRAEFIK': 'app.html?app=traefik' + }; + + let serviceName; + if (fieldKey === 'WHITELIST') { + serviceName = 'Traefik'; + } else if (fieldKey === 'AUTHELIA') { + serviceName = 'Authelia'; + } else if (fieldKey === 'HEADSCALE') { + serviceName = 'Headscale'; + } else { + serviceName = fieldKey.charAt(0) + fieldKey.slice(1).toLowerCase(); + } + + const pageUrl = servicePages[fieldKey] || '#'; + + return ` + + `; + } + + // Handle navigation with unsaved changes check + handleNavigation(url, serviceName) { + // For now, just navigate - could add unsaved changes detection later + window.location.href = url; + } + + // Generate disabled field with navigation button + serviceForField(fieldKey, fieldConfig) { + const map = { AUTHELIA: 'authelia', HEADSCALE: 'headscale', WHITELIST: 'traefik' }; + return (map[fieldKey] || fieldConfig.requiresService || '').toLowerCase(); + } + + generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason) { + const fieldId = fieldKey; + const slug = this.serviceForField(fieldKey, fieldConfig); + const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : ''; + const iconUrl = slug ? `/icons/apps/${encodeURIComponent(slug)}.svg` : '/icons/apps/default.svg'; + const isCheckbox = fieldConfig.type === 'checkbox'; + const hiddenInput = isCheckbox + ? `` + : ``; + + return ` +
+ ${hiddenInput} + +
+
${this.escHtml(fieldConfig.label)}
+
${this.escHtml(disabledReason)}
+
+ ${slug ? `` : ''} +
+ `; + } + + // First-install welcome modal — also openable from the app-header button. + async showInstallWelcome(appName, opts = {}) { + const slug = String(appName || '').toLowerCase(); + if (!slug) return; + const app = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === slug); + if (!app) return; + + let services = []; + try { + const r = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (r.ok) { + const d = await r.json(); + services = Array.isArray(d?.services) ? d.services.filter(s => s.app === slug) : []; + } + } catch (_) {} + + const traefikInstalled = this.checkServiceInstalled('traefik'); + const cfg = app.config || {}; + const upper = slug.toUpperCase(); + const isPublic = services.some(s => s.traefikManaged); + const isAuth = String(cfg[`CFG_${upper}_AUTHELIA`] || '').toLowerCase() === 'true' && this.checkServiceInstalled('authelia'); + const isVpn = String(cfg[`CFG_${upper}_NETWORK`] || '').toLowerCase() === 'gluetun'; + const anyTraefikLogin = services.some(s => s.traefikManaged && s.loginRequired); + + const badges = []; + if (isPublic) badges.push({ icon: '🌍', label: 'Public', cls: 'public' }); + if (services.some(s => s.traefikManaged) && traefikInstalled) badges.push({ icon: '🛡️', label: 'Traefik', cls: 'traefik' }); + if (isAuth) badges.push({ icon: '🔒', label: 'Authelia', cls: 'authelia' }); + if (isVpn) badges.push({ icon: '🌐', label: 'Gluetun VPN', cls: 'gluetun' }); + if (anyTraefikLogin && traefikInstalled) badges.push({ icon: '🔑', label: 'Login required', cls: 'login' }); + + const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : null; + const urls = []; + services.forEach(s => { + if (s.buttonEnabled === false) return; + const links = expand ? expand(s) : [{ url: s.externalURL, label: s.buttonText || s.name }]; + links.forEach(l => { if (l?.url) urls.push({ url: l.url, label: l.label || s.buttonText || s.name }); }); + }); + + const creds = []; + if (anyTraefikLogin && traefikInstalled) { + creds.push({ + title: 'Website Authentication (Traefik basic-auth)', + username: window.globalConfig?.CFG_TRAEFIK_USER || '(see CFG_TRAEFIK_USER)', + password: window.globalConfig?.CFG_TRAEFIK_PASS || '(see CFG_TRAEFIK_PASS)' + }); + } + // App-login keys are the app's OWN admin credentials, anchored to the + // app's CFG prefix. Without the anchor, loose suffix matches pull in + // notification-channel / upstream creds that aren't logins at all + // (e.g. CFG_GLUETUN_OPENVPN_PASSWORD). + const loginKey = (kind) => new RegExp(`^CFG_${upper}_(ADMIN_)?(${kind})$`); + const emailKeys = Object.keys(cfg).filter(k => loginKey('EMAIL').test(k)); + const userKeys = Object.keys(cfg).filter(k => loginKey('USER(NAME)?').test(k)); + const passKeys = Object.keys(cfg).filter(k => loginKey('PASSWORD').test(k)); + const emailVal = emailKeys[0] ? cfg[emailKeys[0]] : ''; + const userVal = userKeys[0] ? cfg[userKeys[0]] : ''; + const identifier = emailVal || userVal || 'admin'; + const userLabel = (emailVal || (typeof identifier === 'string' && identifier.includes('@'))) ? 'Email' : 'User'; + if (userKeys[0] || emailKeys[0] || passKeys[0]) { + creds.push({ + title: `${app.name.split(' - ')[0]} Login`, + username: identifier, + userLabel, + password: cfg[passKeys[0]] || '(not generated)' + }); + } + + const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/icons/apps/${slug}.svg`; + const shortName = app.name.split(' - ')[0]; + + const eoBadges = badges.map(b => ({ + icon: b.icon, label: b.label, + variant: ({public:'success', traefik:'info', authelia:'purple', gluetun:'warning', login:'danger'})[b.cls] + })); + + const bodyParts = []; + bodyParts.push(window.eoBadgeRow(eoBadges)); + if (urls.length) bodyParts.push(window.eoSection(`Open ${shortName}`, window.eoUrlList(urls))); + if (creds.length) bodyParts.push(window.eoSection('Login Details', window.eoCredList(creds))); + if (!urls.length && !creds.length) bodyParts.push(window.eoEmpty('All set up — head to the Services tab for details.')); + + window.openEoModal({ + id: 'install-welcome-modal', + size: 'sm', + icon: iconUrl, + iconAlt: shortName, + eyebrow: `🎉 ${opts.replay ? 'Welcome back to' : 'Installed'}`, + title: shortName, + desc: app.description || '', + body: bodyParts.join(''), + actions: [{ label: 'Done', variant: 'primary' }] + }); + + try { localStorage.setItem(`libreportal.welcomeShown.${slug}`, '1'); } catch (_) {} + } + + navigateToServiceApp(slug) { + if (typeof window.navigateToApp === 'function') return window.navigateToApp(slug); + if (window.librePortalSPA?.navigate) return window.librePortalSPA.navigate(`/app?=${slug}`); + if (typeof window.navigateToRoute === 'function') return window.navigateToRoute(`app?=${slug}`); + window.location.href = `/app?=${slug}`; + } + + // Find matching CFG_ key for a field (working method from app-config-original.js) + togglePasswordVisibility(fieldId) { + const input = document.getElementById(fieldId); + const icon = document.getElementById(`${fieldId}-icon`); + if (!input) return; + const showing = input.type === 'text'; + input.type = showing ? 'password' : 'text'; + if (icon) icon.textContent = showing ? '👁' : '🙈'; + } + + countryFlagEmoji(name) { + const map = { + 'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ', + 'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO', + 'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL', + 'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ', + 'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET', + 'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT', + 'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ', + 'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ', + 'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT', + 'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT', + 'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP', + 'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA', + 'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA', + 'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG', + 'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK', + 'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN', + 'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB', + 'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN' + }; + const code = map[name]; + if (!code) return '🏳️'; + return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65))); + } + + openGluetunCountriesModal(fieldId) { + const hidden = document.getElementById(fieldId); + const chips = document.getElementById(`${fieldId}-chips`); + if (!hidden) return; + + const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl) + ? ConfigOptions.findGluetunProviderEl() + : null; + const provider = providerEl ? providerEl.value : ''; + const providers = window.gluetunProviders || {}; + const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries)) + ? [...providers[provider].countries].sort((a, b) => a.localeCompare(b)) + : []; + + const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean)); + + const existing = document.getElementById('gluetun-countries-modal'); + if (existing) existing.remove(); + + const flag = (n) => this.countryFlagEmoji(n); + const renderChips = (list) => list.length + ? list.map(c => `${flag(c)}${c}`).join('') + : `Any`; + + const fallbackProviderIcon = `data:image/svg+xml;utf8,`; + const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —'; + const iconManifest = window.gluetunProviderIcons || {}; + const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon; + + const bodyHtml = ` +
+
+ ${provider} +
+
+

Provider

+

${providerLabel}

+
+
+
+
+ + + + + +
+
+ + +
+
+
+ ${countries.length === 0 + ? `

No country list available for this provider. Pick a provider first or wait for the snapshot to load.

` + : countries.map(c => ` + `).join('')} +
`; + + const m = window.openEoModal({ + id: 'gluetun-countries-modal', + title: '🌍 Select VPN Countries', + body: bodyHtml, + actions: [ + { label: 'Save', variant: 'primary', onClick: (modal) => { + const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value); + hidden.value = picked.join(','); + hidden.dispatchEvent(new Event('change', { bubbles: true })); + if (chips) chips.innerHTML = renderChips(picked); + modal.close(); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + const root = m.contentEl; + root.querySelector('.gluetun-country-search').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + root.querySelectorAll('.gluetun-country-item').forEach(item => { + const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase(); + item.style.display = label.includes(q) ? '' : 'none'; + }); + }); + root.querySelector('.gluetun-country-all').addEventListener('click', () => { + root.querySelectorAll('.gluetun-country-item').forEach(item => { + if (item.style.display !== 'none') item.querySelector('input').checked = true; + }); + }); + root.querySelector('.gluetun-country-none').addEventListener('click', () => { + root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false); + }); + } + + async openGluetunRouteAppsModal() { + const existing = document.getElementById('gluetun-route-apps-modal'); + if (existing) existing.remove(); + + let allow = []; + try { + const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); + if (r.ok) allow = (await r.json()).categories || []; + } catch {} + const allowSet = new Set(allow.map((c) => String(c).toLowerCase())); + const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL'); + const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']); + const apps = (window.apps || []) + .filter((a) => a.installed) + .map((a) => { + const slug = (a.command || '').split(' ').pop(); + return { ...a, slug }; + }) + .filter((a) => a.slug && !skip.has(a.slug)) + .filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase())) + .sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug)); + + const bodyHtml = ` +

+ Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network. + Each change re-runs that app's install task to apply the new compose. +

+ ${apps.length === 0 ? ` +
+ +
+

No eligible installed apps

+

+ Install an app from the curated categories first, or enable the + Gluetun For All Apps requirement to expose every app. +

+
+
+ ` : ` +
+ ${apps.map((a) => { + const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`; + const current = (a.config && a.config[cfgKey]) || 'default'; + const checked = current === 'gluetun' ? 'checked' : ''; + const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/icons/apps/default.svg'; + return ` + `; + }).join('')} +
+ `}`; + + const m = window.openEoModal({ + id: 'gluetun-route-apps-modal', + title: '🛡️ Route apps through Gluetun', + body: bodyHtml, + actions: [ + { label: 'Apply', variant: 'primary', onClick: async (modal) => { + if (apps.length === 0) { modal.close(); return; } + const root = modal.contentEl; + const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; + const changes = []; + root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => { + const desired = cb.checked ? 'gluetun' : 'default'; + if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired }); + }); + if (changes.length === 0) { modal.close(); return; } + applyBtn.disabled = true; applyBtn.textContent = 'Applying…'; + try { + if (!window.tasksManager?.router) await this.loadTaskSystem?.(); + for (const { slug, value } of changes) { + const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`; + await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } }); + } + this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`); + modal.close(); + if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks'); + } catch (err) { + applyBtn.disabled = false; applyBtn.textContent = 'Apply'; + console.error('Failed to queue gluetun routing tasks', err); + } + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + if (apps.length === 0) { + const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0]; + if (applyBtn) applyBtn.disabled = true; + } + } + + openMullvadGenerateModal() { + const existing = document.getElementById('mullvad-generate-modal'); + if (existing) existing.remove(); + + const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/icons/vpn/mullvad.svg'; + const bodyHtml = ` +

+ Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally + and registered with Mullvad — this consumes one of your 5 device slots. +

+
+ + +
+ `; + + const m = window.openEoModal({ + id: 'mullvad-generate-modal', + size: 'sm', + icon: mullvadIcon, + iconAlt: 'Mullvad', + eyebrow: 'Provider', + title: 'Generate Mullvad Config', + body: bodyHtml, + actions: [ + { label: 'Generate', variant: 'primary', onClick: async (modal) => { + const root = modal.contentEl; + const errEl = root.querySelector('.mullvad-error'); + const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; + const acctEl = root.querySelector('#mullvad-acct'); + const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; }; + const account = (acctEl.value || '').replace(/\s+/g, ''); + if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; } + setError(''); + confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…'; + try { + const res = await fetch('/api/gluetun/mullvad-wireguard', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accountNumber: account }) + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error || `Request failed (${res.status}).`); + confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; + return; + } + const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null; + const setField = (suffix, value) => { + const el = findField(suffix); if (!el) return; + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + setField('WIREGUARD_PRIVATE_KEY', data.privateKey); + setField('WIREGUARD_ADDRESSES', data.addresses); + modal.close(); + } catch (err) { + setError(err.message || 'Network error.'); + confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; + } + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + m.contentEl.querySelector('#mullvad-acct').focus(); + } + + setPasswordMode(fieldId, mode) { + const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`); + const input = document.getElementById(fieldId); + const tokenInput = document.getElementById(`${fieldId}-token`); + if (!wrapper || !input || !tokenInput) return; + const key = wrapper.dataset.fieldKey; + + if (mode === 'random') { + input.dataset.previousCustom = input.value || ''; + input.value = ''; + input.readOnly = true; + input.type = 'password'; + input.setAttribute('placeholder', 'Will generate on save'); + input.removeAttribute('name'); + tokenInput.setAttribute('name', key); + const icon = document.getElementById(`${fieldId}-icon`); + if (icon) icon.textContent = '👁'; + } else { + input.readOnly = false; + input.removeAttribute('placeholder'); + input.value = input.dataset.previousCustom || ''; + input.setAttribute('name', key); + tokenInput.removeAttribute('name'); + input.focus(); + } + } + + // Off-value for a checkbox whose dependency isn't installed. + unmetDependencyValue(fieldConfig) { + return fieldConfig.type === 'checkbox' ? 'false' : ''; + } + + escHtml(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>'); + } + escAttr(s) { + return this.escHtml(s).replace(/"/g, '"').replace(/'/g, '''); + } + + findMatchingCFGKey(fieldKey, appConfig) { + // Try exact match first + const exactMatch = `CFG_${fieldKey}`; + if (appConfig.hasOwnProperty(exactMatch)) { + return exactMatch; + } + + // Try partial matches (more precise) + const keys = Object.keys(appConfig); + for (const cfgKey of keys) { + const cfgKeyWithoutPrefix = cfgKey.replace('CFG_', ''); + // Only match if the field key is a complete word within the cfg key + if (cfgKeyWithoutPrefix === fieldKey || + cfgKeyWithoutPrefix.endsWith('_' + fieldKey) || + cfgKeyWithoutPrefix.startsWith(fieldKey + '_')) { + return cfgKey; + } + } + + return null; + } + + // Helper methods to load config data (working methods from app-config-original.js) + async getConfigCategories() { + try { + // Load config categories (for app config tabs) + const response = await fetch('/data/apps/apps-config-categories.json'); + const data = await response.json(); + //// // console.log('✅ Loaded config categories from apps folder'); + return data.categories || data; // Return the actual data object + } catch (error) { + console.error('Error loading config categories:', error); + throw new Error('Failed to load config categories. Please check your configuration files.'); + } + } + + async getFieldMappings() { + try { + // Load from apps folder (static file) + const response = await fetch('data/apps/apps-field-mappings.json'); + const data = await response.json(); + //// // console.log('✅ Loaded field mappings from apps folder'); + return data.fields || data; + } catch (error) { + console.error('Error loading field mappings:', error); + throw new Error('Failed to load field mappings. Please check your configuration files.'); + } + } + + // Get domain options for DOMAIN field + async getDomainOptions() { + //// // console.log('🎯 Getting domain options...'); + + try { + //// // console.log('🔍 Starting domain fetch...'); + + // Try to load system config to get domain information + const response = await fetch('/data/config/generated/configs.json'); + //// // console.log('📡 Config response status:', response.status); + + if (!response.ok) { + console.warn('Could not load system config for domains, returning empty list'); + return [ + { value: '1', label: 'No domains configured - Configure domains in Network settings first' } + ]; + } + + const configData = await response.json(); + //// // console.log('📄 Full config data:', configData); + //// // console.log('🔧 Config keys available:', Object.keys(configData)); + + const config = configData.config || {}; + //// // console.log('⚙️ Config object:', config); + //// // console.log('🔑 Config keys:', Object.keys(config)); + + const domains = []; + + // Check CFG_DOMAIN_1 through CFG_DOMAIN_9 + for (let i = 1; i <= 9; i++) { + const domainKey = `CFG_DOMAIN_${i}`; + const domainConfig = config[domainKey]; + + //// // console.log(`🌐 Checking ${domainKey}:`, domainConfig, 'type:', typeof domainConfig); + + // Check if domainConfig has a value property and it's a non-empty string + let domainValue = ''; + if (domainConfig && typeof domainConfig === 'object' && domainConfig.value) { + domainValue = domainConfig.value; + } else if (typeof domainConfig === 'string') { + domainValue = domainConfig; + } + + //// // console.log(`🔤 Extracted domain value: "${domainValue}" type: ${typeof domainValue}`); + + // Only add domains that have actual content (non-empty string) + if (typeof domainValue === 'string' && domainValue.trim() !== '') { + //// // console.log(`✅ Adding domain: ${domainValue.trim()}`); + domains.push({ + number: i, + domain: domainValue.trim(), + key: domainKey + }); + } else { + //// // console.log(`⏭️ Skipping empty domain ${domainKey}`); + } + } + + //// // console.log('✅ Found configured domains:', domains); + + if (domains.length === 0) { + //// // console.log('⚠️ No domains found, returning fallback option'); + return [ + { value: '1', label: 'No domains configured - Configure domains in Network settings first' } + ]; + } + + // Create options with just domain names + const options = domains.map(domain => ({ + value: domain.number.toString(), + label: domain.domain + })); + + //// // console.log('✅ Generated domain options:', options); + return options; + + } catch (error) { + console.error('❌ Error fetching domains:', error); + return [ + { value: '1', label: 'Error loading domains - Check console for details' } + ]; + } + } + + // Get current app name + getCurrentAppName() { + // Try to get from current app data + if (this.currentApp) { + return this.currentApp; + } + + // Try to get from URL + const urlParams = new URLSearchParams(window.location.search); + const appName = urlParams.get('app'); + if (appName) { + return appName; + } + + // Try to get from current path + const pathParts = window.location.pathname.split('/'); + const appIndex = pathParts.indexOf('app'); + if (appIndex !== -1 && pathParts[appIndex + 1]) { + return pathParts[appIndex + 1]; + } + + return 'unknown'; + } + + // Initialize port managers after DOM is ready + async initializePortManagers() { + //// // console.log('🔌 Looking for port manager containers...'); + const portContainers = document.querySelectorAll('.port-manager-container'); + //// // console.log(`🔌 Found ${portContainers.length} port manager containers`); + + // Group port containers by app + const appPortContainers = {}; + for (const container of portContainers) { + const appName = container.dataset.appName; + if (!appPortContainers[appName]) { + appPortContainers[appName] = []; + } + appPortContainers[appName].push(container); + } + + // Create one consolidated port manager per app + for (const [appName, containers] of Object.entries(appPortContainers)) { + //// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`); + + try { + // Get all port configurations for this app + const appConfig = this.getCurrentAppConfig(); + const allPortConfigs = this.getAllPortConfigs(appConfig, appName); + + // Create consolidated port manager + const portManager = new PortManager(); + const html = portManager.generateHTML(appName, allPortConfigs); + + // Replace the first container with the consolidated port manager + const firstContainer = containers[0]; + firstContainer.innerHTML = html; + + // Hide other port containers (PORT_2, PORT_3, etc.) and their labels + for (let i = 1; i < containers.length; i++) { + const container = containers[i]; + const formField = container.closest('.form-field'); + if (formField) { + formField.style.display = 'none'; + } else { + container.style.display = 'none'; + } + } + + // Hide labels and help text for the first port container + this.hidePortFieldLabels(containers[0]); + + // Initialize port manager with services + await portManager.initialize(appName); + //// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`); + } catch (error) { + console.error(`Error initializing consolidated port manager for ${appName}:`, error); + containers[0].innerHTML = `
Failed to initialize port manager: ${error.message}
`; + } + } + } + + // Hide labels and help text for port field containers + hidePortFieldLabels(container) { + const formField = container.closest('.form-field'); + if (formField) { + // Hide the label + const label = formField.querySelector('label.form-label'); + if (label) { + label.style.display = 'none'; + } + + // Hide the help text + const helpText = formField.querySelector('small.form-help'); + if (helpText) { + helpText.style.display = 'none'; + } + + // Hide any help icons + const helpIcons = formField.querySelectorAll('.help-icon'); + helpIcons.forEach(icon => { + icon.style.display = 'none'; + }); + } + } + + // Get all port configurations for an app + getAllPortConfigs(appConfig, appName) { + const portConfigs = []; + const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; + Object.keys(appConfig).forEach(key => { + if (key.startsWith(portPrefix)) { + const configValue = appConfig[key]; + if (configValue && configValue.trim() !== '') { + portConfigs.push(configValue); + } + } + }); + // Return as array — one CFG__PORT_N value per element. The + // port manager iterates this directly so commas inside fields + // (multi-button labels / paths) stay meaningful and hand-editable. + return portConfigs; + } + + // Get current app configuration + getCurrentAppConfig() { + //// // console.log(`🔧 getCurrentAppConfig DEBUG: this.currentApp = "${this.currentApp}"`); + //// // console.log(`🔧 getCurrentAppConfig DEBUG: window.apps =`, window.apps ? `${window.apps.length} apps` : 'undefined'); + + if (window.apps && this.currentApp) { + const target = String(this.currentApp).toLowerCase(); + const app = window.apps.find(a => { + const slug = (a.command || '').split(' ').pop(); + return slug.toLowerCase() === target; + }); + return app?.config || {}; + } + return {}; + } + /** + * Collect configuration from form and format as pipe-separated string + */ + collectConfigFromForm(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) { + console.warn(`ℹ️ No config form found for ${appName}, proceeding with no config override`); + return ''; + } + + const configPairs = []; + const inputs = form.querySelectorAll('input, select, textarea'); + + inputs.forEach(input => { + const name = input.name; + if (!name || !name.startsWith('CFG_')) return; + + // Skip the port-manager's aggregate hidden input — it's UI-only state + // (combined value of all ports for the live editor) and isn't a real key + // in any .config file. The actual per-port keys (CFG__PORT_1 …) are + // separate hidden inputs that we DO want to collect. + if (name.endsWith('_PORT_MANAGER')) return; + + let value; + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = input.value.trim(); + if (!value) return; + } + + // Encode `|` characters inside values so the bash splitter (IFS='|') doesn't + // fragment values that legitimately contain a pipe — port configs are the + // main case, format: `service|name|ext:int|access|...`. The bash side decodes + // `%7C` back to `|` after splitting. + const encodedValue = value.replace(/\|/g, '%7C'); + configPairs.push(`${name}=${encodedValue}`); + }); + + // `|` between pairs — `,` shows up in real config values (domain lists etc). + const collectedConfig = configPairs.join('|'); + console.log(`📋 Collected config for ${appName}:`, collectedConfig || '(empty - using defaults from apps.json)'); + return collectedConfig; + } + + // ----- Unsaved config-change tracking ------------------------------------- + // The app config panel is pure DOM until the user hits Apply/Update. These + // helpers track when a field differs from its rendered (saved) value, show a + // sticky bar offering Apply/Discard, and register an SPA nav guard so leaving + // the page with unsaved edits prompts first. + + // Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in + // collectConfigFromForm so the two agree on what counts as a config field. + _readConfigFieldState(form) { + const state = {}; + form.querySelectorAll('input, select, textarea').forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; + state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value; + }); + return state; + } + + _getDirtyConfigFields() { + if (!this._dirtyAppName || !this._configSnapshot) return []; + const form = document.getElementById(`app-form-${this._dirtyAppName}`); + if (!form) return []; + const current = this._readConfigFieldState(form); + return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? '')); + } + + _isConfigDirty() { + return this._getDirtyConfigFields().length > 0; + } + + // Called once per config-panel render: snapshots the saved state, wires the + // change listener + sticky bar, and (re)registers the nav guard. Only tracks + // installed apps — for a fresh install the Install button is already the + // "apply" action, so a dirty bar would just be noise. + wireConfigDirtyTracking(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + const app = (window.apps || []).find((a) => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (!app || !app.installed) { + this._clearConfigDirty(); + return; + } + + this._dirtyAppName = appName; + this._configSnapshot = this._readConfigFieldState(form); + + if (form.dataset.dirtyWired !== '1') { + form.dataset.dirtyWired = '1'; + const onEdit = () => this._refreshDirtyBar(); + form.addEventListener('input', onEdit); + form.addEventListener('change', onEdit); + } + + this._ensureDirtyBar(appName, form); + + // beforeunload covers tab close / refresh / external nav — the browser + // shows its own generic prompt. Registered once for the page lifetime. + if (!this._beforeUnloadWired) { + this._beforeUnloadWired = true; + window.addEventListener('beforeunload', (e) => { + if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; } + }); + } + + // SPA route changes route through this guard (see spa.js navigate()). + window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath); + + this._refreshDirtyBar(); + } + + // Build (or rebuild) the sticky bar at the bottom of the config form. + _ensureDirtyBar(appName, form) { + const stale = document.getElementById('config-dirty-bar'); + if (stale) stale.remove(); + + const bar = document.createElement('div'); + bar.id = 'config-dirty-bar'; + bar.className = 'config-dirty-bar'; + bar.style.display = 'none'; + bar.innerHTML = ` + + + + + + + + + + + + `; + // Sits in normal flow between the config content and the action buttons. + const actions = form.querySelector('.config-actions'); + if (actions) { + form.insertBefore(bar, actions); + } else { + form.appendChild(bar); + } + + bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges()); + bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName)); + } + + _refreshDirtyBar() { + const bar = document.getElementById('config-dirty-bar'); + if (!bar) return; + const count = this._getDirtyConfigFields().length; + if (count === 0) { + bar.style.display = 'none'; + return; + } + const label = bar.querySelector('.config-dirty-count'); + if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`; + bar.style.display = 'flex'; + } + + // Revert every field to its snapshot value, then re-fire change/input so + // dependent UI (showWhen visibility, etc.) reconciles. + _discardConfigChanges() { + if (!this._dirtyAppName || !this._configSnapshot) return; + const form = document.getElementById(`app-form-${this._dirtyAppName}`); + if (!form) return; + form.querySelectorAll('input, select, textarea').forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; + if (!(name in this._configSnapshot)) return; + const orig = this._configSnapshot[name]; + if (input.type === 'checkbox') { + input.checked = (orig === 'true'); + } else { + input.value = orig; + } + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + this._refreshDirtyBar(); + } + + // Drop the dirty state without touching the form — used when the changes are + // being applied (the form is about to be replaced) or discarded on leave. + _clearConfigDirty() { + this._configSnapshot = null; + this._dirtyAppName = null; + window.__appConfigNavGuard = null; + const bar = document.getElementById('config-dirty-bar'); + if (bar) bar.style.display = 'none'; + } + + // SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the + // normal apply flow and stays put (apply navigates to the tasks view itself). + async _appConfigNavGuard() { + if (!this._isConfigDirty()) return 'proceed'; + const appName = this._dirtyAppName; + const decision = await this._confirmLeaveUnsaved(appName); + if (decision === 'apply') { + this.installApp(appName); + return 'stay'; + } + if (decision === 'discard') { + this._clearConfigDirty(); + return 'proceed'; + } + return 'stay'; + } + + // Apply / Discard / Stay prompt. Resolves with the chosen action; closing + // via the X or backdrop resolves 'stay' (the safe default). + _confirmLeaveUnsaved(appName) { + let displayName = appName; + const app = (window.apps || []).find((a) => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app && app.name) displayName = app.name.split(' - ')[0].trim(); + + return new Promise((resolve) => { + let decided = false; + const finish = (val, modal) => { + if (decided) return; + decided = true; + if (modal) modal.close(); + resolve(val); + }; + window.openEoModal({ + id: 'config-unsaved-modal', + size: 'sm', + eyebrow: '⚠ Unsaved changes', + title: displayName, + desc: 'You have configuration changes that haven’t been applied.', + body: ` +
+
+

Apply before you go?

+

Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.

+
+
`, + actions: [ + { label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) }, + { label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) }, + { label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) } + ], + onClose: () => { if (!decided) { decided = true; resolve('stay'); } } + }); + }); + } + + // Update service buttons sidebar with service data from apps-services.json + async updateServiceButtonsSidebar(app, appName) { + if (!window.serviceButtons) return; + + try { + // Update sidebar using the new ServiceButtons API + await window.serviceButtons.updateSidebar(appName); + } catch (error) { + console.error('Error updating service buttons sidebar:', error); + } + } + + // Show service popup on hover + async showServicePopup(event, appName) { + if (!window.serviceButtons) return; + + const popup = document.getElementById(`service-popup-${appName}`); + if (!popup) return; + + try { + // Load services if not already loaded + if (window.serviceButtons.services.length === 0) { + await window.serviceButtons.loadServices(); + } + + // Generate buttons HTML + const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName); + const content = popup.querySelector('.service-popup-content'); + if (content) { + content.innerHTML = buttonsHTML; + } + + // Show popup + popup.style.display = 'block'; + } catch (error) { + console.error('Error showing service popup:', error); + } + } + + // Hide service popup + hideServicePopup() { + const popups = document.querySelectorAll('.service-popup'); + popups.forEach(popup => { + popup.style.display = 'none'; + }); + } + + async installApp(appName) { + const installedApp = (window.apps || []).find(a => + (a.command || '').endsWith(` ${appName}`) || a.name === appName + ); + const isInstalled = !!(installedApp && installedApp.installed); + + if (isInstalled) { + this.showUpdateConfirmModal(appName); + return; + } + + if (await this.shouldRecommendGluetun(appName, installedApp)) { + this.showGluetunRecommendModal(appName, installedApp); + return; + } + + return this.executeInstall(appName, false); + } + + async shouldRecommendGluetun(appName, appData) { + if (appName === 'gluetun') return false; + if (this.checkServiceInstalled('gluetun')) return false; + const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL'); + if (overrideOn) return true; + if (!this._gluetunEligiblePromise) { + this._gluetunEligiblePromise = (async () => { + try { + const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); + if (!r.ok) return new Set(); + const j = await r.json(); + return new Set((j.categories || []).map((c) => String(c).toLowerCase())); + } catch { return new Set(); } + })(); + } + const allow = await this._gluetunEligiblePromise; + const cat = String(appData?.category || '').toLowerCase(); + return allow.has(cat); + } + + showGluetunRecommendModal(appName, appData) { + const existing = document.getElementById('gluetun-recommend-modal'); + if (existing) existing.remove(); + + const displayName = appData?.name?.split(' - ')[0]?.trim() || appName; + let icon = appData?.icon || `/icons/apps/${appName}.svg`; + if (icon && !icon.startsWith('/')) icon = '/' + icon; + + const bodyHtml = ` +
+
+ + + +
+
+

Apps in this category usually benefit from VPN routing

+

Without Gluetun, this app's outbound traffic uses your real public IP — exposing activity to ISPs, copyright trackers, and the destination services.

+
+
`; + + window.openEoModal({ + id: 'gluetun-recommend-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'About to install', + title: displayName, + desc: 'VPN routing is recommended for this app.', + body: bodyHtml, + actions: [ + { label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => { + modal.close(); + this.showAppDetailWithConfig('gluetun'); + }}, + { label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => { + modal.close(); + this.executeInstall(appName, false); + }} + ] + }); + } + + // Per-app required-field map keyed by lowercased app slug. Add entries + // here as new apps grow required inputs. Each value is { keys, message } + // where `keys` is the CFG_* names that must be non-empty. + getRequiredConfigKeys(appName) { + const slug = (appName || '').toLowerCase(); + const map = { + traefik: ['CFG_TRAEFIK_EMAIL'] + }; + return map[slug] || []; + } + + validateRequiredConfig(appName) { + const required = this.getRequiredConfigKeys(appName); + if (required.length === 0) return { ok: true, missing: [] }; + const missing = []; + for (const key of required) { + const input = document.getElementById(`config-${key}`); + if (!input) continue; + const v = (input.value || '').trim(); + if (!v || v === 'changeme' || v === 'changeme.com') missing.push({ key, input }); + } + return { ok: missing.length === 0, missing }; + } + + flashRequiredFields(missing) { + if (!missing.length) return; + if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { + window.appTabbedManager.switchTab('config'); + } + missing.forEach(({ input }, idx) => { + input.classList.add('field-required-error'); + input.setAttribute('title', 'Required'); + const clear = () => { + input.classList.remove('field-required-error'); + input.removeAttribute('title'); + input.removeEventListener('input', clear); + input.removeEventListener('change', clear); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); + if (idx === 0) { + setTimeout(() => { + input.scrollIntoView({ behavior: 'smooth', block: 'center' }); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }, 200); + } + }); + } + + async executeInstall(appName, resetNetwork) { + const validation = this.validateRequiredConfig(appName); + if (!validation.ok) { + this.flashRequiredFields(validation.missing); + const labels = validation.missing.map(({ key }) => + key.replace(/^CFG_[A-Z0-9]+_/, '').replace(/_/g, ' ').toLowerCase() + ).join(', '); + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() + : window.notificationSystem; + if (sys && typeof sys.show === 'function') { + sys.show(`Missing required field${validation.missing.length > 1 ? 's' : ''}: ${labels}`, 'error'); + } + return; + } + + // Immediately disable buttons using appTabbedManager for consistency + if (window.appTabbedManager) { + window.appTabbedManager.disableAppButtons(appName, 'install'); + } else { + this.disableInstallButton(appName, 'install'); + } + + // Initialize task system if not available + if (!window.tasksManager || !window.tasksManager.router) { + try { + await this.loadTaskSystem(); + } catch (error) { + console.error(`❌ Failed to initialize task system:`, error); + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + } + } + + if (window.tasksManager && window.tasksManager.router) { + try { + // Collect configuration from form + const config = this.collectConfigFromForm(appName); + + // Create installation task + const task = await window.tasksManager.router.routeAction('install', { + appName: appName, + config: config, + resetNetwork: resetNetwork + }); + + // Changes are being applied — clear the unsaved-changes state so the + // switch to the tasks view isn't caught by the leave-confirm guard. + this._clearConfigDirty(); + + // Show success message and switch to tasks + this.addSuccessLog(`Installation task created for ${appName}. Switching to tasks view...`); + + // Switch to tasks view to show the installation progress with auto-loaded task + setTimeout(() => { + if (window.appTabbedManager) { + // Switch to tasks tab within current app page + window.appTabbedManager.switchTab('tasks'); + // Auto-expand the created task + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 500); + } else if (window.librePortalSPA) { + // Fallback: navigate to app with tasks tab + const taskUrl = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`; + window.librePortalSPA.navigateTo(taskUrl); + } else if (window.navigateToRoute) { + window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`); + } + }, 1000); + + } catch (error) { + this.addErrorLog(`Failed to create installation task: ${error.message}`); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + } + } else { + // Fallback to original simulation if task system not available + //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); + //// // console.log(`🔍 Debug info:`, { + //tasksManager: !!window.tasksManager, + //router: !!(window.tasksManager && window.tasksManager.router), + //windowTasksManager: window.tasksManager + //}); + + this.addInfoLog(`Starting installation of ${appName}...`); + + // Simulate installation process + setTimeout(() => { + this.addSuccessLog(`Installation completed successfully!`); + // Re-enable buttons after simulation completes + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + }, 2000); + } + } + + /** + * Initialize task system on demand + */ + async loadTaskSystem() { + try { + //// // console.log(`🔧 Loading task system components...`); + + // Only load scripts if they're not already loaded + const scripts = [ + { name: 'TaskManager', src: '/js/components/task/task-manager.js' }, + { name: 'TaskCommands', src: '/js/components/task/task-commands.js' }, + { name: 'TaskActions', src: '/js/components/task/task-actions.js' }, + { name: 'TaskRouter', src: '/js/components/task/task-router.js' }, + { name: 'TasksManager', src: '/js/components/tasks/tasks-manager.js' } + ]; + + for (const script of scripts) { + if (!window[script.name]) { + //// // console.log(`📦 Loading ${script.name}...`); + await this.loadScript(script.src); + } else { + //// // console.log(`✅ ${script.name} already loaded`); + } + } + + // Initialize tasks manager if not already initialized + if (window.TasksManager && !window.tasksManager) { + //// // console.log(`🔧 Initializing TasksManager instance...`); + try { + window.tasksManager = new TasksManager(); + //// // console.log(`✅ TasksManager constructor completed`); + //// // console.log(`🔧 TasksManager instance:`, window.tasksManager); + + if (typeof window.tasksManager.init === 'function') { + //// // console.log(`🔧 Calling TasksManager.init()...`); + await window.tasksManager.init(); + //// // console.log(`✅ TasksManager.init() completed`); + } else { + //// // console.log(`⚠️ TasksManager.init() is not a function`); + } + } catch (error) { + console.error(`❌ Failed to initialize TasksManager:`, error); + throw error; + } + } else if (window.tasksManager) { + //// // console.log(`✅ TasksManager instance already exists`); + } else { + //// // console.log(`❌ TasksManager class not available`); + } + + //// // console.log(`✅ Task system components loaded and initialized`); + } catch (error) { + console.error(`❌ Failed to load task system:`, error); + throw error; + } + } + + /** + * Load script helper + */ + loadScript(src) { + return new Promise((resolve, reject) => { + // Check if script is already loaded + if (document.querySelector(`script[src="${src}"]`)) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + // Terminal logging functions with old styling + addLogMessage(message, type = 'info') { + const messageLog = document.getElementById('message-log'); + if (!messageLog) return; + + const timestamp = new Date().toLocaleTimeString(); + const messageLine = document.createElement('div'); + messageLine.className = `log-entry ${type}`; + messageLine.innerHTML = `[${timestamp}] ${message}`; + + messageLog.appendChild(messageLine); + // Only scroll to bottom if user hasn't scrolled up + if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) { + messageLog.scrollTop = messageLog.scrollHeight; + } + } + + addSuccessLog(message) { + this.addLogMessage(message, 'success'); + } + + addErrorLog(message) { + this.addLogMessage(message, 'error'); + } + + addWarningLog(message) { + this.addLogMessage(message, 'warning'); + } + + addInfoLog(message) { + this.addLogMessage(message, 'info'); + } + + clearConsole() { + const messageLog = document.getElementById('message-log'); + if (!messageLog) return; + + messageLog.innerHTML = '
[' + new Date().toLocaleTimeString() + '] Console cleared...
'; + messageLog.scrollTop = 0; // Force to top + } + + initializeConsole() { + const messageLog = document.getElementById('message-log'); + if (messageLog) { + messageLog.scrollTop = 0; // Force scroll to top + messageLog.innerHTML = ''; // Clear any existing content + // Add initial message after a tiny delay to ensure it renders at top + setTimeout(() => { + this.addInfoLog('Application configuration loaded successfully'); + }, 100); + } + } + + // Public entry point bound to the "Uninstall App" button. Doesn't kick + // anything off itself — it only opens the confirmation modal so the user + // has to explicitly approve a destructive action (matches the create + // backup / update modals' UX). Actual work happens in executeUninstall. + uninstallApp(appName) { + this.showUninstallConfirmModal(appName); + } + + async executeUninstall(appName, deleteImage = false, deleteTasks = false) { + // Track the task id this call spawns so the post-completion handler + // can delete it (the bash side skips the in-flight task to avoid a + // race with the processor still writing its status). + this._pendingTaskCleanup = this._pendingTaskCleanup || new Map(); + // Immediately disable buttons using appTabbedManager for consistency + if (window.appTabbedManager) { + window.appTabbedManager.disableAppButtons(appName, 'uninstall'); + } else { + // Fallback to our own button disabling if appTabbedManager not available + this.disableUninstallButton(appName, 'uninstall'); + } + + // Check if tasks system is available, initialize if needed + //// // console.log(`🔍 Checking task system availability for uninstall...`); + //// // console.log(`🔍 window.tasksManager:`, !!window.tasksManager); + //// // console.log(`🔍 window.tasksManager.router:`, !!(window.tasksManager && window.tasksManager.router)); + + // Initialize task system if not available + if (!window.tasksManager || !window.tasksManager.router) { + //// // console.log(`🔧 Initializing task system...`); + try { + // Load task system components + await this.loadTaskSystem(); + //// // console.log(`✅ Task system initialized successfully`); + } catch (error) { + console.error(`❌ Failed to initialize task system:`, error); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + // Continue with fallback if initialization fails + } + } + + if (window.tasksManager && window.tasksManager.router) { + //// // console.log(`🚀 Uninstalling ${appName} via task system...`); + + try { + // Create uninstallation task + const task = await window.tasksManager.router.routeAction('uninstall', { + appName: appName, + deleteImage: deleteImage, + deleteTasks: deleteTasks + }); + if (deleteTasks && task && task.id) { + this._pendingTaskCleanup.set(task.id, appName); + } + + // Show success message and switch to tasks + this.addSuccessLog(`Uninstallation task created for ${appName}. Switching to tasks view...`); + + // Switch to tasks view to show the uninstallation progress with auto-loaded task + setTimeout(() => { + if (window.appTabbedManager) { + // Switch to tasks tab within current app page + window.appTabbedManager.switchTab('tasks'); + // Auto-expand the created task + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 500); + } else if (window.librePortalSPA) { + // Fallback: navigate to app with tasks tab + const taskUrl = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`; + // console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`); + window.librePortalSPA.navigateTo(taskUrl); + } else if (window.navigateToRoute) { + window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`); + } + }, 1000); + + } catch (error) { + this.addErrorLog(`Failed to create uninstallation task: ${error.message}`); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + } + } else { + // Fallback to original simulation if task system not available + //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); + + this.addInfoLog(`Starting uninstallation of ${appName}...`); + + // Simulate uninstallation process + setTimeout(() => { + this.addSuccessLog(`Uninstallation completed successfully!`); + // Re-enable buttons after simulation completes + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + }, 1500); + } + } + + // Enhance scrollbar dynamically for tabs-list + enhanceTabsScrollbar() { + const tabsList = document.querySelector('.tabs-list'); + if (tabsList) { + // Check if scrolling is needed + const isScrollable = tabsList.scrollWidth > tabsList.clientWidth; + + if (isScrollable) { + // Add data attribute for enhanced styling + tabsList.setAttribute('data-scrollable', 'true'); + //// // console.log('✅ Enhanced tabs scrollbar for scrollable content'); + } else { + // Remove attribute if not scrollable + tabsList.removeAttribute('data-scrollable'); + //// // console.log('📝 Tabs list not scrollable, using default styling'); + } + + // Monitor for content changes + const observer = new MutationObserver(() => { + setTimeout(() => this.enhanceTabsScrollbar(), 100); + }); + + observer.observe(tabsList, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style'] + }); + } + } + + // Helper methods for button state management + disableInstallButton(appName, action) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the install/update button + const button = form.querySelector('.btn-install, .btn-manage'); + if (!button) return; + + // Disable button and add spinner + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner if not already present + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + button.innerHTML = ` + + ${textContent.trim()} + `; + } + + // console.log(`🔍 Install button disabled for ${appName} during ${action}`); + } + + enableInstallButton(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the install/update button + const button = form.querySelector('.btn-install, .btn-manage'); + if (!button) return; + + // Re-enable button and restore original content + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + + // Restore original content if it was saved + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + + // Remove any spinners + const spinners = button.querySelectorAll('.spinner'); + spinners.forEach(spinner => spinner.remove()); + + // console.log(`🔍 Install button enabled for ${appName}`); + } + + disableUninstallButton(appName, action) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the uninstall button + const button = form.querySelector('.btn-uninstall'); + if (!button) return; + + // Disable button and add spinner + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner if not already present + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + button.innerHTML = ` + + ${textContent.trim()} + `; + } + + // console.log(`🔍 Uninstall button disabled for ${appName} during ${action}`); + } + + enableUninstallButton(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the uninstall button + const button = form.querySelector('.btn-uninstall'); + if (!button) return; + + // Re-enable button and restore original content + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + + // Restore original content if it was saved + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + + // Remove any spinners + const spinners = button.querySelectorAll('.spinner'); + spinners.forEach(spinner => spinner.remove()); + + // console.log(`🔍 Uninstall button enabled for ${appName}`); + } + + // Static methods for global access + static showAppsList(category = null) { + if (window.appsManager) { + window.appsManager.showAppsList(category); + } + } + + static showAppDetail(appName) { + if (window.appsManager) { + window.appsManager.showAppDetail(appName); + } + } + + showUpdateConfirmModal(appName) { + let displayName = appName; + let icon = `/icons/apps/${appName}.svg`; + if (window.apps) { + const app = window.apps.find(a => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app) { + displayName = app.name; + if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; + } + } + + const bodyHtml = ` +
+
+ + + + + +
+
+

Container will restart

+

The configuration will be reapplied and the container restarted to pick up changes.

+
+
+ `; + + window.openEoModal({ + id: 'update-confirm-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'Apply Configuration', + title: displayName, + desc: 'Reapply config and restart the container.', + body: bodyHtml, + actions: [ + { label: 'Update', variant: 'primary', onClick: (modal) => { + const cb = modal.contentEl.querySelector('#update-reset-network'); + const resetNetwork = !!(cb && cb.checked); + modal.close(); + this.executeInstall(appName, resetNetwork); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + } + + // Confirmation modal for the destructive "Uninstall App" action. + showUninstallConfirmModal(appName) { + let displayName = appName; + let icon = `/icons/apps/${appName}.svg`; + let app = null; + if (window.apps) { + app = window.apps.find(a => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app) { + displayName = app.name; + if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; + } + } + + // Cascade warning: if the app declares related apps (via CFG__RELATED_APPS) + // and any of them are currently installed, the uninstall will take them down + // too — surface that up-front so the user isn't surprised. + const upper = appName.toUpperCase().replace(/-/g, '_'); + const relatedRaw = app?.config?.[`CFG_${upper}_RELATED_APPS`]?.value + || app?.config?.[`CFG_${upper}_RELATED_APPS`] + || ''; + const relatedNames = String(relatedRaw) + .split(',').map(s => s.trim()).filter(Boolean) + .map(slug => { + const rel = (window.apps || []).find(a => + (a.command || '').endsWith(` ${slug}`) || + (a.name && a.name.toLowerCase() === slug.toLowerCase()) + ); + return rel && rel.installed ? (rel.name || slug) : null; + }) + .filter(Boolean); + const cascadeBlock = relatedNames.length + ? `
+
+

Cascade removal

+

Will also uninstall: ${relatedNames.map(n => `${n}`).join(', ')}.

+
+
` + : ''; + + const bodyHtml = ` + ${cascadeBlock} +
+
+ + + + + +
+
+

This cannot be undone

+

The container will be stopped and its data removed.

+
+
+ + `; + + window.openEoModal({ + id: 'uninstall-confirm-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'Uninstall', + title: displayName, + desc: 'Confirm to remove this application.', + body: bodyHtml, + actions: [ + { label: 'Uninstall', variant: 'danger', onClick: (modal) => { + const cb1 = modal.contentEl.querySelector('#uninstall-delete-image'); + const cb2 = modal.contentEl.querySelector('#uninstall-delete-tasks'); + const deleteImage = !!(cb1 && cb1.checked); + const deleteTasks = !!(cb2 && cb2.checked); + modal.close(); + this.executeUninstall(appName, deleteImage, deleteTasks); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + } +} + +// Service Buttons Manager - handles service URL buttons from port config + apps-services.json +class ServiceButtons { + constructor() { + this.services = []; + } + + // Load services from apps-services.json + async loadServices() { + try { + const response = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + const data = await response.json(); + this.services = data.services || []; + return this.services; + } catch (error) { + console.error('Error loading services:', error); + return []; + } + } + + // Parse port configuration from app config + parsePortConfig(appName) { + const ports = []; + // Get app config from window.apps if available + const app = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + if (!app || !app.config) return ports; + + const appConfig = app.config; + const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; + + Object.keys(appConfig).forEach(key => { + if (key.startsWith(portPrefix)) { + const configValue = appConfig[key]; + if (configValue && configValue.trim() !== '') { + const parts = configValue.split('|'); + if (parts.length >= 8) { + const isNineCol = parts.length >= 9; + ports.push({ + service: parts[0], + name: parts[1], + external: parts[2].split(':')[0], + internal: parts[2].split(':')[1], + access: parts[3], + protocol: parts[4], + loginRequired: isNineCol ? parts[5] === 'true' : false, + traefikManaged: isNineCol ? parts[6] === 'true' : parts[5] === 'true', + buttonEnabled: isNineCol ? parts[7] === 'true' : parts[6] === 'true', + buttonText: isNineCol ? parts[8] : parts[7] + }); + } + } + } + }); + + return ports; + } + + // Get services for a specific app from config, then fill in real IPs/ports from apps-services.json + async getServicesForApp(appName) { + const portConfig = this.parsePortConfig(appName); + // console.log(`📦 Port config for ${appName}:`, portConfig); + const portServices = portConfig.filter(p => p.buttonEnabled); + // console.log(`✅ Enabled services for ${appName}:`, portServices); + + if (portServices.length === 0) return []; + + // Load services from apps-services.json if not already loaded + if (this.services.length === 0) { + await this.loadServices(); + } + // console.log(`🌐 Loaded ${this.services.length} services from apps-services.json`); + + // Merge port config with real data from apps-services.json + return portServices.map(portService => { + // Find matching service in apps-services.json + const serviceData = this.services.find(s => + s.app === appName && + s.name === portService.name + ); + + // console.log(`🔗 Matching service for ${portService.name}:`, serviceData); + + const merged = { + ...portService, + serviceIP: serviceData?.serviceIP || '', + externalPort: serviceData?.externalPort || portService.external, + internalPort: serviceData?.internalPort || portService.internal, + serverIP: serviceData?.serverIP || '', + externalURL: serviceData?.externalURL || '', + internalURL: serviceData?.internalURL || '' + }; + // console.log(`🎯 Merged service data:`, merged); + return merged; + }); + } + + getServiceIcon(serviceName) { + const icons = { + 'webui': '🌐', + 'ssh': '🔒', + 'dns': '🌍', + 'api': '🔌', + 'admin': '⚙️', + 'dashboard': '📊', + 'default': '🔗' + }; + return icons[serviceName.toLowerCase()] || icons['default']; + } + + // Generate HTML for service buttons + async generateButtonsHTML(appName) { + const appServices = await this.getServicesForApp(appName); + + if (appServices.length === 0) { + return ''; + } + + return appServices.map(service => { + // Use externalURL if available, otherwise construct from serverIP and port + let url; + const proto = ['http', 'https'].includes((service.protocol || '').toLowerCase()) ? service.protocol.toLowerCase() : 'http'; + if (service.externalURL) { + url = service.externalURL; + } else if (service.serverIP && service.externalPort) { + url = `${proto}://${service.serverIP}:${service.externalPort}`; + } else if (service.externalPort && service.externalPort !== 'random') { + url = `${proto}://localhost:${service.externalPort}`; + } else { + return ''; + } + + const icon = this.getServiceIcon(service.name); + const protectedClass = service.loginRequired ? ' protected' : ''; + const lockIcon = service.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; + + return ` + + ${icon} + ${service.buttonText} + ${lockIcon} + + + `; + }).join(''); + } + + lockIconSVG(title) { + return ` + + `; + } + + // Update service buttons in app-header + async updateSidebar(appName) { + const buttonsContainer = document.getElementById('service-buttons-container'); + if (!buttonsContainer) return; + + if (this.services.length === 0) { + await this.loadServices(); + } + + // Use apps-services.json as primary source — filter by app and buttonEnabled flag + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + const appServices = this.services.filter(s => s.app === appName && s.buttonEnabled === true); + + // Welcome chip — first slot, before the URL buttons. + const welcomeBtn = ` + `; + + if (appServices.length === 0) { + buttonsContainer.innerHTML = welcomeBtn; + buttonsContainer.style.display = 'flex'; + return; + } + + // Multi-button render via the shared expandServiceLinks() helper. + buttonsContainer.innerHTML = welcomeBtn + appServices.flatMap(s => { + const protectedClass = s.loginRequired ? ' protected' : ''; + const lockIcon = s.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; + return window.expandServiceLinks(s).map(({ url, label }) => ` + + + + + + + ${label} + ${lockIcon} + + + `); + }).filter(Boolean).join(''); + + buttonsContainer.style.display = 'flex'; + } +} + +// Global instance +window.appsManager = new AppsManager(); +window.serviceButtons = new ServiceButtons(); + +// Toggle service trigger popup — one open at a time, click-outside to close +window.toggleServiceTrigger = (appName) => { + const clicked = document.getElementById(`service-trigger-${appName}`); + if (!clicked) return; + const isOpen = clicked.classList.contains('open'); + // Close all open triggers + document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); + if (!isOpen) clicked.classList.add('open'); +}; + +document.addEventListener('click', () => { + document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); +}); + +document.addEventListener('change', (e) => { + if (!e.target?.classList?.contains('advanced-fields-checkbox')) return; + const panel = e.target.closest('.panel-fields') || e.target.closest('.config-section') || document; + panel.querySelectorAll('.advanced-field').forEach(el => { + el.classList.toggle('is-hidden', !e.target.checked); + }); +}); diff --git a/containers/libreportal/frontend/js/components/app/port-manager.js b/containers/libreportal/frontend/js/components/app/port-manager.js new file mode 100755 index 0000000..87ab288 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/port-manager.js @@ -0,0 +1,821 @@ +// Port Manager Component - Handles port configuration for apps +class PortManager { + constructor() { + this.ports = []; + this.availableServices = []; + this.appName = null; + } + + // Parse port configuration string into array of port objects + parsePortConfig(configInput) { + // Accepts either a single CFG__PORT_N string OR an array of + // them (preferred). The array form avoids the comma-join/split + // round-trip that would shred multi-button button_text/url_path + // values like "Speedtest,Results" and "/,/results/stats.php". + let portEntries; + if (Array.isArray(configInput)) { + portEntries = configInput.filter(s => s && String(s).trim() !== ''); + } else { + if (!configInput || String(configInput).trim() === '') return []; + portEntries = String(configInput).split(','); + } + const ports = []; + + portEntries.forEach(entry => { + const parts = entry.trim().split('|'); + // 12-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path|subdomain|recommended + // 10-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path + // 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label + // 8-col: parent|name|ext:int|access|proto|traefik|webui|label (legacy, login defaults to false) + if (parts.length >= 8) { + const portMapping = parts[2].split(':'); + const external = portMapping[0] || ''; + const internal = portMapping[1] || ''; + + const isTenCol = parts.length >= 10; + const isNineCol = parts.length >= 9; + const isTwelveCol = parts.length >= 12; + // Recommended defaults to the webui flag when not stored on the row — + // matches the panel's "primary list" expectation for apps that haven't + // been migrated yet. + const buttonEnabled = isNineCol ? (parts[7] === 'true') : (parts[6] === 'true'); + ports.push({ + service: parts[0] || '', + name: parts[1] || '', + external: external, + internal: internal, + access: parts[3] || 'private', + protocol: parts[4] || 'tcp', + login_required: isNineCol ? (parts[5] === 'true') : false, + traefik_managed: isNineCol ? (parts[6] === 'true') : (parts[5] === 'true'), + button_enabled: buttonEnabled, + button_text: isNineCol ? (parts[8] || '') : (parts[7] || ''), + url_path: isTenCol ? (parts[9] || '') : '', + subdomain: isTwelveCol ? (parts[10] || '') : '', + recommended: isTwelveCol ? (parts[11] === 'true') : buttonEnabled + }); + } + }); + + return ports; + } + + // Generate port configuration string from array of port objects. + // Always emits the 12-col format (adds subdomain + recommended on top of the + // existing 10-col login_required + url_path schema) so saves forward-migrate + // any 8/9/10-col legacy entries automatically. + generatePortConfig(ports) { + return ports.map(port => { + const portMapping = `${port.external}:${port.internal}`; + const login = port.login_required ? 'true' : 'false'; + const urlPath = port.url_path || ''; + const subdomain = port.subdomain || ''; + const recommended = port.recommended ? 'true' : 'false'; + return `${port.service}|${port.name}|${portMapping}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${urlPath}|${subdomain}|${recommended}`; + }).join(','); + } + + // Get available services for an app + async getAvailableServices(appName) { + //console.log(`🔌 PortManager: Getting services for app: ${appName}`); + try { + // Load apps data to get services for this app (with cache busting) + const timestamp = Date.now(); + const response = await fetch(`/data/apps/generated/apps.json?t=${timestamp}`); + if (!response.ok) { + //console.log(`🔌 Failed to fetch apps.json: ${response.status}`); + return []; + } + + const appsData = await response.json(); + //console.log(`🔌 Apps data loaded:`, appsData); + //console.log(`🔌 Available app names:`, appsData.apps.map(app => app.name)); + + let app = appsData.apps.find(a => a.name === appName); + //console.log(`🔌 Found app for ${appName}:`, app); + + // Try fuzzy matching if exact match fails + if (!app) { + const fuzzyApp = appsData.apps.find(a => + a.name.toLowerCase().includes(appName.toLowerCase()) || + appName.toLowerCase().includes(a.name.toLowerCase()) + ); + //console.log(`🔌 Fuzzy match for ${appName}:`, fuzzyApp); + if (fuzzyApp) { + app = fuzzyApp; + } + } + + if (app && app.services) { + //console.log(`🔌 Services found for ${appName}:`, app.services); + return app.services; + } + + //console.log(`🔌 No services found for ${appName}`); + return []; + } catch (error) { + console.error('Error loading services:', error); + return []; + } + } + + // Validate port configuration + validatePort(portData, allPorts) { + const errors = []; + + // Check required fields + if (!portData.service) errors.push('Service is required'); + if (!portData.internal) errors.push('Internal port is required'); + + // Check port format + if (portData.external && !portData.external.match(/^(random|\d+(:\d+)?)$/)) { + errors.push('External port must be "random" or in format "8080" or "8080:8081"'); + } + + if (portData.internal && !portData.internal.match(/^\d+$/)) { + errors.push('Internal port must be a valid port number'); + } + + // Check for conflicts (excluding current port) + const conflicts = allPorts.filter((port, index) => { + return port.internal === portData.internal && index !== allPorts.indexOf(portData); + }); + + if (conflicts.length > 0) { + errors.push(`Internal port ${portData.internal} conflicts with another service`); + } + + return errors; + } + + // Add new port + addPort() { + const newPort = { + service: '', + external: 'random', + internal: '', + access: 'private', + protocol: 'tcp', + url_accessible: false, + traefik_managed: true, + login_required: false, + label: '', + autoMatched: false // Track if service was auto-matched + }; + + // Auto-select service if only one is available + if (this.availableServices.length === 1) { + newPort.service = this.availableServices[0]; + newPort.autoMatched = true; + } + + this.ports.push(newPort); + return newPort; + } + + // Remove port with confirmation + async removePort(index) { + const port = this.ports[index]; + if (!port) return false; + + // Get app name from the port manager + const appName = this.appName || document.querySelector('.port-manager')?.dataset.app; + // Use internal port if available, otherwise show port number (index + 1) + const portDisplay = port.internal ? `#${port.internal}` : `#${index + 1}`; + + // Get app title from the apps data for better display + let appTitle = appName; // fallback to app name + try { + const response = await fetch(`/data/apps/generated/apps.json?t=${Date.now()}`); + if (response.ok) { + const appsData = await response.json(); + const app = appsData.apps.find(a => a.name === appName) || + appsData.apps.find(a => a.name.toLowerCase().includes(appName.toLowerCase())); + if (app) { + appTitle = app.title || appName; + } + } + } catch (error) { + //console.log('Could not fetch app title, using app name'); + } + + // Show confirmation dialog + const confirmed = await this.showConfirmation( + 'Remove Port', + `Are you sure you want to remove port ${portDisplay} from ${appTitle}?`, + 'Remove' + ); + + if (confirmed) { + this.ports.splice(index, 1); + return true; + } + + return false; + } + + // Show confirmation dialog + showConfirmation(title, message, confirmText) { + return new Promise((resolve) => { + // Create modal overlay + const modal = document.createElement('div'); + modal.className = 'port-manager-modal-overlay'; + modal.innerHTML = ` +
+
+

${title}

+ +
+
+

${message}

+
+ +
+ + `; + + // Add to page + document.body.appendChild(modal); + + // Handle events + const close = () => { + document.body.removeChild(modal); + resolve(false); + }; + + const confirm = () => { + document.body.removeChild(modal); + resolve(true); + }; + + modal.querySelector('.port-manager-modal-close').addEventListener('click', close); + modal.querySelector('.port-manager-cancel').addEventListener('click', close); + modal.querySelector('.port-manager-confirm').addEventListener('click', confirm); + modal.addEventListener('click', (e) => { + if (e.target === modal) close(); + }); + }); + } + + // Generate HTML for port manager + generateHTML(appName, configValue) { + // Parse existing configuration + this.ports = this.parsePortConfig(configValue); + + let html = ` +
+
+

Port Configuration

+
+ + +
+
+
+ `; + + // Generate port cards (simplified without individual headers) + this.ports.forEach((port, index) => { + html += this.generateSimplePortCard(port, index); + }); + + html += ` +
+ +
+ `; + + return html; + } + + // Generate simplified HTML for individual port card (with header) + generateSimplePortCard(port, index) { + return ` +
+
+
Port ${index + 1}
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + } + + // Generate HTML for individual port card + generatePortCard(port, index) { + return ` +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + } + + // Initialize port manager after HTML is generated + async initialize(appName) { + this.appName = appName; + this.availableServices = await this.getAvailableServices(appName); + + //console.log(`🔌 PortManager: Available services for ${appName}:`, this.availableServices); + //console.log(`🔌 PortManager: Number of services: ${this.availableServices.length}`); + + // Force full-width layout for port manager containers + this.forceFullWidthLayout(); + + // Populate service dropdowns with auto-matching + const serviceSelects = document.querySelectorAll('.port-service'); + serviceSelects.forEach(select => { + const index = parseInt(select.dataset.index); + const currentService = this.ports[index]?.service || ''; + + //console.log(`🔌 PortManager: Port ${index} current service: "${currentService}"`); + + // Clear existing options + select.innerHTML = ''; + + // Add service options + this.availableServices.forEach(service => { + const option = document.createElement('option'); + option.value = service; + option.textContent = service; + + // Auto-matching logic + let shouldAutoSelect = false; + let isAutoMatched = false; + + if (this.availableServices.length === 1 && !currentService) { + // Case 1: Only one service and no current service - auto-select + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Auto-selecting single service "${service}" for port ${index}`); + } else if (this.availableServices.length === 1 && currentService && currentService !== service) { + // Case 2: Only one service but current service doesn't match - auto-match + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Auto-matching service "${service}" (was "${currentService}") for port ${index}`); + } else if (this.availableServices.length === 1 && currentService === service) { + // Case 3: Single service and current service matches - still show auto-match indicator + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Single service matches "${service}" for port ${index} - showing auto-match indicator`); + } else if (this.availableServices.length > 1 && currentService === service) { + // Case 4: Multiple services and current service matches - normal selection + shouldAutoSelect = true; + isAutoMatched = false; + //console.log(`🔌 PortManager: Normal selection of service "${service}" for port ${index}`); + } + + if (shouldAutoSelect) { + option.selected = true; + // Update the port data to reflect the selected service + if (this.ports[index]) { + this.ports[index].service = service; + // Mark if this was auto-matched + this.ports[index].autoMatched = isAutoMatched; + } + } + + select.appendChild(option); + }); + + // Add visual indicator for auto-matched services + if (this.ports[index]?.autoMatched) { + select.style.borderColor = '#28a745'; // Green border for auto-matched + select.style.boxShadow = '0 0 0 2px rgba(40, 167, 69, 0.3)'; + + // Show the auto-match indicator next to the help icon + const autoMatchIndicator = select.parentElement.querySelector('.auto-match-indicator'); + if (autoMatchIndicator) { + autoMatchIndicator.style.display = 'inline-flex'; + } + } + }); + + // Add event listeners + this.attachEventListeners(); + + // Update all port fields after auto-selection + this.updateAllPortFields(); + } + + // Force full-width layout for port manager containers + forceFullWidthLayout() { + const portContainers = document.querySelectorAll('.form-field[id^="PORT_"]'); + portContainers.forEach(container => { + container.style.gridColumn = '1 / -1'; + container.style.width = '100%'; + }); + } + + // Attach event listeners + attachEventListeners() { + // Add port button + const addBtn = document.querySelector('.add-port-btn'); + if (addBtn) { + addBtn.addEventListener('click', () => { + const newPort = this.addPort(); + this.refreshPortList(); + }); + } + + // Remove port buttons + document.querySelectorAll('.remove-port-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const index = parseInt(e.currentTarget.dataset.index); + const removed = await this.removePort(index); + if (removed) { + this.refreshPortList(); + } + }); + }); + + // Show-advanced toggle — flips `show-advanced` class on the port-manager root; + // CSS hides .port-field-advanced when the class isn't present. + document.querySelectorAll('.port-manager-show-advanced').forEach(cb => { + const mgr = cb.closest('.port-manager'); + const apply = () => { if (mgr) mgr.classList.toggle('show-advanced', cb.checked); }; + cb.addEventListener('change', apply); + apply(); + }); + + // Port field changes. Selector must include EVERY rendered class — fields not + // listed here silently no-op when the user edits them, so port name and button + // text edits were never persisted into the hidden CFG_*_PORT_N fields. + document.querySelectorAll( + '.port-service, .port-name, .port-external, .port-internal, ' + + '.port-access, .port-protocol, .port-traefik, ' + + '.port-button-enabled, .port-button-text, .port-url-path, ' + + '.port-recommended, .port-subdomain' + ).forEach(field => { + field.addEventListener('change', () => this.updatePortData(field)); + field.addEventListener('input', () => this.updatePortData(field)); + }); + } + + // Update port data when field changes + updatePortData(field) { + const index = parseInt(field.dataset.index); + const port = this.ports[index]; + if (!port) return; + + // Match by classList rather than === on className so additional classes added + // elsewhere (e.g. validation styles) don't break the switch. + if (field.classList.contains('port-service')) { + port.service = field.value; + } else if (field.classList.contains('port-name')) { + port.name = field.value; + } else if (field.classList.contains('port-external')) { + port.external = field.value; + } else if (field.classList.contains('port-internal')) { + port.internal = field.value; + } else if (field.classList.contains('port-access')) { + port.access = field.value; + const portCard = field.closest('.port-card'); + if (portCard) portCard.setAttribute('data-access', field.value); + } else if (field.classList.contains('port-protocol')) { + port.protocol = field.value; + } else if (field.classList.contains('port-traefik')) { + // + + + `; + }; + + return ` +
+

🛡️ Traefik Routing

+

Toggle which app ports Traefik routes. Each change applied below reinstalls the affected app so the new traefik.enable label takes effect.

+
+ +
+
+

Recommended ${primary.length}

+ Ports flagged recommended=true in their PORT config. +
+
+ ${primary.length ? primary.map(renderRow).join('') : '
No recommended ports — install an app whose webui port is recommended.
'} +
+
+ +
+
+

Advanced ${advanced.length}

+ +
+
+ ${advanced.length ? advanced.map(renderRow).join('') : '
No additional TCP ports across installed apps.
'} +
+
+ +
+ No pending changes. + +
+ `; + } + + _wire(root) { + root.querySelectorAll('.routing-traefik').forEach(cb => { + cb.addEventListener('change', () => this._trackChange(cb)); + }); + const showAdv = root.querySelector('#routing-show-advanced'); + const advTable = root.querySelector('.routing-advanced-table'); + if (showAdv && advTable) { + const sync = () => { advTable.classList.toggle('routing-advanced-open', showAdv.checked); }; + showAdv.addEventListener('change', sync); + sync(); + } + root.querySelector('.routing-apply')?.addEventListener('click', () => this._apply()); + } + + _trackChange(cb) { + const row = cb.closest('.routing-row'); + if (!row) return; + const key = `${row.dataset.app}|${row.dataset.idx}`; + // If the new value matches the original (clicked twice), drop the entry. + const orig = row.querySelector('.routing-traefik').defaultChecked; + if (cb.checked === orig) this.changes.delete(key); + else this.changes.set(key, cb.checked); + + const root = document.getElementById('routing-list'); + const hint = root.querySelector('#routing-apply-hint'); + const applyBtn = root.querySelector('.routing-apply'); + const n = this.changes.size; + if (n === 0) { + hint.textContent = 'No pending changes.'; + applyBtn.disabled = true; + } else { + const apps = new Set([...this.changes.keys()].map(k => k.split('|')[0])); + hint.textContent = `${n} change${n === 1 ? '' : 's'} across ${apps.size} app${apps.size === 1 ? '' : 's'} will reinstall.`; + applyBtn.disabled = false; + } + } + + async _apply() { + if (this.changes.size === 0) return; + if (!window.tasksManager || !window.tasksManager.router) { + console.error('Tasks router not available'); + return; + } + const apps = window.apps || []; + const byApp = new Map(); + apps.forEach(a => byApp.set((a.command || '').split(' ').pop(), a)); + + // Group changes by app so each app reinstalls once with all its port edits. + const grouped = new Map(); + for (const [key, newTraefik] of this.changes) { + const [slug, idxStr] = key.split('|'); + const idx = parseInt(idxStr, 10); + const app = byApp.get(slug); + if (!app) continue; + const cfgKey = `CFG_${slug.toUpperCase()}_PORT_${idx}`; + const raw = String(app.config[cfgKey] || ''); + const parts = raw.split('|'); + if (parts.length < 8) continue; + // Field 7 is traefik in 9+col, field 6 in 8-col legacy. + if (parts.length >= 9) parts[6] = newTraefik ? 'true' : 'false'; + else parts[5] = newTraefik ? 'true' : 'false'; + const newValue = parts.join('|'); + if (!grouped.has(slug)) grouped.set(slug, {}); + grouped.get(slug)[cfgKey] = newValue; + } + + const slugs = [...grouped.keys()]; + for (const slug of slugs) { + try { + await window.tasksManager.router.routeAction('install', { + appName: slug, + config: grouped.get(slug) + }); + } catch (e) { + console.error(`Routing apply failed for ${slug}:`, e); + } + } + + this.changes.clear(); + if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { + window.appTabbedManager.switchTab('tasks'); + } + } +} + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} +function escapeAttr(s) { return escapeHtml(s); } + +window.routingManager = new RoutingManager(); diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js new file mode 100644 index 0000000..723d710 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/services-manager.js @@ -0,0 +1,507 @@ +// Services tab on the app detail page. +// +// Each row renders a single docker compose service with: +// - colored status dot (running / stopped / unknown) +// - service name + container name +// - port chips and an "Open" button when a public URL exists +// - "Up 2 hours" runtime text +// - restart button (creates a task in the existing task system) +// - expandable live log tail (SSE backed) +// +// Data sources, layered: +// 1. /data/apps/generated/apps-services.json — canonical list of services +// and their URLs/ports per app (already maintained by the WebUI updater). +// 2. /api/apps//services/status — live state from `docker ps`. +// +// The `apps-services.json` file has one row per port. We dedupe by +// serviceName so a service with multiple ports renders as one row with +// multiple port chips. + +class ServicesManager { + constructor() { + this.currentApp = null; + this.refreshTimer = null; + this.openLogStreams = new Map(); // serviceName -> { es, container } + this.servicesIndex = null; // app -> serviceName -> { ports[], urls[] } + } + + // Entrypoint called by app-tabbed-manager. + async load(appName) { + this.currentApp = appName; + this._stopAllLogs(); + const list = document.getElementById('services-list'); + if (!list) return; + + const title = this._titleBlock(appName); + + list.innerHTML = ` + ${title} +
+
+ Loading services… +
`; + + try { + const [aggregated, status] = await Promise.all([ + this._loadAggregated(appName), + this._fetchStatus(appName) + ]); + + const merged = this._merge(aggregated, status); + + if (merged.length === 0) { + list.innerHTML = ` + ${title} +
+ +

No running compose services found for ${escapeHtml(appName)}.

+

If the app is stopped, start it from the topbar; services will appear here once Docker reports them.

+
`; + return; + } + + list.innerHTML = ` + ${title} +
+ ${merged.map(svc => this._renderRow(svc)).join('')} +
`; + this._wireActions(list); + this._startRefreshLoop(); + } catch (err) { + console.error('Services load error', err); + list.innerHTML = ` + ${title} +
+ ⚠️ +

Failed to load services: ${escapeHtml(err.message || String(err))}

+
`; + } + } + + _titleBlock(appName) { + const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return ` +
+

⚡ Services

+

Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}

+
`; + } + + // Called when leaving the Services tab. Tear down timers and SSE. + unload() { + this._stopRefreshLoop(); + this._stopAllLogs(); + } + + // ------------------------------------------------------------------ + // Data loading + // ------------------------------------------------------------------ + + async _loadAggregated(appName) { + let raw = { apps: [] }; + try { + const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (resp.ok) raw = await resp.json(); + } catch { /* file may not exist on a brand-new install */ } + + const rows = Array.isArray(raw.apps) ? raw.apps : []; + const byService = new Map(); + for (const row of rows) { + if (row.app !== appName) continue; + const key = row.serviceName; + if (!byService.has(key)) { + byService.set(key, { + serviceName: row.serviceName, + serviceIP: row.serviceIP, + ports: [], + openUrl: null, + openLabel: null, + // One Open button per entry in this list. Populated from the + // generator's `links[]` array, which itself comes from the + // comma-separated label/path pairs in CFG__PORT_N. + openLinks: [] + }); + } + const entry = byService.get(key); + if (row.externalPort && row.internalPort) { + entry.ports.push({ + name: row.name, + external: row.externalPort, + internal: row.internalPort, + access: row.access, + protocol: row.protocol + }); + } + // Pick the first enabled URL as the row's primary "Open" target + // (kept for back-compat with anything reading openUrl/openLabel). + if (row.buttonEnabled && (row.externalURL || row.internalURL) && !entry.openUrl) { + entry.openUrl = row.externalURL || row.internalURL; + entry.openLabel = row.buttonText || 'Open'; + } + // Multi-button: append every link from this row, dedup'd by URL. + if (row.buttonEnabled && Array.isArray(row.links)) { + for (const link of row.links) { + const url = link.externalURL || link.internalURL; + if (!url) continue; + if (entry.openLinks.some(l => l.url === url)) continue; + entry.openLinks.push({ url, label: link.label || row.buttonText || 'Open' }); + } + } + } + return [...byService.values()].sort(this._compareServices); + } + + _compareServices(a, b) { + const aPrimary = /-service$/.test(a.serviceName) ? 0 : 1; + const bPrimary = /-service$/.test(b.serviceName) ? 0 : 1; + if (aPrimary !== bPrimary) return aPrimary - bPrimary; + return a.serviceName.localeCompare(b.serviceName); + } + + async _fetchStatus(appName) { + const resp = await fetch(`/api/apps/${encodeURIComponent(appName)}/services/status`, { cache: 'no-store' }); + if (!resp.ok) { + // Surface the backend's reason instead of silently empty-arraying. The + // most common cause is the docker socket mount being :ro, which blocks + // connect() — the resulting EACCES used to disappear into a blank tab. + const body = await resp.text().catch(() => ''); + let detail = `HTTP ${resp.status}`; + try { detail = JSON.parse(body).error || detail; } catch { /* not JSON */ } + throw new Error(`Status fetch failed: ${detail}`); + } + return await resp.json(); + } + + _merge(aggregated, status) { + const byName = new Map(status.map(s => [s.serviceName, s])); + const out = aggregated.map(svc => { + const live = byName.get(svc.serviceName) || {}; + byName.delete(svc.serviceName); + return { + ...svc, + state: live.state || 'unknown', + statusText: live.statusText || 'Container not found', + containerName: live.containerName || '' + }; + }); + // Any docker-reported services we didn't know about (e.g. an + // ephemeral helper container) — surface them too with no port info. + for (const live of byName.values()) { + out.push({ + serviceName: live.serviceName, + serviceIP: '', + ports: [], + openUrl: null, + openLabel: null, + state: live.state, + statusText: live.statusText, + containerName: live.containerName + }); + } + return out.sort(this._compareServices); + } + + // ------------------------------------------------------------------ + // Rendering + // ------------------------------------------------------------------ + + _renderRow(svc) { + const state = (svc.state || 'unknown').toLowerCase(); + const stateClass = `status-${state}`; + + // Mirror the task-list .task-info layout: status pill on the left, + // title, then chips. Ports collapse into the info row instead of + // sitting next to the action buttons so it reads the same as + // "duration"/"time" chips on a task row. + const portChips = svc.ports.map(p => ` + ${escapeHtml(p.external)}${escapeHtml(p.internal)}${escapeHtml(p.protocol || '')}`).join(''); + + const ipChip = svc.serviceIP + ? `${escapeHtml(svc.serviceIP)}` + : ''; + + // Multi-button render via the same expandServiceLinks() helper the + // other UI surfaces use, so all four button locations stay in sync + // (Services tab, app-header, apps-list popup, dashboard hover). + // Pre-merged svc.openLinks (built from per-row links[] in _loadAggregated) + // is preferred when present so the user gets a deduped list across + // multiple ports of the same service. + const linkArrowSvg = ``; + const renderOpenBtn = (url, label) => + `${linkArrowSvg}${escapeHtml(label || 'Open')}`; + const linksToRender = (Array.isArray(svc.openLinks) && svc.openLinks.length > 0) + ? svc.openLinks + : (svc.openUrl ? [{ url: svc.openUrl, label: svc.openLabel || 'Open' }] : []); + const openBtn = linksToRender.map(l => renderOpenBtn(l.url, l.label)).join(''); + + const iconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; + const iconHtml = iconUrl + ? `${escapeHtml(this.currentApp || '')}` + : ''; + + return ` +
+
+
+ ${iconHtml} + ${escapeHtml(svc.serviceName)} + ${escapeHtml(state.toUpperCase())} + ${escapeHtml(svc.statusText)} + ${portChips} + ${ipChip} +
+
+ ${openBtn} + + +
+
+
+
+
Service: ${escapeHtml(svc.serviceName)}
+ ${svc.containerName ? `
Container: ${escapeHtml(svc.containerName)}
` : ''} + ${svc.serviceIP ? `
IP: ${escapeHtml(svc.serviceIP)}
` : ''} +
State: ${escapeHtml(state)}
+
Status: ${escapeHtml(svc.statusText)}
+
+
+
+ +
+
+
`; + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + _wireActions(root) { + if (root.dataset.wired === '1') return; + root.dataset.wired = '1'; + root.addEventListener('click', async (ev) => { + const btn = ev.target.closest('[data-action]'); + if (!btn) return; + const item = btn.closest('.service-item'); + if (!item) return; + const serviceName = item.dataset.service; + const action = btn.dataset.action; + + if (action === 'restart') { + await this._restartService(serviceName, btn); + } else if (action === 'toggle-logs') { + this._toggleLogs(item, serviceName); + } else if (action === 'resume-logs') { + this._resumeLogs(item, serviceName); + } + }); + } + + async _restartService(serviceName, btn) { + if (!this.currentApp) return; + btn.disabled = true; + btn.classList.add('is-running'); + try { + const resp = await fetch( + `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`, + { method: 'POST', headers: { 'Content-Type': 'application/json' } } + ); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${resp.status}`); + } + // Background task processor picks it up — refresh status shortly. + setTimeout(() => this._refreshStatusOnly(), 2500); + setTimeout(() => this._refreshStatusOnly(), 7000); + } catch (e) { + alert(`Restart failed: ${e.message}`); + } finally { + setTimeout(() => { + btn.disabled = false; + btn.classList.remove('is-running'); + }, 1500); + } + } + + _toggleLogs(item, serviceName) { + // The task-list uses a .task-details-open class (not the `hidden` + // attribute) because .task-details has `display: none` baked in. + const details = item.querySelector('.task-details'); + const output = item.querySelector('.service-log-output'); + if (!details || !output) return; + + const isOpen = details.classList.contains('task-details-open'); + if (isOpen) { + details.classList.remove('task-details-open'); + this._closeLogStream(serviceName); + this._hideLogOverlay(output); + return; + } + + details.classList.add('task-details-open'); + output.textContent = ''; + this._hideLogOverlay(output); + output.dataset.stream = 'connecting'; + this._openLogStream(serviceName, output); + } + + _openLogStream(serviceName, outputEl) { + if (!this.currentApp) return; + this._closeLogStream(serviceName); + + const url = `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/logs?tail=200`; + const es = new EventSource(url); + + es.addEventListener('ready', () => { + outputEl.dataset.stream = 'live'; + }); + es.addEventListener('log', (ev) => { + try { + const data = JSON.parse(ev.data); + const text = (data.lines || []).join('\n') + '\n'; + const wasAtBottom = isScrolledToBottom(outputEl); + outputEl.appendChild(document.createTextNode(text)); + // Cap buffer at ~1000 lines to keep the DOM cheap. + const lines = outputEl.textContent.split('\n'); + if (lines.length > 1000) { + outputEl.textContent = lines.slice(-1000).join('\n'); + } + if (wasAtBottom) outputEl.scrollTop = outputEl.scrollHeight; + } catch { /* ignore malformed event */ } + }); + es.addEventListener('error', () => { + // EventSource auto-reconnects; reflect connection state. + outputEl.dataset.stream = 'disconnected'; + }); + es.addEventListener('end', (ev) => { + let detail = {}; + try { detail = JSON.parse(ev.data || '{}'); } catch { /* ignore */ } + outputEl.dataset.stream = 'closed'; + this._closeLogStream(serviceName); + // Server-side timeouts surface as `end` events with a reason — pop + // the Resume overlay so the user can re-open the stream with one + // click. The displayed log buffer is preserved. + if (detail.reason === 'idle-timeout' || detail.reason === 'max-duration') { + this._showLogOverlay(outputEl, detail); + } + }); + + this.openLogStreams.set(serviceName, { es }); + } + + _showLogOverlay(outputEl, detail) { + const wrap = outputEl.closest('.task-logs'); + const overlay = wrap?.querySelector('.service-log-overlay'); + const msg = overlay?.querySelector('.service-log-overlay-msg'); + if (!overlay || !msg) return; + const minutes = detail.limitMinutes ? Math.round(detail.limitMinutes) : ''; + if (detail.reason === 'idle-timeout') { + msg.textContent = `Stream paused — no log activity for ${minutes} minute${minutes === 1 ? '' : 's'}.`; + } else { + msg.textContent = `Stream stopped — reached the ${minutes}-minute cap.`; + } + overlay.style.display = 'flex'; + } + + _hideLogOverlay(outputEl) { + const wrap = outputEl.closest('.task-logs'); + const overlay = wrap?.querySelector('.service-log-overlay'); + if (overlay) overlay.style.display = 'none'; + } + + _resumeLogs(item, serviceName) { + const output = item.querySelector('.service-log-output'); + if (!output) return; + this._hideLogOverlay(output); + output.dataset.stream = 'connecting'; + this._openLogStream(serviceName, output); + } + + _closeLogStream(serviceName) { + const entry = this.openLogStreams.get(serviceName); + if (!entry) return; + try { entry.es.close(); } catch { /* already closed */ } + this.openLogStreams.delete(serviceName); + } + + _stopAllLogs() { + for (const [name] of this.openLogStreams) this._closeLogStream(name); + } + + // ------------------------------------------------------------------ + // Status refresh loop (only updates dots/text, not full re-render) + // ------------------------------------------------------------------ + + _startRefreshLoop() { + this._stopRefreshLoop(); + this.refreshTimer = setInterval(() => this._refreshStatusOnly(), 10_000); + } + + _stopRefreshLoop() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + async _refreshStatusOnly() { + if (!this.currentApp) return; + if (!document.getElementById('services-tab')?.classList.contains('active')) return; + let status; + try { status = await this._fetchStatus(this.currentApp); } + catch { return; } + + for (const live of status) { + const item = document.querySelector(`.service-item[data-service="${cssEscape(live.serviceName)}"]`); + if (!item) continue; + const state = (live.state || 'unknown').toLowerCase(); + item.dataset.state = state; + const dot = item.querySelector('.service-dot'); + if (dot) { + dot.className = `service-dot service-dot-${state}`; + dot.title = live.statusText || ''; + } + const txt = item.querySelector('.service-status-text'); + if (txt) txt.textContent = live.statusText || ''; + } + } +} + +// Tiny helpers ---------------------------------------------------------- + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function cssEscape(s) { + if (window.CSS && CSS.escape) return CSS.escape(s); + return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c); +} + +function isScrolledToBottom(el) { + return el.scrollHeight - el.clientHeight - el.scrollTop < 4; +} + +// Singleton — app-tabbed-manager calls `window.servicesManager.load(app)`. +window.servicesManager = new ServicesManager(); diff --git a/containers/libreportal/frontend/js/components/app/tools-manager.js b/containers/libreportal/frontend/js/components/app/tools-manager.js new file mode 100644 index 0000000..f0b9ef0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/tools-manager.js @@ -0,0 +1,872 @@ +// Tools tab on the app detail page. +// +// Each app may declare per-app actions ("tools") in +// containers//.tools.json +// served by GET /api/apps//tools. Each tool is rendered as a card +// with a button. Clicking the button either runs the tool directly (no +// fields, no confirm) or opens a generic modal that collects the tool's +// inputs. +// +// On submit we dispatch through the existing TaskRouter: +// TaskRouter.routeAction('tool', { appName, toolName, toolArgs }) +// which builds: libreportal app tool '' +// — same task pipeline as install/restart/etc., so the Tasks tab and +// log streaming come for free. +// +// Manifest schema (containers//.tools.json): +// { +// "tools": [ +// { +// "id": "", // matches the case in Tool() +// "label": "Refresh providers", +// "description": "...", // optional, shown on card + modal +// "icon": "🔄", // optional emoji +// "destructive": false, // optional, renders red button +// "confirm": "Are you sure?", // optional, forces a modal +// "fields": [ +// { "name": "duration", +// "label": "Duration (s)", +// "type": "number", // text|password|number|select|checkbox|textarea +// "default": 5, +// "placeholder": "5", +// "required": true, +// "min": 1, "max": 60 }, +// { "name": "mode", +// "label": "Mode", +// "type": "select", +// "options": [ +// { "value": "fast", "label": "Fast" }, +// { "value": "slow", "label": "Slow" } +// ] } +// ] +// } +// ] +// } + +class ToolsManager { + constructor() { + this.currentApp = null; + this.tools = []; + // Cache of manifest results per app so prepare() doesn't re-fetch on + // every tab switch. Cleared by reset(). + this._manifestCache = new Map(); + + // Re-check Tools tab visibility on focus + task completion. + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (!document.hidden && this.currentApp) this._revalidateCurrent(); + }); + } + if (typeof window !== 'undefined') { + window.addEventListener('taskCompleted', (ev) => { + if (this.currentApp) this._revalidateCurrent(); + this._maybeOpenUserListModal(ev?.detail); + }); + } + } + + // When a list_users task completes, fetch its log, parse EZ_USER\t lines, + // and open an interactive modal where each row has action buttons. + async _maybeOpenUserListModal(detail) { + if (!detail || detail.status !== 'completed') return; + const cmd = String(detail.command || detail.task?.command || ''); + const m = cmd.match(/libreportal app tool (\S+) list_users\b/); + if (!m) return; + const appName = m[1]; + const taskId = detail.taskId || detail.id || detail.task?.id; + if (!taskId) return; + + let logText = ''; + try { + const r = await fetch(`/read-file?path=tasks/${encodeURIComponent(taskId)}.log`, { cache: 'no-store' }); + if (r.ok) logText = await r.text(); + } catch (_) {} + + const users = []; + logText.split(/\r?\n/).forEach(line => { + const i = line.indexOf('EZ_USER\t'); + if (i < 0) return; + const parts = line.slice(i + 8).split('\t'); + const [email = '', username = '', roles = ''] = parts; + if (!email && !username) return; + users.push({ email: email.trim(), username: username.trim(), roles: roles.trim() }); + }); + + this._openUserListModal(appName, users); + } + + _openUserListModal(appName, users) { + const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || []; + const resetTool = tools.find(t => t.id === 'reset_password'); + const deleteTool = tools.find(t => t.id === 'delete_user'); + const adminTool = tools.find(t => t.id === 'set_admin'); + const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName); + const iconUrl = `/icons/apps/${encodeURIComponent(appName)}.svg`; + + // Reopen this exact modal (used as returnTo when a row action's + // sub-modal is cancelled — keeps the user in flow instead of + // dumping them out to the Tools tab). + const reopen = () => this._openUserListModal(appName, users); + + const rowsHtml = users.length + ? users.map((u, idx) => { + const isAdmin = /admin/i.test(u.roles || ''); + return ` +
+
+
${escapeHtml(u.email || u.username || '—')}
+ ${u.username && u.username !== u.email ? `
${escapeHtml(u.username)}
` : ''} + ${u.roles && u.roles !== '—' ? `
${escapeHtml(u.roles)}
` : ''} +
+
+ ${resetTool ? `` : ''} + ${adminTool ? `` : ''} + ${deleteTool ? `` : ''} +
+
`; + }).join('') + : window.eoEmpty('No users found.'); + + const mod = window.openEoModal({ + id: 'user-list-modal', + size: 'sm', + icon: iconUrl, + iconAlt: appLabel, + eyebrow: 'Users', + title: `${appLabel} Accounts`, + desc: users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : '', + body: `
${rowsHtml}
`, + actions: [{ label: 'Close', variant: 'secondary' }] + }); + + const fillIdentifier = (tool, idValue) => { + const prefill = {}; + if (tool.fields?.some(f => f.name === 'email')) prefill.email = idValue; + if (tool.fields?.some(f => f.name === 'username')) prefill.username = idValue; + return prefill; + }; + + mod.contentEl.querySelectorAll('.user-row-btn').forEach(btn => { + btn.addEventListener('click', () => { + const u = users[parseInt(btn.dataset.idx, 10)]; + const idValue = u.email || u.username; + const act = btn.dataset.act; + const tool = act === 'reset' ? resetTool : act === 'delete' ? deleteTool : adminTool; + if (!tool) return; + const prefill = fillIdentifier(tool, idValue); + // For toggle-admin we also pre-set the boolean. + if (act === 'admin' && tool.fields?.some(f => f.name === 'admin')) { + prefill.admin = !(/admin/i.test(u.roles || '')); + } + mod.close(); + this._activate(tool, { prefill, returnTo: reopen }); + }); + }); + } + + async _revalidateCurrent() { + const app = this.currentApp; + if (!app) return; + this._manifestCache.delete(app); + this._aggregatePromise = null; + try { await this.prepare(app); } catch (_) { /* swallow */ } + } + + // Called when the app changes. Fetches the manifest once and toggles the + // Tools tab buttons' visibility. Apps with no tools never expose the tab. + async prepare(appName) { + if (!appName) { + this._toggleTabVisibility(false); + return { tools: [] }; + } + let result = this._manifestCache.get(appName); + if (!result) { + result = await this._fetchManifest(appName); + // Only cache non-empty success results. An empty list usually means + // apps-tools.json was briefly stale (mid-regen during install) — if + // we cache that, the tab stays hidden until full page reload. By + // also dropping the aggregate promise we force the next prepare() + // call to refetch the JSON fresh. + if (!result.error && result.tools.length > 0) { + this._manifestCache.set(appName, result); + } else { + this._aggregatePromise = null; + } + } + // Tools act on a live container — only expose the tab when the + // app is installed. apps-manager.js already does this on render + // for backup/services/tools, but prepare() can re-fire after that + // (visibilitychange, taskCompleted, app switches), so re-check + // here too — otherwise we'd silently re-show the tab for a + // not-installed app whenever we revalidate. + const installed = this._isAppInstalled(appName); + this._toggleTabVisibility(installed && result.tools.length > 0); + return result; + } + + _isAppInstalled(appName) { + if (!appName) return false; + const target = String(appName).toLowerCase(); + const entry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop() || '').toLowerCase() === target + ); + return !!entry?.installed; + } + + // Drop cached manifests so the next prepare() re-fetches. Called when + // an app's compose/install changes — currently a no-op caller side, but + // keeps the door open for cache invalidation. + reset() { + this._manifestCache.clear(); + this._aggregatePromise = null; + } + + // The whole apps-tools.json aggregate, fetched once per page load. + // Generated by scripts/webui/data/generators/apps/webui_tools.sh. + // Also populates window.toolsCatalog so other components (e.g. + // tasks-manager.js's formatCommandForUser) can look up tool labels + // without re-fetching. + async _loadAggregate() { + if (this._aggregatePromise) return this._aggregatePromise; + this._aggregatePromise = (async () => { + try { + const resp = await fetch('/data/apps/generated/apps-tools.json', { cache: 'no-store' }); + if (!resp.ok) return { apps: {}, error: `HTTP ${resp.status}` }; + const data = await resp.json(); + const apps = data && typeof data.apps === 'object' ? data.apps : {}; + window.toolsCatalog = { apps }; + return { apps }; + } catch (err) { + return { apps: {}, error: err.message || String(err) }; + } + })(); + return this._aggregatePromise; + } + + async _fetchManifest(appName) { + const agg = await this._loadAggregate(); + if (agg.error) return { tools: [], error: agg.error }; + const entry = agg.apps[appName]; + return { tools: Array.isArray(entry?.tools) ? entry.tools : [] }; + } + + _toggleTabVisibility(show) { + const buttons = document.querySelectorAll('[data-tab="tools"]'); + buttons.forEach(btn => { + btn.style.display = show ? '' : 'none'; + }); + } + + async load(appName) { + this.currentApp = appName; + const list = document.getElementById('tools-list'); + if (!list) return; + + list.innerHTML = ` + ${this._titleBlock(appName)} +
+
+ Loading tools… +
`; + + const result = await this.prepare(appName); + this.tools = this._sortTools(result.tools); + + if (result.error) { + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ ⚠️ +

Couldn't load tools right now.

+
`; + return; + } + + if (this.tools.length === 0) { + // Tab should already be hidden by prepare(); render a soft message + // anyway in case the user got here via a deep link. + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ 🧰 +

This app has no tools.

+
`; + return; + } + + const grouped = this._groupByCategory(this.tools); + if (grouped.size > 1) { + const cats = [...grouped.keys()]; + const tabsHtml = cats.map((cat, i) => ` + `).join(''); + const panesHtml = cats.map((cat, i) => ` +
+ ${grouped.get(cat).map(t => this._renderRow(t)).join('')} +
`).join(''); + list.innerHTML = ` + ${this._titleBlock(appName)} +
${tabsHtml}
+ ${panesHtml}`; + this._wireTabs(list); + } else { + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ ${this.tools.map(t => this._renderRow(t)).join('')} +
`; + } + this._wireActions(list); + } + + // Bucket tools by their `category` field into an insertion-ordered map. + // Tools without a category land in "general". Categories appear in the + // order they're first seen (matches authoring order in webui_tools.sh). + _groupByCategory(tools) { + const out = new Map(); + for (const t of tools) { + const cat = (typeof t.category === 'string' && t.category.trim()) ? t.category.trim() : 'general'; + if (!out.has(cat)) out.set(cat, []); + out.get(cat).push(t); + } + return out; + } + + _categoryLabel(cat) { + return String(cat).replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + + _wireTabs(root) { + const bar = root.querySelector('.tools-tab-bar'); + if (!bar) return; + bar.addEventListener('click', (ev) => { + const btn = ev.target.closest('.tools-tab'); + if (!btn) return; + const cat = btn.dataset.cat; + bar.querySelectorAll('.tools-tab').forEach(b => b.classList.toggle('active', b === btn)); + root.querySelectorAll('.tools-cat-pane').forEach(p => p.classList.toggle('active', p.dataset.cat === cat)); + }); + } + + unload() { /* no timers/streams */ } + + _titleBlock(appName) { + const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return ` +
+

🧰 Tools

+

Run app-specific actions for ${escapeHtml(display)} — each tool creates a task.

+
`; + } + + _renderRow(tool) { + const icon = tool.icon || '⚙️'; + const desc = tool.description ? `

${escapeHtml(tool.description)}

` : ''; + const btnClass = tool.destructive ? 'tool-run-btn destructive' : 'tool-run-btn'; + + return ` +
+
+
+ ${escapeHtml(icon)} + ${escapeHtml(tool.label || tool.id)} +
+ ${desc} +
+
+ +
+
`; + } + + _wireActions(root) { + if (root.dataset.wired === '1') return; + root.dataset.wired = '1'; + root.addEventListener('click', (ev) => { + const btn = ev.target.closest('[data-action="run"]'); + if (!btn) return; + const item = btn.closest('.tool-item'); + if (!item) return; + const toolId = item.dataset.toolId; + const tool = this.tools.find(t => t.id === toolId); + if (!tool) return; + this._activate(tool); + }); + } + + // Tools render in this order: explicit `order` field if present, + // otherwise the default precedence map (least → most destructive), + // otherwise their authoring order from webui_tools.sh. + _sortTools(tools) { + if (!Array.isArray(tools) || tools.length < 2) return tools || []; + const defaults = { + reset_password: 10, + apply_dns_updater: 15, + manage_shortcuts: 20, + refresh_providers: 20, + create_account: 30, + list_users: 40, + set_admin: 50, + delete_user: 90, + }; + const weight = (t, idx) => { + if (typeof t.order === 'number') return t.order; + if (defaults[t.id] !== undefined) return defaults[t.id]; + return 1000 + idx; + }; + return tools.map((t, i) => [weight(t, i), i, t]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]) + .map(x => x[2]); + } + + _activate(tool, opts) { + const hasFields = Array.isArray(tool.fields) && tool.fields.length > 0; + if (!hasFields && !tool.confirm) { + this._dispatch(tool, ''); + return; + } + this._openModal(tool, opts); + } + + _openModal(tool, opts) { + const fields = Array.isArray(tool.fields) ? tool.fields : []; + const prefill = (opts && opts.prefill) || {}; + const returnTo = opts && opts.returnTo; + let submitted = false; + const appIconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; + const bodyHtml = ` + ${tool.confirm ? `
${escapeHtml(tool.confirm)}
` : ''} +
+ ${fields.map(f => this._renderField(prefill[f.name] !== undefined ? { ...f, default: prefill[f.name] } : f)).join('')} +
`; + + const widePicker = fields.some(f => f.type === 'app_urls_multi' || f.type === 'installed_apps_multi'); + const m = window.openEoModal({ + id: 'tool-run-modal', + className: 'tool-modal', + size: widePicker ? 'md' : 'sm', + icon: appIconUrl || undefined, + iconAlt: this.currentApp || '', + title: tool.label || tool.id, + endIcon: tool.icon || undefined, + body: bodyHtml, + actions: [ + { label: 'Run', variant: 'primary', onClick: (modal) => { + const args = this._collectFormArgs(modal.contentEl, fields); + if (args === null) return; + submitted = true; + modal.close(); + this._dispatch(tool, args); + }}, + { label: 'Cancel', variant: 'secondary', onClick: (modal) => modal.close() } + ], + onClose: () => { + if (!submitted && typeof returnTo === 'function') { + try { returnTo(); } catch (e) { console.error(e); } + } + } + }); + const modal = m.contentEl; + + // Wire the installed_apps_multi / app_urls_multi search + bulk + // select buttons. For app_urls_multi we also hide whole group + // headers when none of their rows match the search. + modal.querySelectorAll('.installed-apps-multi').forEach((root) => { + const isUrlMode = root.classList.contains('app-urls-multi'); + const search = root.querySelector('.installed-apps-search'); + const all = root.querySelector('.installed-apps-all'); + const none = root.querySelector('.installed-apps-none'); + const filterRows = (q) => { + if (!isUrlMode) { + root.querySelectorAll('.installed-apps-item').forEach((item) => { + const text = item.querySelector('.installed-apps-name')?.textContent.toLowerCase() || ''; + item.style.display = text.includes(q) ? '' : 'none'; + }); + return; + } + root.querySelectorAll('.app-url-row').forEach((item) => { + const label = (item.querySelector('.app-url-label')?.textContent || '').toLowerCase(); + item.style.display = (!q || label.includes(q)) ? '' : 'none'; + }); + }; + if (search) search.addEventListener('input', (e) => filterRows(e.target.value.toLowerCase())); + if (all) all.addEventListener('click', () => { + root.querySelectorAll('.installed-apps-item').forEach((item) => { + if (item.offsetParent !== null) item.querySelector('input').checked = true; + }); + }); + if (none) none.addEventListener('click', () => { + root.querySelectorAll('.installed-apps-item input').forEach((cb) => cb.checked = false); + }); + }); + + // Async-fill app_urls_multi rows from apps-services.json after + // mount so the modal opens instantly and rows fade in. + this._hydrateAppUrlsMulti(modal); + return m; + } + + _renderField(field) { + const name = String(field.name || ''); + const label = escapeHtml(field.label || name); + const id = `tool-field-${name}`; + const required = field.required ? 'required' : ''; + const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : ''; + const def = field.default !== undefined ? String(field.default) : ''; + + const wrap = (inner) => ` +
+ + ${inner} +
`; + + switch (field.type) { + case 'password': + return wrap(``); + case 'number': { + const min = field.min !== undefined ? `min="${Number(field.min)}"` : ''; + const max = field.max !== undefined ? `max="${Number(field.max)}"` : ''; + return wrap(``); + } + case 'checkbox': { + const checked = (def === 'true' || def === '1' || field.default === true) ? 'checked' : ''; + return ` + `; + } + case 'select': { + const opts = Array.isArray(field.options) ? field.options : []; + const optsHtml = opts.map(o => { + const v = escapeHtml(String(o.value)); + const l = escapeHtml(String(o.label || o.value)); + const sel = String(o.value) === def ? 'selected' : ''; + return ``; + }).join(''); + return wrap(``); + } + case 'textarea': + return wrap(``); + case 'installed_apps_multi': + return wrap(this._renderInstalledAppsMulti(field, name)); + case 'app_urls_multi': + // Label rendered inside the picker's container as a title bar + // so the field reads as a single visual unit (no floating + // disconnected label above the box). Skip wrap(). + return `
${this._renderAppUrlsMulti(field, name, label, !!field.required)}
`; + case 'text': + default: + return wrap(``); + } + } + + // Multi-select list of installed apps, styled to mirror the gluetun + // country picker: search box + select-all/clear + compact rows. + // Collected as CSV by _collectFormArgs. + _renderInstalledAppsMulti(field, name) { + const apps = (window.apps || []).filter(a => a && a.installed); + const currentAppEntry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === this.currentApp + ); + const cfgKey = field.prefillFromCfgKey + || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; + const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; + const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); + const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); + + const items = apps + .map(a => ({ app: a, slug: (a.command || '').split(' ').pop() })) + .filter(({ slug }) => slug && !exclude.has(slug)) + .sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug)) + .map(({ app, slug }) => { + const checked = selected.has(slug) ? 'checked' : ''; + const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`; + const displayName = escapeHtml(app.name || slug); + return ` + `; + }).join(''); + + if (!items) { + return `
No other installed apps to choose from.
`; + } + + return ` +
+
+
+ + + + + +
+
+ + +
+
+
${items}
+
`; + } + + // URL-level multi-select. Same data source as the WebUI URL buttons + // (apps-services.json + window.expandServiceLinks), so what the user + // picks here matches what they'd see hovering an app on the + // dashboard. Each row is one URL, grouped under its parent app. + // Stable id per URL: ":". Pre-checked from + // a CSV in CFG__ (override via prefillFromCfgKey). + _renderAppUrlsMulti(field, name, labelHtml, required) { + const cfgKey = field.prefillFromCfgKey + || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; + const currentAppEntry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === this.currentApp + ); + const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; + const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); + const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); + + const titleHtml = labelHtml + ? `
${labelHtml}${required ? ' *' : ''}
` + : ''; + + return ` +
+
+ ${titleHtml} +
+
+ + + + + +
+
+ + +
+
+
+
Loading services…
+
+
+
`; + } + + // Async hydration for the app_urls_multi field — fetch + // apps-services.json and render rows. Called after the modal has + // been mounted (see open() below). + async _hydrateAppUrlsMulti(modal) { + const roots = modal.querySelectorAll('.app-urls-multi'); + if (roots.length === 0) return; + + // Re-fetch apps.json so the pre-checked state reflects the + // latest CFG_* values (a previous tool save may have updated + // them after window.apps was first loaded). Cheap and avoids + // a "ticked nothing even though I just saved" UX bug. + try { + const appsResp = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (appsResp.ok) { + const appsData = await appsResp.json(); + if (Array.isArray(appsData?.apps)) window.apps = appsData.apps; + } + } catch (_) { /* keep stale window.apps if fetch fails */ } + + // Recompute data-prefill from the fresh window.apps. The render + // pass encoded it from whatever data was current at modal-open + // time; if the user saved shortcuts in a different tab/session + // we'd otherwise show stale ticks here. + roots.forEach((root) => { + const cfgKey = root.dataset.cfgKey; + const appSlug = root.dataset.appSlug; + if (!cfgKey || !appSlug) return; + const entry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === appSlug + ); + const csv = (entry && entry.config && entry.config[cfgKey]) || ''; + const fresh = csv.split(',').map(s => s.trim()).filter(Boolean); + root.dataset.prefill = JSON.stringify(fresh); + }); + + let services = []; + try { + const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (resp.ok) { + const data = await resp.json(); + services = Array.isArray(data?.services) ? data.services : []; + } + } catch (e) { /* fall through, render empty state */ } + + const expand = typeof window.expandServiceLinks === 'function' + ? window.expandServiceLinks + : ((s) => [{ url: s.externalURL || `http://localhost:${s.externalPort}`, label: s.buttonText || s.name }]); + + const displayName = (slug) => (window.getAppDisplayName ? window.getAppDisplayName(slug) : slug); + + roots.forEach((root) => { + const name = root.dataset.field; + const selected = new Set(JSON.parse(root.dataset.prefill || '[]')); + const exclude = new Set(JSON.parse(root.dataset.exclude || '[]')); + const list = root.querySelector('.app-urls-list'); + + const byApp = new Map(); + for (const svc of services) { + if (!svc || !svc.app || exclude.has(svc.app)) continue; + if (svc.buttonEnabled === false) continue; + const links = expand(svc); + if (!links || links.length === 0) continue; + if (!byApp.has(svc.app)) byApp.set(svc.app, []); + links.forEach((lnk, idx) => { + if (!lnk?.url) return; + byApp.get(svc.app).push({ + id: `${svc.name}:${idx}`, + label: lnk.label || svc.buttonText || svc.name, + url: lnk.url, + traefik: !!svc.traefikManaged, + locked: !!svc.loginRequired + }); + }); + } + + if (byApp.size === 0) { + list.innerHTML = `
No service URLs available yet — install some apps first.
`; + return; + } + + // Flat task-style rows: one URL per row, app icon + name + URL + // inline. Sorted by app name then URL label so related entries + // sit together without needing per-app group cards. + const flat = []; + [...byApp.keys()].sort((a, b) => displayName(a).localeCompare(displayName(b))).forEach(slug => { + byApp.get(slug).forEach(u => flat.push({ slug, ...u })); + }); + + list.innerHTML = flat.map(u => { + const checked = selected.has(u.id) ? 'checked' : ''; + const iconUrl = `/icons/apps/${encodeURIComponent(u.slug)}.svg`; + const appLabel = displayName(u.slug); + const showButton = u.label && u.label !== appLabel; + const fullLabel = showButton + ? `${escapeHtml(appLabel)} ${escapeHtml(u.label)}` + : escapeHtml(appLabel); + return ` + `; + }).join(''); + }); + } + + // Collect form values into a pipe-encoded args string for the bash side. + // Returns null if a required field is missing. + _collectFormArgs(modal, fields) { + const pairs = []; + for (const field of fields) { + const name = String(field.name || ''); + if (!name) continue; + // installed_apps_multi has no [name=name] element directly; it + // collects from [name=name+'__opt'] checkboxes below. Other field + // types have a single matching element to validate against. + const isMultiPickerType = field.type === 'installed_apps_multi' || field.type === 'app_urls_multi'; + const el = modal.querySelector(`[name="${cssEscape(name)}"]`) + || (isMultiPickerType ? modal.querySelector(`.installed-apps-multi[data-field="${cssEscape(name)}"]`) : null); + if (!el) continue; + let value; + if (field.type === 'checkbox') { + value = el.checked ? 'true' : 'false'; + } else if (isMultiPickerType) { + const checked = modal.querySelectorAll(`[name="${cssEscape(name + '__opt')}"]:checked`); + value = Array.from(checked).map(c => c.value).join(','); + } else { + value = el.value; + } + if (field.required && (value === '' || value === undefined || value === null)) { + if (window.notificationSystem) { + window.notificationSystem.show(`Missing field
${escapeHtml(field.label || name)} is required.`, 'warning'); + } else { + alert(`${field.label || name} is required.`); + } + return null; + } + // Strip pipes/newlines from values — they're our delimiters. + const safe = String(value).replace(/\|/g, '%7C').replace(/[\r\n]/g, ' '); + pairs.push(`${name}=${safe}`); + } + return pairs.join('|'); + } + + async _dispatch(tool, toolArgs) { + if (!this.currentApp) return; + if (!window.tasksManager || !window.tasksManager.router) { + console.error('TasksManager router not available'); + if (window.notificationSystem) { + window.notificationSystem.error('Task system is not ready yet — try again in a moment.'); + } + return; + } + try { + const task = await window.tasksManager.router.routeAction('tool', { + appName: this.currentApp, + toolName: tool.id, + toolArgs, + toolLabel: tool.label || tool.id + }); + + // Mirror the install flow: jump to the Tasks tab and auto-expand + // the new task so the user sees its log streaming in. + setTimeout(() => { + if (window.appTabbedManager) { + window.appTabbedManager.switchTab('tasks'); + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 300); + } + }, 200); + } catch (err) { + console.error('Tool dispatch failed', err); + if (window.notificationSystem) { + window.notificationSystem.error(`Tool failed: ${err.message}`); + } + } + } +} + +// Tiny helpers ---------------------------------------------------------- + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function cssEscape(s) { + if (window.CSS && CSS.escape) return CSS.escape(s); + return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c); +} + +window.toolsManager = new ToolsManager(); diff --git a/containers/libreportal/frontend/js/components/backup/backup-app-card.js b/containers/libreportal/frontend/js/components/backup/backup-app-card.js new file mode 100644 index 0000000..25dad6a --- /dev/null +++ b/containers/libreportal/frontend/js/components/backup/backup-app-card.js @@ -0,0 +1,173 @@ +// Per-app backup card — used inside the app detail "Backups" tab. +// Lightweight view that lists the app's snapshots across all enabled repos and +// offers Backup Now + per-snapshot restore. For full management (delete, +// migrate, schedule overrides) the user follows the link to /backup. + +class BackupAppCard { + constructor(appName) { + this.appName = appName; + this.snapshotsByLoc = {}; + this.locationsByIdx = {}; + this.appStatus = null; + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.bindDelegated(); + } + + bindDelegated() { + if (window.__backupAppCardBound) return; + window.__backupAppCardBound = true; + + document.addEventListener('click', (e) => { + const card = window.backupAppCard; + if (!card) return; + + if (e.target.closest('#backup-app-card-backup-btn')) { + card.backupNow(); + return; + } + + const restoreBtn = e.target.closest('[data-action="restore-app-snapshot"]'); + if (restoreBtn) { + card.restoreSnapshot(restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); + } + }); + } + + async render() { + const statusEl = document.getElementById('backup-app-card-status'); + const snapsEl = document.getElementById('backup-app-card-snapshots'); + if (!statusEl || !snapsEl) return; + + statusEl.textContent = 'Loading…'; + snapsEl.innerHTML = ''; + + await this.loadData(); + + const allSnaps = this.flattenSnapshots(); + if (!allSnaps.length) { + statusEl.innerHTML = ` No snapshots yet`; + snapsEl.innerHTML = `
No snapshots found for ${this.escape(this.appName)}. Click "Backup now" to create the first one.
`; + return; + } + + const latest = allSnaps[0]; + const locCount = Object.keys(this.snapshotsByLoc).length; + statusEl.innerHTML = ` + + Latest backup ${this.formatRelative(latest.time)} + ${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'} + `; + + snapsEl.innerHTML = ` + + + + + + + + + + + ${allSnaps.slice(0, 15).map(s => ` + + + + + + + `).join('')} + +
LocationWhenID
${this.escape(s.locName)}${this.formatRelative(s.time)}${this.escape(s.id)} + +
+ `; + } + + async loadData() { + const ts = Date.now(); + const statusUrl = `/data/backup/generated/apps/${encodeURIComponent(this.appName)}.json?t=${ts}`; + const locationsUrl = `/data/backup/generated/locations.json?t=${ts}`; + + const [appStatus, locationsJson] = await Promise.all([ + this.fetchJson(statusUrl), + this.fetchJson(locationsUrl) + ]); + this.appStatus = appStatus; + this.snapshotsByLoc = {}; + this.locationsByIdx = {}; + + if (locationsJson?.locations?.length) { + locationsJson.locations.forEach(l => { this.locationsByIdx[l.idx] = l; }); + const enabled = locationsJson.locations.filter(l => l.enabled); + await Promise.all(enabled.map(async (l) => { + const data = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`); + if (data?.snapshots) this.snapshotsByLoc[l.idx] = data.snapshots; + })); + } + } + + flattenSnapshots() { + const out = []; + Object.entries(this.snapshotsByLoc).forEach(([locIdx, snaps]) => { + (snaps || []).forEach(s => { + const tags = s.tags || []; + const isApp = tags.includes(`app=${this.appName}`); + if (!isApp) return; + out.push({ + locIdx, + locName: this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`, + time: s.time, + id: s.short_id || (s.id || '').slice(0, 8) + }); + }); + }); + out.sort((a, b) => String(b.time).localeCompare(String(a.time))); + return out; + } + + async fetchJson(url) { + try { + const r = await fetch(url); + if (!r.ok) return null; + return await r.json(); + } catch { return null; } + } + + async backupNow() { + if (!this.taskManager) return; + await this.taskManager.createTask(`libreportal backup app create ${this.appName}`, 'backup', this.appName); + setTimeout(() => this.render(), 1500); + } + + async restoreSnapshot(locIdx, snapshot) { + const locName = this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`; + if (!confirm(`Restore ${this.appName} from backup ${snapshot} at ${locName}? The app will be stopped, its folder wiped, the backup restored in place, then the app started again.`)) return; + if (!this.taskManager) return; + await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName); + } + + escape(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + + formatRelative(iso) { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!t) return iso; + const diff = Math.max(0, Date.now() - t); + const s = Math.floor(diff / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); + } +} + +window.BackupAppCard = BackupAppCard; diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js new file mode 100644 index 0000000..eb72132 --- /dev/null +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -0,0 +1,1533 @@ +// Backup page controller — restic-engine UI. +// Reads JSON snapshots written by scripts/webui/data/generators/backup/* and +// dispatches actions back into the task system (which calls bash CLI). + +// Retention presets — pick the persona that matches you. Each maps to the +// five underlying restic --keep-* values. "Custom" reveals the raw fields. +const BACKUP_RETENTION_PRESETS = { + 'inherit-global': { last: '', daily: '', weekly: '', monthly: '', yearly: '' }, + 'self-hosting': { last: '', daily: '30', weekly: '', monthly: '', yearly: '' }, + 'personal': { last: '', daily: '30', weekly: '', monthly: '6', yearly: '' }, + 'enterprise': { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' } +}; + +const BACKUP_RETENTION_PRESET_META = { + 'inherit-global': { label: 'Inherit global retention (default)', hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' }, + 'self-hosting': { label: 'Self-hosting (default)', hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' }, + 'personal': { label: 'Personal', hint: '30 days of daily backups plus 6 monthly snapshots. Good for personal data where "what did this look like last summer" matters.' }, + 'enterprise': { label: 'Enterprise', hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' }, + 'custom': { label: 'Custom…', hint: 'Define each retention tier yourself.' } +}; + +// Per-location field metadata. Configs.json doesn't carry titles for +// CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline. +// ConfigShared.generateField uses TITLE + key-based widget heuristics; the +// regexes in config-options.js / config-shared.js already cover _TYPE, +// _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets. +const BACKUP_LOC_FIELD_DEFS = { + NAME: { title: 'Friendly name', description: 'Shown in lists and on the dashboard.' }, + ENABLED: { title: 'Enabled', description: 'Push backups to this location.' }, + ENGINE: { title: 'Engine', description: 'Backup engine used at this location.' }, + TYPE: { title: 'Type', description: 'Backend the engine uses to talk to this location.' }, + PATH_MODE: { title: 'Path', description: 'Automatic puts the repo at /docker/backups/. Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' }, + PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' }, + URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' }, + SSH_USER: { title: 'SSH user', description: '' }, + SSH_HOST: { title: 'SSH host', description: '' }, + SSH_PORT: { title: 'SSH port', description: '' }, + SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' }, + SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' }, + SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' }, + S3_ACCESS_KEY: { title: 'S3 access key', description: '' }, + S3_SECRET_KEY: { title: 'S3 secret', description: '' }, + B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' }, + B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' }, + APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' }, + CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' }, + KEEP_LAST: { title: 'Keep last', description: 'Snapshots to always retain.' }, + KEEP_DAILY: { title: 'Keep daily', description: 'One snapshot per day for this many days.' }, + KEEP_WEEKLY: { title: 'Keep weekly', description: 'One snapshot per week for this many weeks.' }, + KEEP_MONTHLY: { title: 'Keep monthly', description: 'One snapshot per month for this many months.' }, + KEEP_YEARLY: { title: 'Keep yearly', description: 'One snapshot per year for this many years.' } +}; + +const BACKUP_LOC_FIELDS_BY_TYPE = { + local: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'], + sftp: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'], + rest: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + s3: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'], + b2: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'], + gs: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + azure: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + rclone: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'] +}; + +function backupRetentionDetectPreset(values, includeInherit = false) { + const norm = (v) => (v == null ? '' : String(v).trim()); + for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) { + if (key === 'inherit-global' && !includeInherit) continue; + if (norm(values.last) === norm(p.last) && + norm(values.daily) === norm(p.daily) && + norm(values.weekly) === norm(p.weekly) && + norm(values.monthly) === norm(p.monthly) && + norm(values.yearly) === norm(p.yearly)) { + return key; + } + } + return 'custom'; +} + +class BackupPage { + constructor() { + this.currentTab = 'dashboard'; + this.dashboard = null; + this.locations = null; + this.snapshotsByLoc = {}; + this.expandedLocs = new Set(); + this.engines = []; // [{id,name,supported_types}, ...] — fetched once + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.eventBound = false; + } + + async init() { + this.currentTab = this.parseTabFromUrl() || this.currentTab; + this.applyActiveTabUi(this.currentTab); + this.bindEvents(); + await this.refreshAll(); + this.render(); + this.updatePageHeader(); + this.updatePrimaryAction(); + } + + /* Read the active tab slug from window.location, supporting both + /backup?=dashboard (the legacy libreportal ?= form used on /config) + and /backup?backup=dashboard (standard query string) so links from + either source resolve correctly. */ + parseTabFromUrl() { + const allowed = new Set(['dashboard', 'backups', 'locations', 'configuration']); + const search = window.location.search || ''; + const legacy = search.match(/\?=([^&]+)/); + if (legacy && allowed.has(legacy[1])) return legacy[1]; + const params = new URLSearchParams(search); + const q = params.get('backup') || params.get('tab'); + if (q && allowed.has(q)) return q; + return null; + } + + /* Toggle the sidebar .active class + panel visibility without going + through switchTab's URL-update path (used on initial render and + browser back/forward). */ + applyActiveTabUi(tab) { + document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => { + b.classList.toggle('active', b.dataset.backupTab === tab); + }); + document.querySelectorAll('.backup-tabpanel').forEach(p => { + p.classList.toggle('active', p.id === `backup-panel-${tab}`); + }); + } + + bindEvents() { + if (this.eventBound) return; + this.eventBound = true; + + // Browser back/forward is handled by the SPA's popstate listener — + // pushTabToUrl includes a `route` field in state so the SPA's + // handler picks it up and re-runs handleBackup, which re-parses + // the URL via parseTabFromUrl() at init time. + + document.addEventListener('click', (e) => { + const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]'); + if (tabBtn) { + this.switchTab(tabBtn.dataset.backupTab); + return; + } + + if (e.target.closest('#backup-refresh-btn')) { + this.refreshAll().then(() => this.render()); + return; + } + + if (e.target.closest('#backup-primary-action')) { + this.handlePrimaryAction(); + return; + } + + const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); + if (restoreBtn) { + this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); + return; + } + + const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); + if (deleteBtn) { + this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); + return; + } + + const locHeader = e.target.closest('[data-action="toggle-location"]'); + if (locHeader) { + this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10)); + return; + } + + const locSave = e.target.closest('[data-action="save-location"]'); + if (locSave) { + this.saveInlineLocation(parseInt(locSave.dataset.loc, 10)); + return; + } + + const locDelete = e.target.closest('[data-action="delete-location"]'); + if (locDelete) { + this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10)); + return; + } + + if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { + this.closeAllModals(); + return; + } + + if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; } + if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; } + if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; } + const engineBtn = e.target.closest('[data-action="open-engine-details"]'); + if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; } + + const exportBtn = e.target.closest('[data-action="export-passwords"]'); + if (exportBtn) { this.exportRepositoryPasswords(exportBtn); return; } + + const saveBtn = e.target.closest('[data-backup-save]'); + if (saveBtn) { + this.saveSection(saveBtn.dataset.backupSave); + return; + } + }); + + document.addEventListener('input', (e) => { + if (e.target.id === 'backup-snapshot-filter' || e.target.id === 'backup-snapshot-repo') { + this.renderSnapshots(); + } + }); + + // Type select changes refresh the visible connection fields inline. + // Retention preset changes are handled by applyRetentionPreset, which + // already updates CUSTOM_RETENTION too — no extra toggle wiring needed. + document.addEventListener('change', (e) => { + const detailsScope = e.target.closest('.backup-location-row .task-details'); + if (detailsScope) { + const locIdx = parseInt(detailsScope.dataset.loc, 10); + if (e.target.matches('[name$="_TYPE"]')) { + this.refreshInlineTypeFields(locIdx, e.target.value); + } + if (e.target.matches('[name$="_SSH_AUTH"]')) { + this.applySshAuthVisibility(detailsScope); + } + if (e.target.matches('[name$="_PATH_MODE"]')) { + this.applyPathModeVisibility(detailsScope); + } + } + const presetSel = e.target.closest('[data-retention-preset]'); + if (presetSel) { + this.applyRetentionPreset(presetSel); + } + }); + } + + async refreshAll() { + const ts = Date.now(); + const [dashboard, locations] = await Promise.all([ + this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`), + this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`) + ]); + this.dashboard = dashboard; + this.locations = locations; + this.snapshotsByLoc = {}; + + if (!this.engines.length) await this.loadEngines(); + + if (locations?.locations?.length) { + const enabled = locations.locations.filter(l => l.enabled); + await Promise.all(enabled.map(async (l) => { + const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`); + if (s) this.snapshotsByLoc[l.idx] = s; + })); + } + } + + async fetchJson(url) { + try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); } + catch { return null; } + } + + async loadEngines() { + const ts = Date.now(); + const index = await this.fetchJson(`/data/backup/generated/engines/index.json?t=${ts}`); + const ids = index?.engines || []; + const metas = await Promise.all(ids.map(id => + this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(id)}.json?t=${ts}`) + )); + this.engines = metas.filter(Boolean); + // Fallback so the dropdown never collapses to empty if the regen + // hasn't run yet — restic is always assumed available. + if (!this.engines.length) { + this.engines = [{ id: 'restic', name: 'restic', supported_types: ['local','sftp','rest','s3','b2','gs','azure','rclone'] }]; + } + } + + engineDisplayName(id) { + if (!id) return 'restic'; + const match = (this.engines || []).find(e => e.id === id); + return match?.name || id; + } + + enginesForType(type) { + if (!type) return this.engines; + return this.engines.filter(e => + !Array.isArray(e.supported_types) || + e.supported_types.includes(type) + ); + } + + switchTab(tab, opts = {}) { + if (!tab || tab === this.currentTab) return; + this.currentTab = tab; + this.applyActiveTabUi(tab); + this.updatePageHeader(); + this.updatePrimaryAction(); + if (!opts.fromPopstate) this.pushTabToUrl(tab); + } + + pushTabToUrl(tab) { + const url = `/backup?=${tab}`; + // Use replaceState for the *first* push (initial tab inferred from + // URL); otherwise pushState so back/forward navigates between tabs. + if (!this._pushedAnyTab) { + window.history.replaceState({ backupTab: tab, route: url }, '', url); + this._pushedAnyTab = true; + } else { + window.history.pushState({ backupTab: tab, route: url }, '', url); + } + } + + updatePageHeader() { + const titleEl = document.getElementById('backup-section-title'); + const subEl = document.getElementById('backup-section-subtitle'); + const iconEl = document.getElementById('backup-page-header-icon'); + if (titleEl) titleEl.textContent = this.titleFor(this.currentTab); + if (subEl) subEl.textContent = this.subtitleFor(this.currentTab); + if (iconEl) iconEl.innerHTML = this.iconFor(this.currentTab); + } + + titleFor(tab) { + return { + dashboard: 'Dashboard', + backups: 'Backups', + locations: 'Locations', + configuration: 'Configuration' + }[tab] || 'Backups'; + } + + subtitleFor(tab) { + return { + dashboard: 'Per-app status and storage at a glance.', + backups: 'Every snapshot across every enabled location.', + locations: 'Where backups are stored. Add, edit, or remove destinations.', + configuration: 'Schedule, retention, and engine settings.' + }[tab] || ''; + } + + iconFor(tab) { + const icons = { + dashboard: + '' + + '' + + '' + + '' + + '', + backups: + '' + + '' + + '' + + '', + locations: + '' + + '' + + '', + configuration: + '' + + '' + + '' + }; + return icons[tab] || icons.backups; + } + + updatePrimaryAction() { + const btn = document.getElementById('backup-primary-action'); + if (!btn) return; + if (this.currentTab === 'locations') { + btn.innerHTML = ` + + + + + Add location + `; + btn.dataset.intent = 'add-location'; + } else { + btn.innerHTML = ` + + + + + + Backup all apps + `; + btn.dataset.intent = 'backup-all'; + } + } + + handlePrimaryAction() { + const intent = document.getElementById('backup-primary-action')?.dataset.intent; + if (intent === 'add-location') { + this.openAddLocationModal(); + } else { + this.runBackupAllApps(); + } + } + + render() { + this.renderDashboard(); + this.renderLocations(); + this.renderSnapshots(); + this.renderConfiguration(); + } + + renderDashboard() { + const summary = document.getElementById('backup-summary-row'); + const appGrid = document.getElementById('backup-app-grid'); + const locSummary = document.getElementById('backup-repo-list-summary'); + if (!summary || !appGrid || !locSummary) return; + + const d = this.dashboard || {}; + const locs = d.locations || []; + const apps = d.apps || []; + const totalSnapshots = Object.values(this.snapshotsByLoc).reduce((acc, r) => { + return acc + (Array.isArray(r?.snapshots) ? r.snapshots.length : 0); + }, 0); + const protectedApps = apps.filter(a => a.latest_snapshot).length; + const totalSize = locs.reduce((acc, r) => acc + (parseInt(r.total_size_bytes) || 0), 0); + + summary.innerHTML = ` + ${this.tile('Apps protected', `${protectedApps} / ${apps.length}`, 'with at least one backup')} + ${this.tile('Backups', `${totalSnapshots}`, `across ${locs.length} location${locs.length === 1 ? '' : 's'}`)} + ${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')} + `; + + if (!apps.length) { + appGrid.innerHTML = `
No apps installed yet.
`; + } else { + appGrid.innerHTML = apps.map(app => this.renderAppTile(app)).join(''); + } + + if (!locs.length) { + locSummary.innerHTML = `
No locations enabled.
`; + } else { + locSummary.innerHTML = locs.map(r => ` +
+
+ ${this.escape(r.type)} + ${this.escape(r.name)} +
+
+ ${this.formatBytes(parseInt(r.total_size_bytes) || 0)}
+ ${r.total_files || 0} files +
+
+ `).join(''); + } + } + + tile(label, value, detail) { + return ` +
+
${this.escape(label)}
+
${this.escape(value)}
+
${this.escape(detail || '')}
+
+ `; + } + + /* Look up the icon + display name from window.apps the same way the + dashboard and tasks page do. Falls back to the default app icon and + a capitalised slug if the app isn't in the cached list. */ + appMeta(slug) { + const apps = window.apps || []; + const match = apps.find(a => { + const command = a.command || ''; + return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); + }); + let icon = match?.icon || 'icons/apps/default.svg'; + if (!icon.startsWith('/')) icon = '/' + icon; + const displayName = (typeof window.getAppDisplayName === 'function') + ? window.getAppDisplayName(slug) + : (slug.charAt(0).toUpperCase() + slug.slice(1)); + return { icon, displayName }; + } + + renderAppTile(app) { + const has = !!app.latest_snapshot; + const dot = has ? 'ok' : 'none'; + const when = has ? this.formatRelative(app.latest_time) : 'No backup yet'; + const { icon, displayName } = this.appMeta(app.app); + return ` +
+ +
+
${this.escape(displayName)}
+
+ + ${when} +
+
+
+ `; + } + + renderLocations() { + const list = document.getElementById('backup-location-list'); + const repoSelect = document.getElementById('backup-snapshot-repo'); + if (!list) return; + + const locs = this.locations?.locations || []; + if (!locs.length) { + list.innerHTML = ` +
+ No backup locations configured yet.
+ Click Add location above to create one. +
+ `; + } else { + list.innerHTML = locs.map(l => this.renderLocationRow(l)).join(''); + } + + if (repoSelect) { + const cur = repoSelect.value; + repoSelect.innerHTML = `` + + locs.filter(l => l.enabled).map(l => ``).join(''); + if (cur) repoSelect.value = cur; + } + } + + renderLocationRow(l) { + // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled. + const statusKind = l.enabled && l.password_exists ? 'ready' + : l.enabled && !l.password_exists ? 'init' + : 'disabled'; + const statusMeta = { + ready: { icon: '✅', label: 'Ready' }, + init: { icon: '⏳', label: 'Initialising' }, + disabled: { icon: '⏸', label: 'Disabled' } + }[statusKind]; + const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0; + const expanded = this.expandedLocs.has(l.idx); + const size = this.formatBytes(parseInt(l.total_size_bytes) || 0); + return ` +
+
+
+ ${this.typeIcon(l.type)} + ${this.escape(l.name)} + ${this.escape(l.type)} + ${this.escape(this.engineDisplayName(l.engine))} + ${l.append_only ? 'append-only' : ''} + ${statusMeta.icon} ${statusMeta.label} + · + ${snapCount} backup${snapCount === 1 ? '' : 's'} + · + ${size} +
+ + + +
+
+ ${expanded ? this.renderLocationDetailsBody(l) : ''} +
+
+ `; + } + + /* Inline-SVG icon for a location's backend type. Local gets the disk + (stack of platters) glyph; everything else gets a cloud — that's + the visual line between "lives on this box" and "lives somewhere else." */ + typeIcon(type) { + const local = ` + + + + `; + const cloud = ` + + `; + return type === 'local' ? local : cloud; + } + + renderLocationDetailsBody(l) { + const idx = l.idx; + const connectionFields = BACKUP_LOC_FIELDS_BY_TYPE[l.type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + const retentionValues = { + last: l.custom_retention ? (l.keep_last || '') : '', + daily: l.custom_retention ? (l.keep_daily || '') : '', + weekly: l.custom_retention ? (l.keep_weekly || '') : '', + monthly: l.custom_retention ? (l.keep_monthly || '') : '', + yearly: l.custom_retention ? (l.keep_yearly || '') : '' + }; + + return ` +
+

Connection

+
+ ${this.renderLocFields(idx, connectionFields, l)} +
+
+
+

Retention

+

When to delete old backups from this location.

+
+ ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)} +
+
+
+ + +
+ `; + } + + toggleLocationExpand(idx) { + const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`); + if (!row) return; + const details = row.querySelector('.task-details'); + const header = row.querySelector('.task-header'); + if (!details) return; + + const willOpen = !this.expandedLocs.has(idx); + if (willOpen) { + this.expandedLocs.add(idx); + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + if (loc) { + details.innerHTML = this.renderLocationDetailsBody(loc); + this.tagFieldsForSave(details); + this.filterEngineSelect(details, loc.type, loc.engine); + this.applySshAuthVisibility(details); + this.applyPathModeVisibility(details); + } + this.enhanceEngineDetailsButton(); + details.classList.add('show'); + row.classList.add('expanded'); + if (header) header.setAttribute('aria-expanded', 'true'); + } else { + this.expandedLocs.delete(idx); + details.classList.remove('show'); + row.classList.remove('expanded'); + if (header) header.setAttribute('aria-expanded', 'false'); + } + } + + refreshInlineTypeFields(idx, type) { + const container = document.getElementById(`backup-location-${idx}-connection`); + if (!container) return; + const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {}; + const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + container.innerHTML = this.renderLocFields(idx, suffixes, { ...loc, type }); + this.tagFieldsForSave(container); + this.filterEngineSelect(container, type, loc.engine); + this.applySshAuthVisibility(container); + this.applyPathModeVisibility(container); + this.enhanceEngineDetailsButton(); + } + + /* Hide the SSH password field when SSH auth = key, show it when = password. + Applied at expand time and whenever the SSH_AUTH select changes. */ + applySshAuthVisibility(scope) { + const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]'); + if (!authSelect) return; + const passInput = scope.querySelector('input[name$="_SSH_PASS"]'); + const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement; + if (!passGroup) return; + passGroup.style.display = authSelect.value === 'password' ? '' : 'none'; + } + + /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */ + applyPathModeVisibility(scope) { + const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]'); + if (!modeSelect) return; + const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])'); + const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement; + if (!pathGroup) return; + pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none'; + } + + /* Trim the per-location ENGINE select to only engines whose + supported_types include the location's current TYPE. If the currently + saved engine isn't compatible, fall back to the first compatible one. */ + filterEngineSelect(scope, type, preferred) { + const select = scope.querySelector('select[name$="_ENGINE"]'); + if (!select) return; + const compatible = this.enginesForType(type); + if (!compatible.length) return; + + const want = compatible.find(e => e.id === preferred)?.id || compatible[0].id; + select.innerHTML = compatible + .map(e => ``) + .join(''); + select.value = want; + } + + async saveInlineLocation(idx) { + await this.saveSection(`location-${idx}`); + } + + async deleteInlineLocation(idx) { + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + const name = loc?.name || `Location ${idx}`; + if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.`)) return; + this.expandedLocs.delete(idx); + await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + renderSnapshots() { + const tbody = document.getElementById('backup-snapshot-tbody'); + if (!tbody) return; + + const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase(); + const locFilter = document.getElementById('backup-snapshot-repo')?.value || ''; + + const locNameByIdx = {}; + (this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; }); + + const rows = []; + Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => { + if (locFilter && String(locFilter) !== String(locIdx)) return; + const snaps = Array.isArray(data?.snapshots) ? data.snapshots : []; + snaps.forEach(s => { + const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '—'; + rows.push({ + app, + host: s.hostname || '—', + locIdx, + locName: locNameByIdx[locIdx] || `Loc ${locIdx}`, + time: s.time, + id: s.short_id || (s.id || '').slice(0, 8), + }); + }); + }); + + rows.sort((a, b) => String(b.time).localeCompare(String(a.time))); + + const filtered = filter ? rows.filter(r => + r.app.toLowerCase().includes(filter) || + r.host.toLowerCase().includes(filter) || + r.id.toLowerCase().includes(filter) || + r.locName.toLowerCase().includes(filter) + ) : rows; + + if (!filtered.length) { + tbody.innerHTML = `No backups yet.`; + return; + } + + tbody.innerHTML = filtered.map(r => ` + + ${this.escape(r.app)} + ${this.escape(r.host)} + ${this.escape(r.locName)} + ${this.formatRelative(r.time)} + ${this.escape(r.id)} + + + + + + `).join(''); + } + + renderConfiguration() { + const body = document.getElementById('backup-configuration-body'); + if (!body) return; + + body.innerHTML = ` +
+
+ Keep your LibrePortal config backed up offline. + Repository passwords live inside the config directory. Without that backup, snapshots cannot be decrypted by anyone — including you. +
+ +
+
+ `; + + this.invokeConfigManager(); + } + + async exportRepositoryPasswords(triggerBtn) { + const restoreBtn = () => { + if (triggerBtn) { + triggerBtn.disabled = false; + triggerBtn.dataset.busy = ''; + } + }; + if (triggerBtn) { + triggerBtn.disabled = true; + triggerBtn.dataset.busy = '1'; + } + + try { + const task = await this.taskManager?.createTask( + 'libreportal webui generate backup', + 'webui', + null + ); + if (task?.id) { + await this.waitForTask(task.id, 20000); + } + const res = await fetch(`/data/backup/generated/passwords.txt?t=${Date.now()}`, { + credentials: 'same-origin' + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + if (!text || !text.includes('CFG_BACKUP_LOC_')) { + throw new Error('Password file is empty — no locations configured?'); + } + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const host = (window.systemConfigs?.CFG_INSTALL_NAME || 'libreportal').replace(/[^a-z0-9_-]/gi, '_'); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + a.href = url; + a.download = `libreportal-backup-passwords-${host}-${stamp}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.notify('Password export downloaded — store it offline.', 'success'); + } catch (err) { + this.notify(`Export failed: ${err.message || err}`, 'error'); + } finally { + restoreBtn(); + } + } + + waitForTask(taskId, timeoutMs = 15000) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + window.removeEventListener('taskCompleted', onComplete); + clearTimeout(timer); + resolve(); + }; + const onComplete = (e) => { + if (e?.detail?.taskId === taskId) finish(); + }; + window.addEventListener('taskCompleted', onComplete); + const timer = setTimeout(finish, timeoutMs); + }); + } + + async invokeConfigManager(attempt = 0) { + if (window.configManager && typeof window.configManager.renderConfig === 'function') { + try { + await window.configManager.renderConfig('backup'); + this.enhanceConfigurationWithPresets(); + } catch (err) { + console.error('Backup configuration render failed:', err); + } + return; + } + if (attempt >= 20) { + const sec = document.getElementById('config-section'); + if (sec) sec.innerHTML = `
Configuration system not loaded. Try refreshing the page.
`; + return; + } + setTimeout(() => this.invokeConfigManager(attempt + 1), 150); + } + + /* Post-render polish on the dynamic /config render: wrap the five raw + retention number fields in a persona-preset dropdown. The five inputs + stay in the DOM (so /config's save flow captures them unchanged) but + are hidden under "Custom…" by default. */ + enhanceConfigurationWithPresets() { + this.enhanceEngineDetailsButton(); + const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]'); + if (!lastInput) return; + + const section = lastInput.closest('.config-category'); + if (!section || section.dataset.backupPresetEnhanced === '1') return; + section.dataset.backupPresetEnhanced = '1'; + + const fieldNames = [ + 'CFG_BACKUP_KEEP_LAST', + 'CFG_BACKUP_KEEP_DAILY', + 'CFG_BACKUP_KEEP_WEEKLY', + 'CFG_BACKUP_KEEP_MONTHLY', + 'CFG_BACKUP_KEEP_YEARLY' + ]; + const inputs = fieldNames + .map(n => section.querySelector(`[name="${n}"]`)) + .filter(Boolean); + if (inputs.length < 5) return; + + const wrappers = inputs.map(input => { + return input.closest('.config-field, .field-group, .form-group') || input.parentElement; + }); + + const extraCustomFields = ['CFG_BACKUP_PRUNE_AFTER_FORGET']; + extraCustomFields.forEach(name => { + const el = section.querySelector(`[name="${name}"]`); + if (el) { + const wrap = el.closest('.config-field, .field-group, .form-group') || el.parentElement; + if (wrap) wrappers.push(wrap); + } + }); + + const readVals = () => ({ + last: inputs[0].value || '', + daily: inputs[1].value || '', + weekly: inputs[2].value || '', + monthly: inputs[3].value || '', + yearly: inputs[4].value || '' + }); + + const preset = backupRetentionDetectPreset(readVals()); + const meta = BACKUP_RETENTION_PRESET_META[preset]; + const presetOptions = Object.entries(BACKUP_RETENTION_PRESET_META) + .map(([k, v]) => ``) + .join(''); + + const block = document.createElement('div'); + block.className = 'backup-retention-preset-block'; + block.innerHTML = ` + +
${this.escape(meta?.hint || '')}
+ `; + + const fieldsGrid = section.querySelector('.config-fields'); + if (fieldsGrid) { + fieldsGrid.prepend(block); + } else { + section.prepend(block); + } + + const applyVisibility = (presetKey) => { + const isCustom = presetKey === 'custom'; + wrappers.forEach(w => { if (w) w.style.display = isCustom ? '' : 'none'; }); + }; + applyVisibility(preset); + + const select = block.querySelector('[data-backup-retention-preset]'); + const hintEl = block.querySelector('.backup-retention-hint'); + select.addEventListener('change', () => { + const chosen = select.value; + hintEl.textContent = BACKUP_RETENTION_PRESET_META[chosen]?.hint || ''; + applyVisibility(chosen); + if (chosen === 'custom') return; + const p = BACKUP_RETENTION_PRESETS[chosen]; + const map = { last: 0, daily: 1, weekly: 2, monthly: 3, yearly: 4 }; + Object.entries(map).forEach(([k, i]) => { + inputs[i].value = p[k]; + inputs[i].dispatchEvent(new Event('input', { bubbles: true })); + inputs[i].dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + } + + /* Retention preset dropdown + hidden underlying fields. + `prefix` is the CFG name prefix, e.g. 'CFG_BACKUP_' or 'CFG_BACKUP_LOC_3_'. + When `includeInherit` is true (per-location scope), an "Inherit global" + option is added at the top and an extra hidden CUSTOM_RETENTION field is + written: false when inherit, true otherwise. The five raw KEEP_* inputs + are always rendered (so the save flow captures them) but hidden until + "Custom…" is selected. */ + formRetention(prefix, values, includeInherit = false) { + const preset = backupRetentionDetectPreset(values, includeInherit); + const meta = BACKUP_RETENTION_PRESET_META[preset]; + const presetOptions = Object.entries(BACKUP_RETENTION_PRESET_META) + .filter(([k]) => k !== 'inherit-global' || includeInherit) + .map(([k, v]) => ``) + .join(''); + + const customRetentionHidden = includeInherit + ? `` + : ''; + + return ` +
+ +
${this.escape(meta?.hint || '')}
+ ${customRetentionHidden} +
+
+
+ ${this.formInput(`${prefix}KEEP_LAST`, 'Keep last', values.last, 'number', '', 'snapshots')} + ${this.formInput(`${prefix}KEEP_DAILY`, 'Keep daily', values.daily, 'number', '', 'days')} + ${this.formInput(`${prefix}KEEP_WEEKLY`, 'Keep weekly', values.weekly, 'number', '', 'weeks')} + ${this.formInput(`${prefix}KEEP_MONTHLY`, 'Keep monthly', values.monthly, 'number', '', 'months')} + ${this.formInput(`${prefix}KEEP_YEARLY`, 'Keep yearly', values.yearly, 'number', '', 'years')} +
+
+ `; + } + + applyRetentionPreset(selectEl) { + const block = selectEl.closest('[data-retention-prefix]'); + const advanced = block?.nextElementSibling; + if (!block) return; + const prefix = block.dataset.retentionPrefix; + const allowInherit = block.dataset.retentionAllowInherit === '1'; + const preset = selectEl.value; + const hintEl = block.querySelector('[data-retention-hint]'); + if (hintEl) hintEl.textContent = BACKUP_RETENTION_PRESET_META[preset]?.hint || ''; + + if (preset === 'custom') { + if (advanced) advanced.hidden = false; + } else { + if (advanced) advanced.hidden = true; + const p = BACKUP_RETENTION_PRESETS[preset]; + if (p) { + const setField = (suffix, value) => { + const el = document.querySelector(`[name="${prefix}${suffix}"]`); + if (el) { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + } + }; + setField('KEEP_LAST', p.last); + setField('KEEP_DAILY', p.daily); + setField('KEEP_WEEKLY', p.weekly); + setField('KEEP_MONTHLY', p.monthly); + setField('KEEP_YEARLY', p.yearly); + } + } + + // Keep CUSTOM_RETENTION in sync with the preset (location scope only). + if (allowInherit) { + const cr = block.querySelector(`[name="${prefix}CUSTOM_RETENTION"]`); + if (cr) cr.value = preset === 'inherit-global' ? 'false' : 'true'; + } + } + + formInput(name, label, value, type = 'text', placeholder = '', unit = '') { + const escVal = this.escape(value ?? ''); + const escPh = this.escape(placeholder); + const escLabel = this.escape(label); + const inputHTML = ``; + const wrapped = unit ? `
${inputHTML}${this.escape(unit)}
` : inputHTML; + return ` + + `; + } + + formSelect(name, label, value, options) { + const escLabel = this.escape(label); + const opts = options.map(([v, lbl]) => ``).join(''); + return ` + + `; + } + + formToggle(name, label, checked) { + const escLabel = this.escape(label); + return ` + + `; + } + + /* Append a "Details" button next to every Engine field (global or + per-location). The button reads its engine id from a sibling input + at click time so per-location selects work even before save. */ + enhanceEngineDetailsButton() { + const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]'; + document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => { + const customSelect = engineInput.closest('.custom-select'); + const wrapTarget = customSelect || engineInput; + const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement; + if (!group || group.dataset.engineDetailsBound === '1') return; + group.dataset.engineDetailsBound = '1'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'backup-secondary-btn backup-engine-details-btn'; + btn.dataset.action = 'open-engine-details'; + btn.innerHTML = ` + + + + + + Details + `; + + const wrap = document.createElement('div'); + wrap.className = 'backup-engine-input-row'; + wrapTarget.parentNode.insertBefore(wrap, wrapTarget); + wrap.appendChild(wrapTarget); + wrap.appendChild(btn); + }); + } + + async openEngineDetailsModal(triggerEl) { + const modal = document.getElementById('backup-engine-modal'); + const body = document.getElementById('backup-engine-modal-body'); + const title = document.getElementById('backup-engine-modal-title'); + if (!modal || !body) return; + + // Find the engine select adjacent to the Details button that fired + // this event so per-location Details work even when the user has + // changed the select but not saved yet. + let engineId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim(); + const row = triggerEl?.closest('.backup-engine-input-row'); + const sel = row?.querySelector('select, input'); + if (sel && sel.value) engineId = sel.value.trim(); + body.innerHTML = `
Loading engine details…
`; + modal.classList.add('open'); + + const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`); + if (!data) { + body.innerHTML = ` +
+ No details file for engine "${this.escape(engineId)}".
+ Add scripts/backup/engines/${this.escape(engineId)}.json and run the WebUI regen. +
+ `; + return; + } + + if (title) title.textContent = `Backup engine: ${data.name || engineId}`; + const propsHTML = (data.properties || []).map(p => + `${this.escape(p.label)}${this.escape(p.value)}` + ).join(''); + const featsHTML = (data.features || []).map(f => `
  • ${this.escape(f)}
  • `).join(''); + const docsHTML = data.docs_url + ? `${this.escape(data.docs_url)} ↗` + : ''; + const logoHTML = data.logo + ? `` + : ''; + + body.innerHTML = ` +
    + ${logoHTML} +
    +

    ${this.escape(data.name || engineId)}

    +

    ${this.escape(data.tagline || '')}

    +
    +
    + ${propsHTML ? `${propsHTML}
    ` : ''} + ${featsHTML ? `
    Highlights
      ${featsHTML}
    ` : ''} + ${docsHTML ? `
    Documentation

    ${docsHTML}

    ` : ''} + `; + } + + formCrontab(name, label, value) { + if (typeof ConfigShared === 'undefined' || !ConfigShared.createCrontabField) { + return this.formInput(name, label, value, 'text', 'minute hour day month weekday'); + } + const fieldId = `config-${name}`; + let cronHtml = ConfigShared.createCrontabField(fieldId, name, value, label, ''); + cronHtml = cronHtml.replace(`name="${name}"`, `name="${name}" data-backup-field`); + return ` + + `; + } + + formReadOnly(label, value) { + return ` +
    + ${this.escape(label)} + ${this.escape(value)} +
    + `; + } + + /* ----- Location modal (edit / add) ----- */ + + openLocationModal_unused(idx) { + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + if (!loc) return; + + const modal = document.getElementById('backup-location-modal'); + const body = document.getElementById('backup-location-modal-body'); + const title = document.getElementById('backup-location-modal-title'); + if (!modal || !body) return; + + modal.dataset.locIdx = idx; + title.textContent = `Edit location: ${loc.name}`; + + body.innerHTML = ` +
    +
    +
    +
    +

    Retention

    +

    When to delete old backups from this location.

    +
    +
    + `; + + this.refreshLocationModalTypeFields(loc.type, loc); + this.refreshLocationModalRetention(loc.custom_retention); + + modal.classList.add('open'); + } + + refreshLocationModalTypeFields(type, locOverride) { + const container = document.getElementById('backup-location-connection'); + const modal = document.getElementById('backup-location-modal'); + if (!container || !modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = locOverride || (this.locations?.locations || []).find(l => l.idx === idx) || {}; + + const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + container.innerHTML = this.renderLocFields(idx, suffixes, loc); + this.tagFieldsForSave(container); + } + + refreshLocationModalRetention(enabled) { + const container = document.getElementById('backup-location-retention'); + const modal = document.getElementById('backup-location-modal'); + if (!container || !modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {}; + + // The "Use custom retention" toggle itself stays at the top regardless. + const toggleField = this.renderLocFields(idx, ['CUSTOM_RETENTION'], loc); + + if (!enabled) { + container.innerHTML = ` + ${toggleField} +
    Inherits the global retention policy from the Configuration tab.
    + `; + this.tagFieldsForSave(container); + return; + } + + const values = { + last: loc.keep_last || '', + daily: loc.keep_daily || '', + weekly: loc.keep_weekly || '', + monthly: loc.keep_monthly || '', + yearly: loc.keep_yearly || '' + }; + container.innerHTML = ` + ${toggleField} + ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, values)} + `; + this.tagFieldsForSave(container); + } + + /* Render a list of CFG_BACKUP_LOC_${idx}_${suffix} fields via the same + ConfigShared.generateField machinery /config uses, so widgets and + styling match pixel-for-pixel. Values are picked up from the location + object (locations.json) using the camelCase mirrors of each suffix. */ + renderLocFields(idx, suffixes, loc) { + if (typeof ConfigShared === 'undefined' || !ConfigShared.generateField) { + return `
    Configuration system not loaded.
    `; + } + const locValueLookup = { + NAME: loc.name, ENABLED: loc.enabled ? 'true' : 'false', TYPE: loc.type, + ENGINE: loc.engine || 'restic', + PATH_MODE: loc.path_mode || 'custom', + PATH: loc.path, URI: loc.uri, SSH_USER: loc.ssh_user, SSH_HOST: loc.ssh_host, + SSH_PORT: loc.ssh_port, SSH_PATH: loc.ssh_path, + SSH_AUTH: loc.ssh_auth || 'key', SSH_PASS: '', + S3_ACCESS_KEY: '', S3_SECRET_KEY: '', + B2_ACCOUNT_ID: '', B2_ACCOUNT_KEY: '', + APPEND_ONLY: loc.append_only ? 'true' : 'false', + CUSTOM_RETENTION: loc.custom_retention ? 'true' : 'false', + KEEP_LAST: loc.keep_last, KEEP_DAILY: loc.keep_daily, + KEEP_WEEKLY: loc.keep_weekly, KEEP_MONTHLY: loc.keep_monthly, + KEEP_YEARLY: loc.keep_yearly + }; + + let html = '
    '; + let inBlock = 0; + for (const suffix of suffixes) { + const def = BACKUP_LOC_FIELD_DEFS[suffix]; + if (!def) continue; + const key = `CFG_BACKUP_LOC_${idx}_${suffix}`; + const value = (locValueLookup[suffix] ?? '').toString(); + const fieldId = `config-${key}`; + // Three-up grouping mirrors /config's row layout. + if (inBlock > 0 && inBlock % 3 === 0) html += '
    '; + html += ConfigShared.generateField(fieldId, key, value, def.title, def.description, {}, {}); + inBlock++; + } + html += '
    '; + return html; + } + + tagFieldsForSave(container) { + container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => { + if (!el.hasAttribute('data-backup-field')) { + el.setAttribute('data-backup-field', ''); + if (el.type === 'checkbox') el.setAttribute('data-backup-bool', ''); + } + }); + } + + async saveLocationModal() { + const modal = document.getElementById('backup-location-modal'); + if (!modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + this.closeAllModals(); + await this.saveSection(`location-${idx}`); + } + + async deleteLocationModal() { + const modal = document.getElementById('backup-location-modal'); + if (!modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + const name = loc?.name || `Location ${idx}`; + if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted by this action — only LibrePortal's reference to it. The password file on disk also stays in place (rename it manually if you want to start fresh).`)) return; + this.closeAllModals(); + await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + /* ----- Add location modal ----- */ + + openAddLocationModal() { + const modal = document.getElementById('backup-add-location-modal'); + const body = document.getElementById('backup-add-location-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +
    + ${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')} + ${this.formSelect('__add_type', 'Type', 'local', [ + ['local', 'Local / mounted path'], + ['sftp', 'SFTP'], + ['rest', 'REST server'], + ['s3', 'S3'], + ['b2', 'Backblaze B2'], + ['gs', 'Google Cloud Storage'], + ['azure', 'Azure'], + ['rclone', 'rclone'] + ])} +
    +

    The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.

    + `; + modal.classList.add('open'); + } + + async confirmAddLocation() { + const modal = document.getElementById('backup-add-location-modal'); + if (!modal) return; + const name = modal.querySelector('[name="__add_name"]')?.value?.trim(); + const type = modal.querySelector('[name="__add_type"]')?.value || 'local'; + if (!name) { this.notify('Name is required.', 'error'); return; } + this.closeAllModals(); + const safeName = name.replace(/'/g, "'\\''"); + await this.runTask(`libreportal backup location add '${safeName}' ${type}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + /* ----- Snapshot restore/delete modals ----- */ + + openRestoreModal(app, locIdx, snapshot) { + const locName = this.locName(locIdx); + const modal = document.getElementById('backup-restore-modal'); + const body = document.getElementById('backup-restore-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +

    Restore ${this.escape(app)} from backup ${this.escape(snapshot)} at ${this.escape(locName)}?

    +

    The app will be stopped, its folder wiped, the snapshot restored in place, then the app started again. App-specific pre/post-restore hooks run if present.

    + `; + modal.dataset.app = app; + modal.dataset.locIdx = locIdx; + modal.dataset.snapshot = snapshot; + modal.classList.add('open'); + } + + openDeleteModal(app, locIdx, snapshot) { + const locName = this.locName(locIdx); + const modal = document.getElementById('backup-delete-modal'); + const body = document.getElementById('backup-delete-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +

    Delete backup ${this.escape(snapshot)} for ${this.escape(app)} from ${this.escape(locName)}?

    +

    This cannot be undone. Append-only locations will reject the operation.

    + `; + modal.dataset.app = app; + modal.dataset.locIdx = locIdx; + modal.dataset.snapshot = snapshot; + modal.classList.add('open'); + } + + locName(idx) { + const l = (this.locations?.locations || []).find(x => String(x.idx) === String(idx)); + return l?.name || `Location ${idx}`; + } + + closeAllModals() { + document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open')); + } + + async confirmRestore() { + const modal = document.getElementById('backup-restore-modal'); + const { app, locIdx, snapshot } = modal.dataset; + this.closeAllModals(); + await this.runTask(`libreportal restore app start ${app} ${snapshot} ${locIdx}`, 'restore', app); + } + + async confirmDelete() { + const modal = document.getElementById('backup-delete-modal'); + const { app, locIdx, snapshot } = modal.dataset; + this.closeAllModals(); + await this.runTask(`libreportal backup app delete ${app} ${locIdx}:${snapshot}`, 'backup', app); + } + + async runBackupAllApps() { + await this.runTask(`libreportal backup all`, 'backup', null); + } + + async runTask(command, type, app) { + if (!this.taskManager) { + this.notify('Task system unavailable', 'error'); + return; + } + try { + await this.taskManager.createTask(command, type, app); + setTimeout(() => this.refreshAll().then(() => this.render()), 1500); + } catch (err) { + this.notify(`Failed to queue task: ${err.message || err}`, 'error'); + } + } + + /* ----- Generic save handler ----- */ + + async saveSection(sectionId) { + let scope; + if (sectionId.startsWith('location-')) { + const idx = sectionId.slice('location-'.length); + scope = document.querySelector(`.backup-location-row[data-loc="${idx}"] .task-details`); + } else { + scope = document.querySelector(`#backup-panel-${sectionId}`); + } + if (!scope) return; + + const cfg = window.systemConfigs || {}; + const changes = []; + scope.querySelectorAll('[data-backup-field]').forEach(el => { + const name = el.name; + if (!name || name.startsWith('__')) return; + let value; + if (el.hasAttribute('data-backup-bool')) { + value = el.checked ? 'true' : 'false'; + } else { + value = (el.value ?? '').toString(); + } + const original = (cfg[name] ?? '').toString(); + if (value === original) return; + changes.push(`${name}=${value.replace(/\|/g, '%7C')}`); + }); + + if (!changes.length) { + this.notify('No changes to save.', 'info'); + return; + } + + const encoded = changes.join('|'); + try { + if (!window.tasksManager?.router) throw new Error('Task system not available'); + await window.tasksManager.router.routeAction('config_update', { + changes: `'${encoded.replace(/'/g, "'\\''")}'` + }); + this.notify(`Saving ${changes.length} change${changes.length === 1 ? '' : 's'}…`, 'success'); + setTimeout(() => this.reloadAfterSave(), 2500); + } catch (err) { + this.notify(`Save failed: ${err.message || err}`, 'error'); + } + } + + async reloadAfterSave() { + try { + const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`); + if (r.ok) window.systemConfigs = await r.json(); + } catch {} + await this.refreshAll(); + this.render(); + } + + notify(message, type) { + if (window.notificationSystem) { + window.notificationSystem.show(message, type || 'info'); + } else { + console.log(`[backup ${type || 'info'}] ${message}`); + } + } + + escape(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + + formatBytes(b) { + if (!b || b < 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let v = b; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; + } + + formatRelative(iso) { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!t) return iso; + const diff = Math.max(0, Date.now() - t); + const s = Math.floor(diff / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); + } +} + +window.BackupPage = BackupPage; diff --git a/containers/libreportal/frontend/js/components/config/config-core.js b/containers/libreportal/frontend/js/components/config/config-core.js new file mode 100755 index 0000000..ab9c43d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-core.js @@ -0,0 +1,80 @@ +// Core Config Management - Handles loading, caching, and basic operations +class ConfigCore { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your configuration settings...", + "Gathering finest configuration options...", + "Loading configuration data...", + "Fetching your preferences...", + "Setting up your workspace...", + "Loading configuration components...", + "Preparing configuration interface...", + "Gathering system settings...", + "Loading configuration modules...", + "Initializing configuration system..." + ]; + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadConfig(category) { + //console.log(`ConfigCore: Loading ${category} config...`); + + if (this.cache.has('unified')) { + //console.log(`ConfigCore: Using cached unified config`); + const cachedData = this.cache.get('unified'); + window.configData = cachedData; // Make available globally + return cachedData.subcategories || {}; + } + + try { + //console.log(`ConfigCore: Fetching from /data/config/generated/configs.json`); + const response = await fetch('/data/config/generated/configs.json'); + //console.log(`ConfigCore: Response status: ${response.status}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const configData = await response.json(); + //console.log(`ConfigCore: Loaded config data:`, configData); + + this.cache.set('unified', configData); + window.configData = configData; // Make available globally + + // Return the actual subcategories, not just the category metadata + const categoryData = configData.subcategories || {}; + //console.log(`ConfigCore: Available subcategories:`, Object.keys(categoryData)); + + return categoryData; + } catch (error) { + console.error(`ConfigCore: Error loading ${category} config:`, error); + return {}; + } + } + + filterConfigByCategory(unifiedData, category) { + if (!unifiedData || !unifiedData.config) { + return {}; + } + + const categoryData = {}; + Object.entries(unifiedData.config).forEach(([key, value]) => { + if (value.category === category) { + categoryData[key] = value; + } + }); + + return categoryData; + } + + clearCache() { + this.cache.clear(); + } +} + +// Export for use in other modules +window.ConfigCore = ConfigCore; diff --git a/containers/libreportal/frontend/js/components/config/config-form.js b/containers/libreportal/frontend/js/components/config/config-form.js new file mode 100755 index 0000000..b2d6b8d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-form.js @@ -0,0 +1,124 @@ +class ConfigForm { + constructor() { + this.form = null; + this._submitHandlerAttached = false; + } + + resetForm() { + this.form = document.getElementById('config-form'); + if (this.form) { + this.form.reset(); + } + } + + snapshotOriginal() { + const original = {}; + const data = window.configData && window.configData.config; + if (!data) return original; + Object.entries(data).forEach(([key, entry]) => { + original[key] = entry && entry.value !== undefined ? String(entry.value) : ''; + }); + return original; + } + + collectChanges(original) { + const changes = []; + if (!this.form) return changes; + + const current = {}; + const inputs = this.form.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_')) return; + if (name.endsWith('_PORT_MANAGER')) return; // UI-only aggregate + let value; + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = (input.value || '').trim(); + } + current[name] = value; + }); + + Object.keys(current).forEach((name) => { + const oldValue = original[name] !== undefined ? original[name] : ''; + const newValue = current[name]; + if (oldValue === newValue) return; + const encoded = newValue.replace(/\|/g, '%7C'); + changes.push(`${name}=${encoded}`); + }); + + return changes; + } + + async saveConfig() { + this.form = document.getElementById('config-form'); + if (!this.form) { + console.error('ConfigForm: Form not found'); + return; + } + + const original = this.snapshotOriginal(); + const changes = this.collectChanges(original); + + if (changes.length === 0) { + this.showNotification('No configuration changes to save.', 'info'); + return; + } + + const encoded = changes.join('|'); + + try { + if (!window.tasksManager || !window.tasksManager.router) { + throw new Error('Task system not available'); + } + const task = await window.tasksManager.router.routeAction('config_update', { + changes: `'${encoded.replace(/'/g, "'\\''")}'` + }); + + this.showNotification( + `Saving ${changes.length} configuration change${changes.length === 1 ? '' : 's'}...`, + 'success' + ); + + if (task && window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + setTimeout(() => window.librePortalSPA.navigate(`/tasks?=all&task=${task.id}`), 400); + } else if (task && window.navigateToRoute) { + setTimeout(() => window.navigateToRoute(`tasks?=all&task=${task.id}`), 400); + } + } catch (error) { + console.error('ConfigForm: Error saving configuration:', error); + this.showNotification('Failed to save configuration: ' + error.message, 'error'); + } + } + + // preventDefault stops the form from falling back to GET (which dumps every + // CFG into the URL). + attachSubmitHandler() { + const form = document.getElementById('config-form'); + if (!form) return; + if (form.dataset.submitWired === '1') return; + form.dataset.submitWired = '1'; + form.addEventListener('submit', (event) => { + event.preventDefault(); + this.saveConfig(); + }); + } + + showNotification(message, type) { + type = type || 'info'; + if (window.notificationSystem) { + window.notificationSystem.show(message, type); + return; + } + const notification = document.createElement('div'); + notification.className = 'notification notification-' + type; + notification.textContent = message; + document.body.appendChild(notification); + setTimeout(() => { + if (notification.parentNode) notification.parentNode.removeChild(notification); + }, 5000); + } +} + +window.ConfigForm = ConfigForm; diff --git a/containers/libreportal/frontend/js/components/config/config-manager-old.js b/containers/libreportal/frontend/js/components/config/config-manager-old.js new file mode 100755 index 0000000..c328c98 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-manager-old.js @@ -0,0 +1,1645 @@ +// Simple Config Manager - Direct approach without complex dependencies +class ConfigManager { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your configuration settings...", + "Gathering the finest configuration options...", + "Tuning up your system preferences...", + "Organizing your configuration categories...", + "Loading the perfect settings for you...", + "Crafting your personalized configuration...", + "Aligning your configuration stars...", + "Brewing the ideal configuration blend...", + "Setting up your configuration masterpiece...", + "Polishing your configuration preferences...", + "Configuring things just right for you...", + "Preparing your digital control panel...", + "Gathering your system's best settings...", + "Optimizing your configuration experience...", + "Loading your configuration superpowers..." + ]; + + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadConfig(category) { + //console.log(`ConfigManager: Loading ${category} config...`); + + // Check cache first + if (this.cache.has('unified')) { + //console.log(`ConfigManager: Using cached unified config`); + const unifiedData = this.cache.get('unified'); + return this.filterConfigByCategory(unifiedData, category); + } + + try { + // Load unified config data + const response = await fetch('/data/config/generated/configs.json'); + if (!response.ok) { + throw new Error(`Failed to load configs.json: ${response.status}`); + } + + const configData = await response.json(); + //console.log(`ConfigManager: Loaded unified config:`, configData); + + // Cache the result + this.cache.set('unified', configData); + + // Filter by requested category + const categoryData = this.filterConfigByCategory(configData, category); + //console.log(`ConfigManager: Filtered ${category} config:`, categoryData); + + return categoryData; + + } catch (error) { + console.error(`ConfigManager: Error loading ${category} config:`, error); + return { config: {}, description: 'Failed to load configuration' }; + } + } + + filterConfigByCategory(unifiedData, category) { + if (!unifiedData || !unifiedData.config) { + return { config: {}, categories: {} }; + } + + // Filter config items by category + const filteredConfig = {}; + Object.entries(unifiedData.config).forEach(([key, value]) => { + if (value.category === category) { + filteredConfig[key] = value; + } + }); + + return { + config: filteredConfig, + categories: unifiedData.categories || {}, + subcategories: unifiedData.subcategories || {}, + configType: unifiedData.configType, + name: unifiedData.name + }; + } + + async renderConfig(category) { + //console.log(`ConfigManager: Rendering ${category} config...`); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('ConfigManager: config-section element not found'); + return; + } + + // Show loading with enhanced visual + configSection.innerHTML = ` +
    +
    +
    +
    + Loading configuration... +
    +
    + ${this.getRandomLoadingMessage()} +
    +
    + +
    + `; + + // Update loading bar if available + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + try { + // Load config data + const configData = await this.loadConfig(category); + const config = configData.config || {}; + + // Update loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(70); + } + + if (Object.keys(config).length === 0) { + configSection.innerHTML = '
    No configuration available
    '; + return; + } + + // Use the original ConfigShared system for beautiful rendering + if (typeof ConfigShared !== 'undefined') { + await this.renderWithOriginalStyling(category, configData); + } else { + // Fallback: load ConfigShared and then render + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(75); + } + await this.loadScript('/js/components/config/config-options.js'); + await this.loadScript('/js/components/config/config-shared.js'); + await this.renderWithOriginalStyling(category, configData); + } + + // Final progress update + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + //console.log(`ConfigManager: Successfully rendered ${category} config`); + + // Initialize git field visibility after rendering + if (typeof initializeGitFieldVisibility === 'function') { + setTimeout(() => { + initializeGitFieldVisibility(); + }, 100); + } + + } catch (error) { + console.error(`ConfigManager: Error rendering ${category} config:`, error); + configSection.innerHTML = `
    Failed to load ${category} configuration: ${error.message}
    `; + } + } + + async renderWithOriginalStyling(category, configData) { + //console.log(`renderWithOriginalStyling: category=${category}, CFG_INSTALL_MODE=${configData.config?.CFG_INSTALL_MODE?.value}`); + const configSection = document.getElementById('config-section'); + const config = configData.config || {}; + const subcategories = configData.subcategories || {}; + + // Check if we have subcategories data available + const hasSubcategories = Object.keys(subcategories).length > 0; + + // Use shared categorization functionality + const categorized = ConfigShared.categorizeConfigs(config); + const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; + + // Render using the original system's approach with advanced/unused sections + let formHTML = ` +
    +

    ${this.formatCategoryName(category)} Configuration

    +

    ${configData.description || 'Configure settings for ' + category}

    +
    +
    +
    + `; + + // Add requirements warning for requirements category + if (category === 'requirements') { + formHTML += ConfigShared.generateRequirementsWarning(); + } + + // Add danger zone warning for features category + if (category === 'features') { + formHTML += ` +
    +
    + ⚠️ +
    + Danger Zone - These options are for advanced users and may affect system stability +
    +
    +
    + `; + } + + // Render using subcategories structure if available, otherwise fall back to original + if (hasSubcategories) { + // Render using new subcategories structure + const regularSubcategories = []; + const advancedSubcategories = []; + const unusedSubcategories = []; + + // Filter subcategories by category and separate into regular, advanced, and unused + for (const [subcategoryName, subcategoryData] of Object.entries(subcategories)) { + if (subcategoryData.category === category) { + if (subcategoryData.description.includes('**ADVANCED**')) { + advancedSubcategories.push(subcategoryName); + } else if (subcategoryData.description.includes('**UNUSED**')) { + unusedSubcategories.push(subcategoryName); + } else { + regularSubcategories.push(subcategoryName); + } + } + } + + // Render regular subcategories with proper sectioning + for (const subcategoryName of regularSubcategories) { + const subcategoryData = subcategories[subcategoryName]; + const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName); + const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category); + const subcategoryDescription = subcategoryData.description || 'Subcategory configuration'; + + // Find config items for this subcategory + const configItems = Object.entries(config) + .filter(([key, value]) => value.subcategory === subcategoryName) + .map(([key, value]) => ({ key, ...value })); + + if (configItems.length > 0) { + // Check for master toggle in this subcategory + const masterKey = configItems.find(item => item.master === true); + + // Find any ENABLED options and use universal toggle renderer + const enabledKey = configItems.find(item => item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE'); + //console.log('ConfigManager: Checking for toggle - subcategoryName:', subcategoryName, 'enabledKey found:', !!enabledKey, enabledKey ? enabledKey.key : null); + + // Special handling for domains section + const isDomains = subcategoryName.includes('domains'); + + if (enabledKey) { + // Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE + formHTML += ToggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription, config); + } else if (masterKey) { + // Render with master toggle + formHTML += this.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription); + } else if (isDomains) { + // Render domains section with special handling + formHTML += await this.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription); + } else { + // Render regular subcategory with proper sectioning + formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config); + } + } + } + + // Render advanced subcategories (hidden by default) + if (advancedSubcategories.length > 0) { + // Add danger zone toggle for advanced sections + formHTML += ConfigShared.generateToggleControls(true, false); + + // Generate advanced sections using shared functionality + const advancedGroupedConfigs = {}; + advancedSubcategories.forEach(subcategoryName => { + const configItems = Object.entries(config) + .filter(([key, value]) => value.subcategory === subcategoryName) + .map(([key, value]) => ({ key, ...value })); + + if (configItems.length > 0) { + advancedGroupedConfigs[subcategoryName] = configItems.map(item => item.key); + } + }); + + formHTML += await ConfigShared.generateAdvancedSections( + advancedSubcategories, + advancedGroupedConfigs, + config, + (category) => this.cleanDescription(subcategories[category]?.description || 'Advanced settings') + ); + } + + // Render unused subcategories (hidden by default) + if (unusedSubcategories.length > 0) { + // Add unused section toggle + formHTML += ConfigShared.generateToggleControls(false, true); + + // Wrap unused sections in hidden container + formHTML += ` + + `; + } + } else { + // Fall back to original categorization system + const categorized = ConfigShared.categorizeConfigs(config); + const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; + + // Render regular categories (always visible) + for (const cat of regularCategories) { + const keys = groupedConfigs[cat]; + if (keys && keys.length > 0 && cat !== 'Hidden/Unused Options') { + const displayCategory = ConfigShared.formatCategoryName(cat); + const categoryDescription = await ConfigShared.getCategoryDescription(cat); + + // Check if this category has a master toggle (any key with master: true) + const masterKey = keys.find(key => { + const configItem = config[key] || {}; + return configItem.master === true; + }); + + if (masterKey) { + // Dynamic master toggle handling + const masterValue = config[masterKey]?.value || 'false'; + const isMasterEnabled = masterValue === 'true'; + const masterTitle = config[masterKey]?.title || ConfigShared.formatConfigLabel(masterKey); + const masterDescription = config[masterKey]?.description || ''; + const sectionId = `${cat.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + const toggleId = `${masterKey.toLowerCase()}-toggle`; + + formHTML += ` +
    +
    +
    +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the master toggle) + keys.filter(key => key !== masterKey).forEach(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || ConfigShared.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + formHTML += ConfigShared.generateField(fieldId, key, value, title, description, options, config); + }); + + formHTML += ` +
    +
    +
    +
    +
    + `; + } else if (cat === 'DOMAINS') { + // Check if Traefik is installed + const traefikInstalled = await this.checkTraefikInstallation(); + + // Always show the domains section, but add a warning banner if Traefik is not installed + formHTML += ` +
    +
    +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    +
    + `; + + if (!traefikInstalled) { + // Show smaller warning banner + formHTML += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + formHTML += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = keys.filter(key => key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + return value.trim() !== ''; + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + const isMaxDomains = domainKeysWithContent.length >= 9; + + domainKeysWithContent.forEach(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || ConfigShared.formatConfigLabel(key); + const fieldId = `config-${key}`; + + // Extract domain number + const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + formHTML += ` +
    +
    + ${ConfigShared.generateField(fieldId, key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input' + })} + +
    +
    + `; + }); + + formHTML += ` +
    +
    + +
    +
    +
    + `; + } else { + // Regular category handling (no master toggle) + formHTML += ` +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    + ${ConfigShared.generateFieldsForCategory(keys, cat, config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))} +
    +
    + `; + } + } + } + } + + // Add danger zone before advanced/unused sections (so content appears below) + formHTML += ConfigShared.generateToggleControls( + advancedCategories.length > 0, + unusedCategories.length > 0 + ); + + // Add advanced and unused sections using shared functionality + formHTML += await ConfigShared.generateAdvancedSections( + advancedCategories, + groupedConfigs, + config, + (category) => ConfigShared.getCategoryDescription(category) + ); + + formHTML += await ConfigShared.generateUnusedSections( + unusedCategories, + groupedConfigs, + config, + (category) => ConfigShared.getCategoryDescription(category) + ); + + formHTML += ` +
    +
    + + +
    +
    + `; + + configSection.innerHTML = formHTML; + + // Initialize all master toggles dynamically + setTimeout(() => { + // Find all master toggle checkboxes (any input with id ending in "-toggle" where the name ends with "_ENABLED") + const masterToggles = document.querySelectorAll('input[id$="-toggle"][name$="_ENABLED"]'); + + masterToggles.forEach(toggle => { + const sectionId = toggle.id.replace('-toggle', ''); + const section = document.getElementById(`section-content-${sectionId}`); + + if (section && typeof ConfigShared.toggleSectionVisibility === 'function') { + // Initialize the section state based on the toggle + ConfigShared.toggleSectionVisibility(`section-content-${sectionId}`, toggle.checked); + } + }); + }, 100); + } + + // Check if Traefik is installed + async checkTraefikInstallation() { + try { + // Use the generic app installation checker + return await DataLoader.isAppInstalled('traefik'); + } catch (error) { + //console.log('Traefik check failed:', error.message); + return false; + } + } + + // Domain management functions + addNewDomain() { + //console.log('Add Domain button clicked!'); + + try { + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })} + +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + const addBtn = document.getElementById('add-domain-btn'); + if (addBtn) { + const totalDomains = document.querySelectorAll('.domain-building-block').length; + if (totalDomains >= 9) { + addBtn.disabled = true; + addBtn.className = 'btn btn-secondary'; + addBtn.innerHTML = 'Maximum Domains Reached'; + } + } + + // Update delete button states (only highest numbered domain should be deletable) + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + const inputName = input ? input.name : ''; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const allDomainNumbers = Array.from(allDomainBlocks).map(b => { + const inp = b.querySelector('input'); + const name = inp ? inp.name : ''; + const match = name.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + }); + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; + } + }); + } + } catch (error) { + console.error('Error adding new domain:', error); + } + } + + deleteDomain(domainKey, buttonElement) { + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update add domain button state (re-enable if we're below 9 domains) + const addBtn = document.getElementById('add-domain-btn'); + if (addBtn) { + const totalDomains = document.querySelectorAll('.domain-building-block').length; + if (totalDomains < 9) { + addBtn.disabled = false; + addBtn.className = 'btn btn-primary'; + addBtn.innerHTML = '+Add Domain'; + } + } + + // Update delete button states (only highest numbered domain should be deletable) + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + const inputName = input ? input.name : ''; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const allDomainNumbers = Array.from(allDomainBlocks).map(b => { + const inp = b.querySelector('input'); + const name = inp ? inp.name : ''; + const match = name.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + }); + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; + } + }); + } + } catch (error) { + console.error('Error deleting domain:', error); + } + } + + async loadScript(src) { + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + const existingScript = document.getElementById(scriptId); + + if (existingScript && src.includes('config-shared.js')) { + existingScript.remove(); + } else if (existingScript) { + return; + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); + } + + formatCategoryName(category) { + return category + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } + + static async saveConfig(category) { + const form = document.getElementById(`config-form-${category}`); + if (!form) return; + + const formData = new FormData(form); + const config = {}; + + for (const [key, value] of formData.entries()) { + const checkbox = form.querySelector(`input[name="${key}"][type="checkbox"]`); + if (checkbox) { + config[key] = checkbox.checked; + } else { + config[key] = value; + } + } + } + + static async resetConfig(category) { + if (confirm('Are you sure you want to reset all settings to their default values?')) { + window.location.reload(); + } + } + + // Helper method to render subcategory with master toggle + renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription, isAdvanced = false) { + const masterValue = masterKey.value || 'false'; + const isMasterEnabled = masterValue === 'true'; + const masterTitle = masterKey.title || ConfigShared.formatConfigLabel(masterKey.key); + const masterDescription = masterKey.description || ''; + const sectionId = `${displaySubcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + const toggleId = `${masterKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the master toggle) + configItems.filter(item => item.key !== masterKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render domains section with special handling + async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) { + // Check if Traefik is installed + const traefikInstalled = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    + `; + + if (!traefikInstalled) { + // Show smaller warning banner + html += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + html += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = configItems.filter(item => item.key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(item => { + const value = item.value || ''; + return value.trim() !== ''; + }); + + // Only render domains that have content + domainKeysWithContent.forEach(item => { + const value = item.value || ''; + const title = item.title || ConfigShared.formatConfigLabel(item.key); + const fieldId = `config-${item.key}`; + + // Extract domain number + const domainNum = parseInt(item.key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.key.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + html += ` +
    +
    + ${ConfigShared.generateField(fieldId, item.key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'window.configManager.validateDomainFormat(this, true)', + oninput: 'window.configManager.validateDomainFormat(this, true)', + onblur: 'window.configManager.validateDomainFormat(this, true)' + })} + +
    +
    + `; + }); + + // Add "Add Domain" button outside the grid + const isMaxDomains = domainKeysWithContent.length >= 9; + html += ` +
    +
    + +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render remote backup section with toggle + renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + const isEnabled = backupKey.value === 'true'; + const sectionId = `backup-${backupKey.key}`; + const toggleId = `${backupKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the ENABLED toggle) + configItems.filter(item => item.key !== backupKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render mail section with toggle + renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + const isEnabled = mailKey.value === 'true'; + const sectionId = `mail-${mailKey.key}`; + const toggleId = `${mailKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the ENABLED toggle) + configItems.filter(item => item.key !== mailKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + // Add test connection button after all mail fields + html += ` +
    + + +
    +
    +
    +
    +
    +
    + `; + + return html; + } + + // Test mail server connection + async testMailConnection(mailKey) { + const resultDiv = document.getElementById('mail-test-result'); + const button = event.target.closest('button'); + + // Show loading state + button.disabled = true; + button.innerHTML = 'Testing...'; + resultDiv.style.display = 'block'; + resultDiv.className = 'test-result testing'; + resultDiv.innerHTML = 'Testing mail server connection...'; + + // Initialize mailConfig outside try block for catch block access + let mailConfig = {}; + + try { + // Get current mail configuration values from the form + mailConfig = { + host: document.querySelector('input[name="CFG_MAIL_HOST"]')?.value || '', + port: document.querySelector('input[name="CFG_MAIL_PORT"]')?.value || '', + secure: document.querySelector('select[name="CFG_MAIL_SECURE"]')?.value || '', + username: document.querySelector('input[name="CFG_MAIL_USERNAME"]')?.value || '', + password: document.querySelector('input[name="CFG_MAIL_PASSWORD"]')?.value || '', + from: document.querySelector('input[name="CFG_MAIL_FROM"]')?.value || '' + }; + + // Validate required fields + if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) { + throw new Error('Please fill in all required mail server fields (host, port, username, password)'); + } + + // Call backend test script + const response = await fetch('/api/test-mail-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mailConfig) + }); + + const result = await response.json(); + + if (result.success) { + resultDiv.className = 'test-result success'; + resultDiv.innerHTML = `✅ ${result.message || 'Mail server connection successful!'}${result.details ? `
    ${result.details}` : ''}`; + } else { + resultDiv.className = 'test-result error'; + let errorHtml = `❌ ${result.message || 'Mail server connection failed'}`; + + // Add detailed error information directly underneath + if (result.details || result.error || result.config) { + errorHtml += ` +
    + Error Details:
    + ${result.details || result.error || 'No additional details available'} + ${result.stack ? `

    Stack Trace:
    ${result.stack}` : ''} + ${result.config ? `

    Connection Config:
    ${JSON.stringify(result.config, null, 2)}` : ''} +
    + `; + } + + resultDiv.innerHTML = errorHtml; + } + + } catch (error) { + resultDiv.className = 'test-result error'; + resultDiv.innerHTML = ` + ❌ ${error.message || 'Failed to test mail connection'} +
    + Error Details:
    + ${error.message || 'Unknown error'} + ${error.stack ? `

    Stack Trace:
    ${error.stack}` : ''} + ${error.response ? `

    Response:
    ${JSON.stringify(error.response, null, 2)}` : ''} + ${mailConfig ? `

    Mail Config:
    ${JSON.stringify({...mailConfig, password: mailConfig.password ? '[REDACTED]' : undefined}, null, 2)}` : ''} +
    + `; + } finally { + // Restore button state + button.disabled = false; + button.innerHTML = '📧Test Mail Connection'; + } + } + + // Helper method to render Git section with toggle + renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + // CFG_INSTALL_MODE controls git section: 'git' = enabled, 'local' = disabled + const isEnabled = gitKey.value === 'git'; + const sectionId = `git-${gitKey.key}`; + const toggleId = `${gitKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + + // Add all other git fields (excluding the CFG_INSTALL_MODE itself) + configItems.filter(item => item.key !== gitKey.key && item.key.startsWith('CFG_GIT_')).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to clean description text by removing tags + cleanDescription(description) { + return description + .replace(/\*\*ADVANCED\*\*/g, '') + .replace(/\*\*UNUSED\*\*/g, '') + .replace(/^\s+|\s+$/g, '') // Trim whitespace + .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space + } + + // Update all domain delete button states + updateDomainDeleteButtons() { + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find the highest domain number (regardless of content) + const domainNumbers = Array.from(allDomainBlocks).map(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + if (input) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + } + return 0; + }).filter(num => num > 0); + + const highestDomain = Math.max(...domainNumbers, 0); + + // Update delete buttons + allDomainBlocks.forEach(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + const deleteBtn = block.querySelector('.delete-domain-btn'); + + if (input && deleteBtn) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + const domainNum = match ? parseInt(match[1]) : 0; + + // SIMPLE RULE: Only highest numbered domain can be deleted (except Domain 1) + const canDelete = domainNum === highestDomain && domainNum !== 1; + + if (canDelete) { + deleteBtn.classList.remove('disabled'); + deleteBtn.disabled = false; + deleteBtn.title = 'Delete domain'; + } else { + deleteBtn.classList.add('disabled'); + deleteBtn.disabled = true; + if (domainNum === 1) { + deleteBtn.title = 'Domain 1 cannot be deleted'; + } else { + deleteBtn.title = 'Can only delete highest numbered domain'; + } + } + } + }); + } + + // Validate domain format when user tries to add a new domain + validateDomainFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(value); + + // Check for duplicates + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => { + if (otherInput === input) return false; // Skip self + return otherInput.value.trim().toLowerCase() === value.toLowerCase(); + }); + const hasDuplicate = duplicates.length > 0; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid domain format: "${value}". Please use a valid domain like example.com`); + } + return false; + } else if (hasDuplicate) { + input.style.borderColor = '#ffc107'; + input.title = 'Domain already exists!'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.warning(`Domain "${value}" already exists. Please use a unique domain.`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate email format for mail fields + validateEmailFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Email validation regex + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const isValidFormat = emailRegex.test(value); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid email format (e.g., user@example.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid email format: "${value}". Please use a valid email like user@example.com`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate hostname format for mail server + validateHostnameFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Hostname validation regex - allows subdomains and multiple TLD levels + const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = hostnameRegex.test(value); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid hostname format (e.g., mail.domain.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid hostname format: "${value}". Please use a valid hostname like mail.domain.com`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate port number for mail server + validatePortNumber(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + const port = parseInt(value, 10); + const isValidPort = !isNaN(port) && port >= 1 && port <= 65535; + + if (!isValidPort) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid port number (1-65535)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid port number: "${value}". Please use a valid port between 1 and 65535`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Check if all domains are valid before allowing new domain addition + canAddNewDomain() { + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!this.validateDomainFormat(input, true)) { // Don't show notifications during bulk check + return false; // At least one domain has invalid format or duplicate + } + } + return true; // All domains are valid + } + + // Add a new domain field + addDomain(button) { + // Find all existing domain blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + const domainData = Array.from(allDomainBlocks).map(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + if (input) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + return { + num: match ? parseInt(match[1]) : 0, + value: input.value.trim(), + input: input, + block: block + }; + } + return null; + }).filter(item => item !== null); + + // Sort by domain number + domainData.sort((a, b) => a.num - b.num); + + // Find the highest domain number with content + const domainsWithContent = domainData.filter(d => d.value); + const highestDomainWithContent = domainsWithContent.length > 0 ? + Math.max(...domainsWithContent.map(d => d.num)) : 0; + + // Find the highest domain number overall (including empty) + const highestDomainOverall = Math.max(...domainData.map(d => d.num), 0); + + // Check if the highest domain (overall) is empty + const highestDomainData = domainData.find(d => d.num === highestDomainOverall); + if (highestDomainData && !highestDomainData.value) { + // Flash the empty highest domain without validation checks + const emptyInput = document.querySelector(`input[id="config-CFG_DOMAIN_${highestDomainOverall}"]`); + if (emptyInput) { + emptyInput.style.animation = 'flash 0.5s ease-in-out 2'; + emptyInput.focus(); + setTimeout(() => { + emptyInput.style.animation = ''; + }, 1000); + return; + } + + // Remove animation after it completes + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + return; + } + + // Find the next available domain slot (only if highest with content is filled) + const usedNumbers = domainData.map(d => d.num); + let nextDomain = 1; + while (usedNumbers.includes(nextDomain) && nextDomain <= 9) { + nextDomain++; + } + + // Only add if we have domains with content and the highest with content is filled + if (highestDomainWithContent === 0) { + // No domains with content yet, this shouldn't happen but handle it + } else if (nextDomain > 9) { + if (window.notificationSystem) { + window.notificationSystem.warning('Maximum of 9 domains reached!'); + } + return; + } + + // Before adding new domain, validate that all existing domains have valid format + if (!this.canAddNewDomain()) { + // Find the first invalid domain and focus it with flash + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!this.validateDomainFormat(input, false)) { // Don't show notification here + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return; // Just flash and focus, no extra notification + } + } + } + + // Create new domain field with proper structure + const domainKey = `CFG_DOMAIN_${nextDomain}`; + const fieldId = `config-${domainKey}`; + const title = `Domain ${nextDomain}`; + + const newDomainHTML = ` +
    +
    + ${ConfigShared.generateField(fieldId, domainKey, '', title, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'window.configManager.validateDomainFormat(this, true)', + oninput: 'window.configManager.validateDomainFormat(this, true)', + onblur: 'window.configManager.validateDomainFormat(this, true)' + })} + +
    +
    + `; + + // Insert inside the domain-building-blocks container before the domain-actions + const domainBlocks = button.closest('.domains-wrapper').querySelector('.domain-building-blocks'); + domainBlocks.insertAdjacentHTML('beforeend', newDomainHTML); + + // Update all delete button states after DOM is ready + setTimeout(() => this.updateDomainDeleteButtons(), 10); + + // Update button state if we're now at max domains + const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; + if (totalDomains >= 9) { + button.classList.add('disabled'); + button.disabled = true; + const iconSpan = button.querySelector('.add-icon'); + const textSpan = button.querySelector('.add-text'); + iconSpan.textContent = '✓'; + textSpan.textContent = 'Maximum Domains Reached'; + } + } + + // Delete a domain field + deleteDomain(domainKey, button) { + const domainBlock = button.closest('.domain-building-block'); + + // Clear the domain value + const input = document.getElementById(`config-${domainKey}`); + if (input) { + input.value = ''; + } + + // Remove the domain block if it's empty + if (!input || input.value === '') { + domainBlock.remove(); + } + + // Update all delete button states after DOM is ready + setTimeout(() => this.updateDomainDeleteButtons(), 10); + + // Update add button state + const addButton = document.querySelector('.add-domain-btn'); + if (addButton) { + const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; + const domainsWithContent = Array.from(document.querySelectorAll('[id^="config-CFG_DOMAIN_"]')) + .filter(input => input.value.trim() !== '').length; + + if (totalDomains < 9) { + addButton.classList.remove('disabled'); + addButton.disabled = false; + const iconSpan = addButton.querySelector('.add-icon'); + const textSpan = addButton.querySelector('.add-text'); + iconSpan.textContent = '+'; + textSpan.textContent = 'Add Domain'; + } else { + addButton.classList.add('disabled'); + addButton.disabled = true; + const iconSpan = addButton.querySelector('.add-icon'); + const textSpan = addButton.querySelector('.add-text'); + iconSpan.textContent = '✓'; + textSpan.textContent = 'Maximum Domains Reached'; + } + } + } + + // Helper method to render subcategory with proper sectioning, dividers and headers + renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) { + //console.log(`renderSubcategorySection: subcategory=${subcategoryDescription}, configKeys=${Object.keys(config)}, CFG_INSTALL_MODE=${config.CFG_INSTALL_MODE?.value}`); + const cleanDescription = this.cleanDescription(subcategoryDescription); + let html = ` +
    +

    ${displaySubcategory}

    +

    ${cleanDescription}

    +
    +
    +
    + `; + + // Add all config items using standard layout + configItems.forEach((item, index) => { + const fieldId = `config-${item.key}`; + const cleanItemDescription = this.cleanDescription(item.description || ''); + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, cleanItemDescription, item.options); + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render regular subcategory + renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    + `; + + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    + `; + + return html; + } +} + +// Global instance +window.configManager = new ConfigManager(); diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js new file mode 100755 index 0000000..4f1ce6d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-manager.js @@ -0,0 +1,307 @@ +// Config Manager - Main orchestrator for modular config system +if (typeof window.ConfigManager === 'undefined') { + //console.log('ConfigManager: Defining new ConfigManager class...'); + + class ConfigManager { + constructor() { + this.core = new ConfigCore(); + this.domainManager = new DomainManager(); + this.whitelistManager = new IPWhitelistManager(); + this.renderer = new ConfigRenderer(); + this.sidebar = new ConfigSidebar(); + this.form = new ConfigForm(); + this.utils = new ConfigUtils(); + + // Expose IPWhitelistManager globally for wrapper functions + window.IPWhitelistManager = this.whitelistManager; + } + + async renderConfig(category) { + //console.log('ConfigManager: Rendering ' + category + ' config...'); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('ConfigManager: config-section element not found'); + return; + } + + try { + // Show loading state with enhanced box styling + configSection.innerHTML = ` +
    +
    +
    + Loading configuration... +
    +
    + ${this.core.getRandomLoadingMessage()} +
    +
    + + `; + + // Load configuration data + const configData = await this.core.loadConfig(category); + + // Populate sidebar with categories + this.sidebar.populateSidebar(); + + if (Object.keys(configData).length === 0) { + configSection.innerHTML = '

    No Configuration Available

    No configuration items found for this category.

    '; + return; + } + + // Render configuration sections + var formHTML = ''; + var self = this; // Preserve 'this' context + + // Features page is system-level — add a Danger Zone header at the + // top so it's visually obvious before the user touches anything. + // Reuses the same `.danger-zone-section` / `.danger-zone-header` + // styling used elsewhere, but without the advanced/unused toggle + // tickboxes that live inside the normal danger zone — this is just + // the heading. + if (category === 'features') { + formHTML += '

    ⚠️ Danger Zone

    These options are for advanced users and may affect system stability

    '; + } + + //console.log('ConfigManager: About to process configData entries:', Object.keys(configData)); + + // Filter subcategories by type + const subcategoryTypes = this.utils.filterSubcategoriesByType(configData, category); + + // Render regular subcategories + for (const subcategoryName of subcategoryTypes.regular) { + const subcategoryData = configData[subcategoryName]; + //console.log('ConfigManager: Processing regular subcategory:', subcategoryName, 'data:', subcategoryData); + + if (typeof subcategoryData === 'object' && subcategoryData !== null) { + //console.log('ConfigManager: Calling renderSubcategory for:', subcategoryName); + formHTML += await self.renderSubcategory.call(self, category, subcategoryName, subcategoryData); + } + } + + // Render advanced and unused sections + formHTML = await this.utils.renderSectionedContent(formHTML, subcategoryTypes.advanced, subcategoryTypes.unused, self, category, configData); + + //console.log('ConfigManager: Final formHTML length:', formHTML.length); + + if (formHTML) { + // Page-level header for the config section. Mirrors the + // .backup-page-header used on /backup so /config gets the same + // prominent H1 + description above the form fields. Looked up from + // window.configData.categories[category] so titles/descriptions + // come straight from the .category metadata file. + var catMeta = (window.configData && window.configData.categories && window.configData.categories[category]) || {}; + var catTitle = catMeta.title || (typeof ConfigShared !== 'undefined' && ConfigShared.formatCategoryName ? ConfigShared.formatCategoryName(category) : category); + var catDesc = catMeta.description || ''; + var catIcon = catMeta.icon || category; + var headerHTML = + ''; + + configSection.innerHTML = headerHTML + '
    ' + formHTML + '
    ' + + '' + + '' + + '
    '; + // Wire the submit event so it dispatches the config-update task + // instead of letting the browser fall back to a GET that dumps every + // CFG value (including passwords) into the URL. + if (this.form && typeof this.form.attachSubmitHandler === 'function') { + this.form.attachSubmitHandler(); + } + } + + //console.log('ConfigManager: Successfully rendered ' + category + ' config'); + + // Force rediscover toggles to handle timing issues + if (window.toggleManager && window.toggleManager.forceRediscover) { + setTimeout(() => { + window.toggleManager.forceRediscover(); + }, 200); + } + + } catch (error) { + console.error('ConfigManager: Error rendering ' + category + ' config: ', error); + configSection.innerHTML = '

    Error Loading Configuration

    Failed to load configuration: ' + error.message + '

    '; + } + } + + async renderSubcategory(category, subcategoryName, subcategoryData) { + //console.log('ConfigManager: renderSubcategory() called - category: ' + category + ', subcategory: ' + subcategoryName); + + var displaySubcategory = this.utils.formatSubcategoryName(subcategoryName); + // Strip the parent-category prefix from the display title so the user + // sees "Basic" instead of "General Basic" while on the General page. + if (typeof ConfigShared !== 'undefined' && ConfigShared.stripCategoryPrefix) { + displaySubcategory = ConfigShared.stripCategoryPrefix(displaySubcategory, category); + } + var subcategoryDescription = this.utils.cleanDescription(subcategoryData.description || ''); + + // The subcategoryData IS the config items, not a container for them + var configItems = []; + + // Look for actual config items in the main config object + if (window.configData && window.configData.config) { + Object.entries(window.configData.config).forEach(function([configKey, configValue]) { + if (configValue.subcategory === subcategoryName) { + configItems.push({ + key: configKey, + title: configValue.title, + description: configValue.description, + value: configValue.value, + options: configValue.options, + master: configValue.master, + subcategory: configValue.subcategory + }); + } + }); + } + + //console.log('ConfigManager: Processing subcategory:', subcategoryName, 'data:', subcategoryData); + //console.log('ConfigManager: configItems count: ' + configItems.length); + //console.log('ConfigManager: All config items keys:', configItems.map(item => item.key)); + + if (configItems.length === 0) { + //console.log('ConfigManager: No config items, returning empty string'); + return ''; + } + + //console.log('ConfigManager: renderSubcategory called with:', { + //category, + //subcategoryName, + //displaySubcategory, + //hasData: !!subcategoryData + //}); + + // Check for master toggle in this subcategory + var masterKey = configItems.find(function(item) { return item.master === true; }); + //console.log('ConfigManager: masterKey found: ' + !!masterKey, masterKey ? masterKey.key : null); + + // Look for any ENABLED options and use universal toggle renderer + var enabledKey = configItems.find(function(item) { + //console.log('Checking item for ENABLED:', item.key, item.key.includes('ENABLED')); + return item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE'; + }); + //console.log('ConfigManager: enabledKey found: ' + !!enabledKey, enabledKey ? enabledKey.key : null); + + // Special handling for domains section + var isDomains = subcategoryName.includes('domains') || subcategoryName.includes('network_domains'); + //console.log('ConfigManager: isDomains:', isDomains); + + // Special handling for IP whitelist section + var isWhitelist = subcategoryName === 'network_whitelist' || subcategoryName.includes('whitelist'); + //console.log('ConfigManager: subcategoryName:', subcategoryName, 'isWhitelist:', isWhitelist); + + var resultHTML = ''; + + if (isDomains) { + //console.log('ConfigManager: Using domains renderer'); + // Render domains section with special handling + resultHTML = await this.domainManager.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription); + } else if (isWhitelist) { + //console.log('ConfigManager: Using whitelist renderer'); + resultHTML = await this.whitelistManager.renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription); + } else if (enabledKey) { + //console.log('ConfigManager: Using universal toggle renderer'); + // Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE + resultHTML = window.toggleManager ? window.toggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription) : ''; + } else if (masterKey) { + //console.log('ConfigManager: Using master toggle renderer'); + // Render with master toggle + resultHTML = this.renderer.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription); + } else { + //console.log('ConfigManager: Using regular renderer'); + // Render regular subcategory + resultHTML = this.renderer.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription); + } + + //console.log('ConfigManager: resultHTML length:', resultHTML.length); + return resultHTML; + } + + // Delegate form operations to ConfigForm + resetForm() { + return this.form.resetForm(); + } + + // Domain management methods + addDomain() { + return window.domainManager.addDomain(); + } + + deleteDomain(domainKey, buttonElement) { + return window.domainManager.deleteDomain(domainKey, buttonElement); + } + + async saveConfig() { + return await this.form.saveConfig(); + } + + showNotification(message, type) { + return this.form.showNotification(message, type); + } + } + + // Export to global scope + window.ConfigManager = ConfigManager; +} else { + //console.log('ConfigManager: Already exists, using existing instance'); +} diff --git a/containers/libreportal/frontend/js/components/config/config-options.js b/containers/libreportal/frontend/js/components/config/config-options.js new file mode 100755 index 0000000..4f1419b --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-options.js @@ -0,0 +1,434 @@ +// Config Options - Centralized configuration options for dropdown fields +class ConfigOptions { + + // Per-app form renders id="GLUETUN_VPN_*"; global form renders id="config-CFG_GLUETUN_VPN_*". + static findGluetunProviderEl() { + return document.getElementById('config-CFG_GLUETUN_VPN_SERVICE_PROVIDER') + || document.getElementById('GLUETUN_VPN_SERVICE_PROVIDER'); + } + static findGluetunVpnTypeEl() { + return document.getElementById('config-CFG_GLUETUN_VPN_TYPE') + || document.getElementById('GLUETUN_VPN_TYPE'); + } + static findGluetunFieldEl(suffix) { + return document.getElementById(`config-CFG_GLUETUN_${suffix}`) + || document.getElementById(`GLUETUN_${suffix}`); + } + + // Show only the credential fields that match the selected VPN type. + static refreshGluetunCredentialVisibility() { + const typeEl = this.findGluetunVpnTypeEl(); + if (!typeEl) return; + const type = (typeEl.value || '').toLowerCase(); + const wg = ['WIREGUARD_PRIVATE_KEY', 'WIREGUARD_ADDRESSES']; + const ov = ['OPENVPN_USER', 'OPENVPN_PASSWORD']; + const setVisible = (suffix, visible) => { + const el = this.findGluetunFieldEl(suffix); + if (!el) return; + const wrapper = el.closest('.form-field') || el.parentElement; + if (wrapper) wrapper.style.display = visible ? '' : 'none'; + }; + wg.forEach((s) => setVisible(s, type === 'wireguard')); + ov.forEach((s) => setVisible(s, type === 'openvpn')); + this.refreshMullvadGenerateButton(type); + } + + static refreshMullvadGenerateButton(typeValue) { + const providerEl = this.findGluetunProviderEl(); + const provider = (providerEl?.value || '').toLowerCase(); + const type = (typeValue || this.findGluetunVpnTypeEl()?.value || '').toLowerCase(); + const shouldShow = provider === 'mullvad' && type === 'wireguard'; + + document.querySelectorAll('.mullvad-generate-field').forEach(b => b.remove()); + if (!shouldShow) return; + + const typeEl = this.findGluetunVpnTypeEl(); + const anchor = typeEl && typeEl.closest('.form-field'); + if (!anchor) return; + + const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY'); + const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES'); + const configured = !!(keyEl?.value && addrEl?.value); + + const block = document.createElement('div'); + block.className = 'form-field mullvad-generate-field'; + block.innerHTML = ` + +
    + + + ${configured ? '✓' : ''} + ${configured ? 'Configured' : 'Not configured'} + +
    + Generates a WireGuard key against your Mullvad account and fills the credentials below. + `; + block.querySelector('.mullvad-generate-btn') + .addEventListener('click', () => window.appsManager?.openMullvadGenerateModal?.()); + anchor.insertAdjacentElement('afterend', block); + } + + static refreshMullvadGenerateStatus() { + const status = document.querySelector('.mullvad-generate-field .mullvad-generate-status'); + if (!status) return; + const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY'); + const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES'); + const configured = !!(keyEl?.value && addrEl?.value); + status.classList.toggle('is-configured', configured); + status.querySelector('.mullvad-generate-tick').textContent = configured ? '✓' : ''; + status.querySelector('.mullvad-generate-status-text').textContent = configured ? 'Configured' : 'Not configured'; + } + + static loadGluetunProviderIcons() { + if (this._gluetunIconsPromise) return this._gluetunIconsPromise; + this._gluetunIconsPromise = (async () => { + try { + const res = await fetch('/data/apps/gluetun-provider-icons.json', { cache: 'no-store' }); + if (!res.ok) return {}; + return await res.json(); + } catch { return {}; } + })(); + this._gluetunIconsPromise.then((m) => { window.gluetunProviderIcons = m || {}; }); + return this._gluetunIconsPromise; + } + static _gluetunIconsPromise = null; + + // Pulled from gluetun's upstream servers.json by webuiGenerateGluetunProviders. + // Generator writes to /data/apps/generated/; static fallback ships at /data/apps/. + // We try generated first, fall back to bundled, fall back to a tiny static list. + static _gluetunProvidersPromise = null; + static loadGluetunProviders() { + if (this._gluetunProvidersPromise) return this._gluetunProvidersPromise; + this._gluetunProvidersPromise = (async () => { + const tryFetch = async (url) => { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return null; + const json = await res.json(); + return json && json.providers ? json.providers : null; + } catch { return null; } + }; + const live = await tryFetch('/data/apps/generated/gluetun-providers.json'); + if (live) return live; + const fallback = await tryFetch('/data/apps/gluetun-providers.json'); + if (fallback) return fallback; + return null; + })(); + this._gluetunProvidersPromise.then((p) => { + window.gluetunProviders = p; + // If the provider field rendered before this finished, repopulate it + // (and the VPN-type field, which depends on the selected provider). + const sel = ConfigOptions.findGluetunProviderEl(); + if (sel && p) { + const previous = sel.value; + const opts = ConfigOptions.getGluetunProviderOptions(); + sel.innerHTML = opts.map((o) => + `` + ).join(''); + ConfigOptions.refreshGluetunVpnTypeOptions(); + } + ConfigOptions.refreshGluetunCredentialVisibility(); + }); + return this._gluetunProvidersPromise; + } + + static getGluetunProviderOptions() { + const providers = window.gluetunProviders; + if (!providers) { + this.loadGluetunProviders(); + return [ + { value: 'mullvad', label: 'Mullvad' }, + { value: 'nordvpn', label: 'NordVPN' }, + { value: 'protonvpn', label: 'ProtonVPN' }, + { value: 'surfshark', label: 'Surfshark' }, + { value: 'custom', label: 'Custom (manual config)' } + ]; + } + const titleCase = (s) => s.replace(/\b\w/g, (c) => c.toUpperCase()); + return Object.keys(providers).sort().map((slug) => ({ + value: slug, + label: slug === 'custom' ? 'Custom (manual config)' : titleCase(slug) + })); + } + + // Repaint the VPN-type changes, rebuild the +// VPN-type + + + ${masterKey.title || 'Enable Advanced Configuration'} + ℹ️ + + + + +
    +
    + `; + + // Add all other fields (excluding the master toggle) + configItems.filter(item => item.key !== masterKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options) || ''; + }); + + html += ` +
    +
    + +
    + + `; + + return html; + } + + renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + `; + + // Add all fields + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + `; + + // Add all fields + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + // Field rendering methods + renderTextField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderNumberField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderEmailField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderPasswordField(fieldId, key, value, title, description, options) { + return ` +
    + +
    + + +
    + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderSelectField(fieldId, key, value, title, description, fieldOptions) { + const options = window.ConfigOptions?.getSelectOptions(key) || []; + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderCheckboxField(fieldId, key, value, title, description, options) { + const isChecked = value === 'true' || value === true; + return ` +
    + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderTextareaField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + togglePasswordVisibility(fieldId) { + const input = document.getElementById(fieldId); + const button = document.querySelector(`button[onclick*="${fieldId}"]`); + + if (input && button) { + if (input.type === 'password') { + input.type = 'text'; + button.innerHTML = '🙈️'; + } else { + input.type = 'password'; + button.innerHTML = '👁️'; + } + } + } + + cleanDescription(description) { + if (!description) return ''; + return description.replace(/CFG_[A-Z_]+/g, '').replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } +} + +// Export for use in other modules +window.ConfigRenderer = ConfigRenderer; diff --git a/containers/libreportal/frontend/js/components/config/config-router.js b/containers/libreportal/frontend/js/components/config/config-router.js new file mode 100755 index 0000000..d465aaa --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-router.js @@ -0,0 +1,291 @@ +// Configuration Router - Loads appropriate config component based on URL +class ConfigRouter { + constructor() { + this.currentComponent = null; + } + + async init() { + //console.log('ConfigRouter: Initializing...'); + // Get current category from query parameter or global variable + const searchParams = new URLSearchParams(window.location.search); + let currentCategory = searchParams.get('config') || window.configCategory || 'general'; + + //console.log(`Initial parsing - searchParams.get('config'): ${searchParams.get('config')}`); + //console.log(`Window location search: ${window.location.search}`); + //console.log(`Initial currentCategory: ${currentCategory}`); + + // Handle the case where URL is config?=backup (malformed but common) + // Check if we got the category from URL params, not from fallback + const gotFromUrlParams = searchParams.get('config') !== null; + + if (!gotFromUrlParams && window.location.search.includes('?=')) { + //console.log(`URL contains ?= and no valid config param, attempting regex match...`); + const pathMatch = window.location.search.match(/\?=([^&]+)/); + //console.log(`Regex match result:`, pathMatch); + //console.log(`Regex test result:`, /\?=([^&]+)/.test(window.location.search)); + if (pathMatch && pathMatch[1]) { + currentCategory = pathMatch[1]; + //console.log(`Updated currentCategory from regex: ${currentCategory}`); + } else { + //console.log(`Regex failed to match, currentCategory remains: ${currentCategory}`); + } + } else { + //console.log(`Got category from URL params (${gotFromUrlParams}) or URL doesn't contain ?=, keeping: ${currentCategory}`); + } + + //console.log(`Config router init: final category=${currentCategory}`); + + // Backup config moved to /backup — redirect old URL/bookmarks. + if (currentCategory === 'backup') { + if (window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + window.librePortalSPA.navigate('/backup', true); + } else { + window.location.href = '/backup'; + } + return; + } + + // Load categories for sidebar + await this.loadConfigCategories(); + + // Set active category in sidebar + this.setActiveCategory(currentCategory); + + // Load appropriate config component + await this.loadConfigComponent(currentCategory); + } + + async loadConfigCategories() { + try { + //console.log('Loading config categories from: data/config/generated/configs.json'); + + // Start loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(20); + } + + // Load unified config file + const response = await fetch('/data/config/generated/configs.json'); + //console.log('Response status:', response.status, response.statusText); + //console.log('Response ok:', response.ok); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const text = await response.text(); + //console.log('Raw response text:', text); + //console.log('Text length:', text.length); + //console.log('First 100 chars:', text.substring(0, 100)); + + if (!text || text.trim() === '') { + throw new Error('Empty response from configs.json'); + } + + const data = JSON.parse(text); + const categories = data.categories; + + //console.log('Parsed categories:', categories); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + const categoriesList = document.getElementById('config-categories-list'); + if (!categoriesList) { + console.error('config-categories-list element not found'); + return; + } + + categoriesList.innerHTML = ''; + + // Convert categories object to array and sort by ORDER + const categoriesArray = Object.entries(categories).map(([key, value]) => ({ + id: key, + ...value + })); + + // Sort by ORDER if available, otherwise by title + categoriesArray.sort((a, b) => { + const orderA = parseInt(a.order) || 999; + const orderB = parseInt(b.order) || 999; + return orderA - orderB; + }); + + categoriesArray.forEach(category => { + const categoryItem = document.createElement('div'); + categoryItem.className = 'category'; + categoryItem.setAttribute('data-category', category.id); + + // Use correct icon from our new structure + const iconName = category.icon || category.id; + const iconPath = `/icons/config/${iconName}.svg`; + //console.log(`Category: ${category.id}, Icon path: ${iconPath}`); + categoryItem.innerHTML = ` + ${category.title} + ${category.title} + `; + + categoryItem.addEventListener('click', () => { + //console.log(`Category clicked: ${category.id}`); + this.navigateToCategory(category.id); + }); + + categoriesList.appendChild(categoryItem); + }); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + } catch (error) { + console.error('Error loading config categories:', error); + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + } + + setActiveCategory(categoryId) { + // Update active state + document.querySelectorAll('.category').forEach(item => { + item.classList.remove('active'); + }); + document.querySelector(`[data-category="${categoryId}"]`)?.classList.add('active'); + } + + navigateToCategory(categoryId) { + //console.log(`Config router: navigating to ${categoryId} (SPA mode)`); + + // Update URL without full page reload using query parameter + const url = `/config?=${categoryId}`; + //console.log(`Updating URL to: ${url}`); + window.history.pushState({}, '', url); + + // Set active category + this.setActiveCategory(categoryId); + + // Load config content dynamically + this.loadConfigComponent(categoryId); + } + + async loadConfigComponent(categoryId) { + try { + //console.log(`Config router: Loading component for ${categoryId}`); + + // Start loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(10); + router.showLoadingBar(); + } + + // Clear current content + const configSection = document.getElementById('config-section'); + if (configSection) { + configSection.innerHTML = '
    Loading configuration...
    '; + } + + // Update progress + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(30); + } + + // Use the simple config manager + if (window.configManager) { + //console.log(`Using ConfigManager for ${categoryId}`); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(50); + } + + await window.configManager.renderConfig(categoryId); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(90); + } + } else { + // Fallback - try to load config manager + //console.log('ConfigManager not available, loading it...'); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(40); + } + + await new Promise((resolve) => { + const script = document.createElement('script'); + script.src = '/js/components/config/config-manager.js'; + script.onload = async () => { + if (window.configManager) { + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + await window.configManager.renderConfig(categoryId); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(90); + } + } + resolve(); + }; + document.head.appendChild(script); + }); + } + + // Complete loading + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(100); + setTimeout(() => { + router.hideLoadingBar(); + }, 500); // Small delay to show completion + } + + } catch (error) { + console.error(`Error loading config component for ${categoryId}:`, error); + const configSection = document.getElementById('config-section'); + if (configSection) { + configSection.innerHTML = `
    Failed to load ${categoryId} configuration: ${error.message}
    `; + } + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + } + + async loadConfigComponentManual(categoryId) { + const configSection = document.getElementById('config-section'); + + // This method is no longer needed since we use ConfigManager for all categories + // The individual config classes have been removed + //console.log(`ConfigRouter: loadConfigComponentManual called for ${categoryId} - delegating to ConfigManager`); + + // Use ConfigManager for all categories now + if (window.configManager) { + await window.configManager.renderConfig(categoryId); + } else { + configSection.innerHTML = '
    ConfigManager not available
    '; + } + + // Hide loading bar + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + + async loadScript(src) { + // Check if script is already loaded + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + //console.log(`Script ${src} already loaded, skipping`); + return; + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } +} + +// Export for global access +window.ConfigRouter = ConfigRouter; diff --git a/containers/libreportal/frontend/js/components/config/config-shared.js b/containers/libreportal/frontend/js/components/config/config-shared.js new file mode 100755 index 0000000..6a57ad5 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-shared.js @@ -0,0 +1,1542 @@ +// Config Shared Functions - Common functionality used across all config components +class ConfigShared { + + // Toggle switch system - handles different types of toggles with proper layout + static createToggleSwitch(fieldId, key, value, title, description, options = {}) { + const isChecked = value === 'true'; + const tooltipHtml = description ? `ℹ️` : ''; + + // Determine toggle type and layout + const toggleType = options.type || 'standard'; + const layout = options.layout || 'inline'; + + let toggleHTML = ''; + + switch (toggleType) { + case 'master': + toggleHTML = this.createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + case 'section': + toggleHTML = this.createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + case 'standard': + default: + toggleHTML = this.createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + } + + // Wrap in layout container + return this.wrapToggleInLayout(toggleHTML, layout, options); + } + + // Standard toggle switch (most common) + static createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + +
    + `; + } + + // Master toggle (for enabling/disabling entire sections) + static createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + + +
    + `; + } + + // Section toggle (for showing/hiding sections) + static createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + +
    + `; + } + + // Wrap toggle in layout container + static wrapToggleInLayout(toggleHTML, layout, options) { + switch (layout) { + case 'grid': + return `
    ${toggleHTML}
    `; + case 'flex': + return `
    ${toggleHTML}
    `; + case 'inline': + default: + return toggleHTML; + } + } + + // Auto-detect and create appropriate field type + static createSmartField(fieldId, key, value, title, description, options = {}) { + //console.log(`createSmartField: key=${key}, value=${value}, config=${!!options.config}`); + + // Check if value is boolean (true/false strings) + const isBoolean = value === 'true' || value === 'false'; + + if (isBoolean) { + // Auto-create toggle switch for boolean values + return this.createToggleSwitch(fieldId, key, value, title, description, { + type: options.type || 'standard', + layout: options.layout || 'inline', + category: options.category || '', + sectionId: options.sectionId || '', + fieldIds: options.fieldIds || [] + }); + } + + // Fall back to regular field generation for non-boolean values + return this.generateField(fieldId, key, value, title, description, options.selectOptions, options.config); + } + + // Handle standard toggle changes + static handleToggleChange(checkbox, key, category) { + //console.log(`Toggle changed: ${key} = ${checkbox.checked} (category: ${category})`); + + // Trigger custom event for other components to listen to + const event = new CustomEvent('configToggleChanged', { + detail: { key, value: checkbox.checked, category } + }); + document.dispatchEvent(event); + + // Auto-save if enabled + if (checkbox.dataset.autoSave === 'true') { + this.saveToggleValue(key, checkbox.checked); + } + } + + // Handle master toggle changes (enables/disables multiple fields) + static handleMasterToggle(checkbox, sectionId, fieldIds) { + //console.log(`Master toggle changed: ${sectionId} = ${checkbox.checked}`); + + // Enable/disable related fields + if (fieldIds && fieldIds.length > 0) { + fieldIds.forEach(fieldId => { + const field = document.getElementById(`config-${fieldId}`); + if (field) { + field.disabled = !checkbox.checked; + field.closest('.config-field')?.classList.toggle('disabled', !checkbox.checked); + } + }); + } + + // Show/hide section content + if (sectionId) { + const sectionContent = document.getElementById(`${sectionId}-content`); + if (sectionContent) { + sectionContent.style.display = checkbox.checked ? 'block' : 'none'; + } + } + + // Trigger custom event + const event = new CustomEvent('configMasterToggleChanged', { + detail: { sectionId, enabled: checkbox.checked, fieldIds } + }); + document.dispatchEvent(event); + } + + // Handle section toggle changes (shows/hides sections) + static handleSectionToggle(checkbox, sectionId) { + //console.log(`Section toggle changed: ${sectionId} = ${checkbox.checked}`); + + const content = document.getElementById(`${sectionId}-content`); + if (content) { + content.style.display = checkbox.checked ? 'block' : 'none'; + } + + // Trigger custom event + const event = new CustomEvent('configSectionToggleChanged', { + detail: { sectionId, visible: checkbox.checked } + }); + document.dispatchEvent(event); + } + + // Save toggle value immediately + static async saveToggleValue(key, value) { + try { + // For now, just log the change - local implementation + //console.log('Toggle value changed:', key, value ? 'true' : 'false'); + + // TODO: Implement local config file update + // This would require backend integration to write to actual config files + + } catch (error) { + console.error('Error saving toggle value:', error); + } + } + + // Create custom range field with start-end inputs + static createRangeField(fieldId, key, value, title, description) { + // Parse range value (format: "start-end") + let startRange = ''; + let endRange = ''; + + if (value && value.includes('-')) { + const parts = value.split('-'); + startRange = parts[0] || ''; + endRange = parts[1] || ''; + } + + return ` +
    + + - + + +
    + + `; + } + + // Update range value when inputs change + static updateRangeValue(key) { + const startInput = document.getElementById(`config-${key}-start`); + const endInput = document.getElementById(`config-${key}-end`); + const hiddenInput = document.getElementById(`config-${key}`); + + if (startInput && endInput && hiddenInput) { + const startValue = startInput.value || ''; + const endValue = endInput.value || ''; + + if (startValue && endValue) { + hiddenInput.value = `${startValue}-${endValue}`; + } else if (startValue) { + hiddenInput.value = `${startValue}-`; + } else if (endValue) { + hiddenInput.value = `-${endValue}`; + } else { + hiddenInput.value = ''; + } + } + } + + // Create custom crontab field with hour/minutes/AM-PM + static createCrontabField(fieldId, key, value, title, description) { + // Parse crontab value (format: "minute hour * * *") + const parts = value.split(' '); + let hour = '5'; + let minute = '0'; + let period = 'AM'; + + if (parts.length >= 2) { + minute = parts[0] || '0'; + const cronHour = parseInt(parts[1]) || 0; + + // Convert 24-hour to 12-hour format + if (cronHour === 0) { + hour = '12'; + period = 'AM'; + } else if (cronHour < 12) { + hour = cronHour.toString(); + period = 'AM'; + } else if (cronHour === 12) { + hour = '12'; + period = 'PM'; + } else { + hour = (cronHour - 12).toString(); + period = 'PM'; + } + } + + return ` +
    + + : + + / + + +
    + + `; + } + + // Update crontab value when fields change + static updateCrontabValue(key) { + const fieldId = `config-${key}`; + const hourSelect = document.getElementById(`${fieldId}-hour`); + const minuteSelect = document.getElementById(`${fieldId}-minute`); + const periodSelect = document.getElementById(`${fieldId}-period`); + const hiddenInput = document.getElementById(fieldId); + + if (!hourSelect || !minuteSelect || !periodSelect || !hiddenInput) { + console.warn(`Crontab field elements not found for: ${key}`); + return; + } + + const hour = parseInt(hourSelect.value); + const minute = minuteSelect.value; + const period = periodSelect.value; + + // Convert 12-hour to 24-hour format + let cronHour; + if (period === 'AM') { + cronHour = hour === 12 ? 0 : hour; + } else { + cronHour = hour === 12 ? 12 : hour + 12; + } + + // Create crontab format: "minute hour * * *" + const crontabValue = `${minute} ${cronHour} * * *`; + hiddenInput.value = crontabValue; + + //console.log(`Updated crontab for ${key}: ${crontabValue}`); + } + + // Toggle password visibility + static togglePasswordVisibility(fieldId) { + const passwordField = document.getElementById(fieldId); + const icon = document.getElementById(`${fieldId}-icon`); + + if (!passwordField || !icon) { + console.warn(`Password field or icon not found: ${fieldId}`); + return; + } + + if (passwordField.type === 'password') { + passwordField.type = 'text'; + icon.textContent = '👁‍🗨'; // Eye with strikethrough (hidden) + } else { + passwordField.type = 'password'; + icon.textContent = '👁'; // Regular eye (visible) + } + } + + static setPasswordMode(fieldId, mode) { + const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`); + const input = document.getElementById(fieldId); + const tokenInput = document.getElementById(`${fieldId}-token`); + if (!wrapper || !input || !tokenInput) return; + + const key = wrapper.dataset.fieldKey; + + if (mode === 'random') { + input.dataset.previousCustom = input.value || ''; + input.value = ''; + input.readOnly = true; + input.type = 'password'; + input.setAttribute('placeholder', 'Will generate on save'); + input.removeAttribute('name'); + tokenInput.setAttribute('name', key); + const icon = document.getElementById(`${fieldId}-icon`); + if (icon) icon.textContent = '👁'; + } else { + input.readOnly = false; + input.removeAttribute('placeholder'); + input.value = input.dataset.previousCustom || ''; + input.setAttribute('name', key); + tokenInput.removeAttribute('name'); + input.focus(); + } + } + + // Format config key to readable label + static formatConfigLabel(key) { + // Special handling for domain configuration + if (key.startsWith('CFG_DOMAIN_')) { + const domainNum = key.replace('CFG_DOMAIN_', ''); + return `Domain ${domainNum}`; + } + + // Special handling for requirement configuration - remove REQUIREMENT and format nicely + if (key.startsWith('CFG_REQUIREMENT_')) { + const requirement = key.replace('CFG_REQUIREMENT_', ''); + return requirement + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + return key + .replace(/^CFG_/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + // Group config keys by category + static groupConfigKeys(config) { + const groups = {}; + + // Group by category field from JSON config + for (const key of Object.keys(config)) { + const configItem = config[key] || {}; + const category = configItem.category; + + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(key); + } + + // Pin any per-app `*_BACKUP` toggle to the top of its category so the + // user always sees it first when configuring an app. Matches both + // `CFG_BACKUP` and `CFG__BACKUP` (but not `*_BACKUP_*` like + // `CFG_BACKUP_CRONTAB_APP`, which would otherwise bubble up too). + for (const cat of Object.keys(groups)) { + groups[cat].sort((a, b) => { + const aBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(a); + const bBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(b); + if (aBackup && !bBackup) return -1; + if (!aBackup && bBackup) return 1; + return 0; + }); + } + + return groups; + } + + // Extract category order from config + static extractCategoryOrder(config) { + const order = []; + const seen = new Set(); + + // Get categories in the order they appear in config + for (const key of Object.keys(config)) { + const configItem = config[key] || {}; + const category = configItem.category; + + if (category && !seen.has(category)) { + seen.add(category); + order.push(category); + } + } + + return order; + } + + // Format category name for display + static formatCategoryName(category) { + return category + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + /* Subcategory titles often come back as " " — e.g. + "General Basic" under the General category, "Webui Logins" under + the Webui category. The redundant leading category word is noisy + when the user is already viewing that category page, so strip it. + Comparison is case-insensitive and only strips a clean leading + whole-word match (won't turn "Configuration" into "iguration"). */ + static stripCategoryPrefix(title, parentCategory) { + if (!title || !parentCategory) return title; + const prefix = ConfigShared.formatCategoryName(parentCategory) + ' '; + if (title.toLowerCase().startsWith(prefix.toLowerCase())) { + return title.substring(prefix.length); + } + return title; + } + + // Get category description from config or fallback + static async getCategoryDescription(category) { + try { + // First try to get description from the unified config + const response = await fetch('/data/config/generated/configs.json'); + const data = await response.json(); + + // Check if categories exist in this config + if (data.categories) { + // Categories are stored as simple key-value pairs: "CATEGORY": "description" + const categoryDescription = data.categories[category]; + if (categoryDescription) { + return categoryDescription; + } + } + + // Fallback to unified config for general descriptions + const unifiedConfigResponse = await fetch('/data/config/generated/configs.json'); + const unifiedConfigData = await unifiedConfigResponse.json(); + const fallbackCategoryData = unifiedConfigData.categories[category] || null; + + return fallbackCategoryData ? fallbackCategoryData.description : `${this.formatCategoryName(category)} settings and configuration`; + } catch (error) { + console.error('Error loading category descriptions:', error); + return `${this.formatCategoryName(category)} settings and configuration`; + } + } + + // Generate appropriate field based on value type and key + static generateField(fieldId, key, value, title, description, options = {}, allConfig = {}) { + //console.log(`generateField: key=${key}, value=${value}, CFG_INSTALL_MODE=${allConfig.CFG_INSTALL_MODE?.value}`); + + // Note: Git fields (CFG_GIT_*) are now handled by the toggle system in renderGitSection + // They don't need to be hidden here since the section itself is toggled + + // Handle boolean toggle fields from options + const { onchange, oninput, onblur, className, placeholder, ...fieldOptions } = options; + + // Check if value is boolean (true/false strings) + const isBoolean = value === 'true' || value === 'false'; + + if (isBoolean) { + // Auto-create toggle switch for boolean values + return this.createToggleSwitch(fieldId, key, value, title, description, { + type: 'standard', + layout: 'inline', + category: '' + }); + } + + // Non-boolean fields - use exact old config structure + const tooltipHtml = description ? `ℹ️` : ''; + + let fieldHTML = ` +
    + + `; + + // Determine field type based on key or options + ////console.log(`Checking field type for key: ${key}`); // Debug log + ////console.log(`Password check: ${key.includes('PASS') && !key.includes('LENGTH')}, ${key.includes('SECRET')}`); // Debug log + + // Custom crontab fields for backup schedules + if (key === 'CFG_BACKUP_CRONTAB_APP') { + fieldHTML += this.createCrontabField(fieldId, key, value, title, description); + } else if (key.includes('PORT_RANGE')) { + fieldHTML += this.createRangeField(fieldId, key, value, title, description); + } else if ((key.includes('PASS') && !key.includes('LENGTH')) || key.includes('SECRET') || key.endsWith('_API_KEY') || key.endsWith('_PRIVATE_KEY')) { + ////console.log(`Creating password field for: ${key}`); // Debug log + const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value); + const placeholderToken = randomMatch ? value : `RANDOMIZEDPASSWORD1`; + const initialMode = randomMatch ? 'random' : 'custom'; + const inputValue = randomMatch ? '' : value; + const visibleName = initialMode === 'custom' ? `name="${key}"` : ''; + const hiddenName = initialMode === 'random' ? `name="${key}"` : ''; + fieldHTML += ` +
    + +
    + + + +
    +
    + + `; + } else if (key.includes('EMAIL') || key.includes('MAIL')) { + // Special handling for CFG_MAIL_SECURE - it's a dropdown + if (key === 'CFG_MAIL_SECURE') { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else { + // Different validation for different mail fields + let validationAttrs = ''; + let fieldType = 'email'; + let placeholder = 'user@example.com'; + + if (key === 'CFG_MAIL_HOST') { + fieldType = 'text'; + placeholder = 'mail.domain.com'; + validationAttrs = `onchange="window.configManager.validateHostnameFormat(this, true)" oninput="window.configManager.validateHostnameFormat(this, true)" onblur="window.configManager.validateHostnameFormat(this, true)"`; + } else if (key === 'CFG_MAIL_PORT') { + fieldType = 'number'; + placeholder = '587'; + validationAttrs = `onchange="window.configManager.validatePortNumber(this, true)" oninput="window.configManager.validatePortNumber(this, true)" onblur="window.configManager.validatePortNumber(this, true)"`; + } else if (key === 'CFG_MAIL_USERNAME' || key === 'CFG_MAIL_FROM') { + validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`; + } else { + validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`; + } + + fieldHTML += ` + + `; + } + } else if (key.includes('URL') || key.includes('LINK') || key.includes('HOST') || key.startsWith('CFG_DOMAIN_')) { + const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com'; + fieldHTML += ` + + `; + } else if (key === 'CFG_BACKUP_CRONTAB_APP_INTERVAL') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (/^CFG_BACKUP(_LOC_[0-9]+)?_KEEP_(LAST|DAILY|WEEKLY|MONTHLY|YEARLY)$/.test(key)) { + const unitLabel = key.endsWith('_KEEP_LAST') ? 'snapshots' : + key.endsWith('_KEEP_DAILY') ? 'days' : + key.endsWith('_KEEP_WEEKLY') ? 'weeks' : + key.endsWith('_KEEP_MONTHLY') ? 'months' : 'years'; + fieldHTML += ` +
    + + ${unitLabel} +
    + `; + } else if (key === 'CFG_UPDATER_CHECK') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_GENERATED_PASS_LENGTH') { + fieldHTML += ` +
    + + characters +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC') { + fieldHTML += ` +
    + + lines +
    + `; + } else if (key === 'CFG_SWAPFILE_SIZE') { + // Extract numeric value from "2G" format + let numericValue = value.replace(/[^0-9.]/g, ''); + fieldHTML += ` +
    + + GB +
    + `; + } else if (key.includes('SIZE') || key.includes('LENGTH') || key.includes('CHECK') || key.includes('MTU') || key.includes('PORT')) { + let min = ''; + let max = ''; + + if (key.includes('PORT')) { + min = '1'; + max = '65535'; + } else if (key.includes('SIZE') || key.includes('LENGTH')) { + min = '0'; + max = key.includes('PASS_LENGTH') ? '128' : ''; + } + + fieldHTML += ` + + `; + } else if (key.includes('TIMEZONE')) { + // Special handling for Timezone - create comprehensive timezone dropdown + const timezoneOptions = ConfigOptions.getTimezoneOptions(); + //console.log('Timezone key:', key, 'Current value:', value, 'Type:', typeof value); + //console.log('Available timezone options:', timezoneOptions.map(opt => ({value: opt.value, label: opt.label}))); + fieldHTML += ` + + `; + //console.log('Generated timezone dropdown HTML for', key, 'with value', value); + } else if (key === 'CFG_INSTALL_MODE') { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else if (/^CFG_BACKUP_LOC_[0-9]+_TYPE$/.test(key)) { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else if (ConfigOptions.isDropdownKey(key) || (options && Object.keys(options).length > 0)) { + //console.log('=== GENERIC DROPDOWN BLOCK ENTERED for key:', key); + //console.log('Dropdown detected for key:', key); + //console.log('isDropdownKey result:', ConfigOptions.isDropdownKey(key)); + //console.log('options available:', options); + const selectOptions = (options && typeof options === 'string') ? this.parseOptions(options) : ConfigOptions.getSelectOptions(key); + //console.log('selectOptions:', selectOptions); + fieldHTML += ` + + `; + //console.log('Generated dropdown for', key, 'with value', value); + } else if (key.includes('DESCRIPTION') || key.includes('COMMENTS') || key.includes('NOTES')) { + //console.log('Textarea detected for key:', key); + fieldHTML += ` + + `; + } else { + //console.log('Default text input for key:', key); + // Default text input with event handlers and options + const inputClass = className ? `form-control ${className}` : 'form-control'; + const inputPlaceholder = placeholder || ''; + const eventHandlers = []; + if (onchange) eventHandlers.push(`onchange="${onchange}"`); + if (oninput) eventHandlers.push(`oninput="${oninput}"`); + if (onblur) eventHandlers.push(`onblur="${onblur}"`); + const eventAttrs = eventHandlers.length > 0 ? ` ${eventHandlers.join(' ')}` : ''; + + fieldHTML += ` + + `; + } + + fieldHTML += ` +
    + `; + + return fieldHTML; + } + + // Create master toggle for section enabling/disabling + static createMasterToggle(sectionId, masterKey, isEnabled, title, description) { + return ` +
    +
    + +
    +
    + `; + } + + // Create section content wrapper + static createSectionContent(sectionId, isEnabled) { + return ` +
    + `; + } + + // Toggle section fields (modular function) + static toggleSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(`section-content-${sectionId}`); + + if (!sectionContent) { + console.warn(`Section content not found: ${sectionId}`); + return; + } + + const fields = sectionContent.querySelectorAll('input, select, textarea'); + + if (isEnabled) { + // Enable section + sectionContent.classList.remove('disabled'); + fields.forEach(field => { + field.disabled = false; + const fieldGroup = field.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + //console.log(`Section ${sectionId} enabled`); + } else { + // Disable section + sectionContent.classList.add('disabled'); + fields.forEach(field => { + field.disabled = true; + const fieldGroup = field.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.6'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + //console.log(`Section ${sectionId} disabled`); + } + } + + // Toggle section visibility (hide/show entire section) + static toggleSectionVisibility(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + + if (!sectionContent) { + console.warn(`Section content not found: ${sectionId}`); + return; + } + + if (isEnabled) { + // Show section + sectionContent.classList.remove('hidden'); + //console.log(`Section ${sectionId} shown`); + } else { + // Hide section + sectionContent.classList.add('hidden'); + //console.log(`Section ${sectionId} hidden`); + } + } + + // Initialize section toggles on page load + static initializeSectionToggles() { + // Find all master toggles and initialize their sections + document.querySelectorAll('[id^="config-CFG_"][onchange*="toggleSection"]').forEach(toggle => { + // Extract section ID from the onchange attribute + const onchangeAttr = toggle.getAttribute('onchange'); + const match = onchangeAttr.match(/toggleSection\('([^']+)'/); + if (match) { + const sectionId = match[1]; + const isEnabled = toggle.checked; + this.toggleSection(sectionId, isEnabled); + } + }); + } + + // Git section toggle function (moved from global scope) + static toggleGitSectionFields(isEnabled) { + // If no parameter provided, get the state from the checkbox + if (typeof isEnabled === 'undefined') { + const gitLoginCheckbox = document.getElementById('git-login-toggle'); + isEnabled = gitLoginCheckbox ? gitLoginCheckbox.checked : false; + } + + const gitSectionContent = document.getElementById('git-section-content'); + const gitFields = gitSectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (gitSectionContent && gitFields) { + if (isEnabled) { + gitSectionContent.classList.remove('hidden'); + gitFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + gitSectionContent.classList.add('hidden'); + gitFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + gitSectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Universal toggle function for all _ENABLED options + static toggleSection(sectionId, isEnabled) { + //console.log('=== UNIVERSAL TOGGLE DEBUG ==='); + //console.log('sectionId:', sectionId); + //console.log('isEnabled:', isEnabled); + + const sectionContent = document.getElementById(sectionId); + const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + //console.log('sectionContent found:', !!sectionContent); + //console.log('fields found:', fields ? fields.length : 0); + + if (sectionContent && fields) { + if (isEnabled) { + //console.log('Enabling section...'); + sectionContent.classList.remove('hidden'); + fields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling section...'); + sectionContent.classList.add('hidden'); + fields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + //console.log('=== UNIVERSAL TOGGLE DEBUG END ==='); + } + + // Remote backup section toggle function + static toggleMailSection(sectionId, isEnabled) { + //console.log('=== TOGGLE MAIL SECTION DEBUG ==='); + //console.log('sectionId:', sectionId); + //console.log('isEnabled:', isEnabled); + + alert('toggleMailSection called: ' + sectionId + ', enabled: ' + isEnabled); + + //console.log('Looking for sectionContent...'); + const sectionContent = document.getElementById(sectionId); + //console.log('sectionContent found:', !!sectionContent); + + //console.log('Looking for mailFields...'); + const mailFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + //console.log('mailFields found:', mailFields ? mailFields.length : 0); + + if (sectionContent && mailFields) { + //console.log('Enabling mail section...'); + if (isEnabled) { + //console.log('Removing hidden class...'); + sectionContent.classList.remove('hidden'); + //console.log('Enabling fields...'); + mailFields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + //console.log('Enabling field group...'); + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling mail section...'); + sectionContent.classList.add('hidden'); + //console.log('Disabling fields...'); + mailFields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + //console.log('Disabling field group...'); + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + //console.log('=== TOGGLE MAIL SECTION DEBUG END ==='); + } + + static toggleRemoteBackupSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + const backupFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (sectionContent && backupFields) { + if (isEnabled) { + sectionContent.classList.remove('hidden'); + backupFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + sectionContent.classList.add('hidden'); + backupFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + sectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Git section toggle function + static toggleGitSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + const gitFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (sectionContent && gitFields) { + if (isEnabled) { + sectionContent.classList.remove('hidden'); + gitFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + sectionContent.classList.add('hidden'); + gitFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + sectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Parse options string into array of {value, label} objects + static parseOptions(options) { + if (!options || typeof options !== 'string') { + return []; + } + + return options.split('|').map(opt => { + const parts = opt.split('='); + if (parts.length === 2) { + return { value: parts[0].trim(), label: parts[1].trim() }; + } + return { value: opt.trim(), label: opt.trim() }; + }); + } + + // Generate fields for category with 3-per-line layout and smart field detection + static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) { + let formHTML = '
    '; + + keys.forEach((key, index) => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + + // Add line break every 3 items + if (index > 0 && index % 3 === 0) { + formHTML += `
    `; + } + + // Use smart field creation if no callback provided, otherwise use callback + if (generateFieldCallback) { + formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config); + } else { + formHTML += this.createSmartField(fieldId, key, value, title, description, { + selectOptions: options, + category: category, + layout: 'inline', + config: config + }); + } + }); + + formHTML += `
    `; + return formHTML; + } + + // Generate fields for category WITHOUT the leading divider (for master toggle sections) + static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) { + let formHTML = ''; + + keys.forEach((key, index) => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + + let fieldHTML; + if (generateFieldCallback) { + fieldHTML = generateFieldCallback(fieldId, key, value, title, description, options, config); + } else { + fieldHTML = this.generateField(fieldId, key, value, title, description, options, config); + } + + formHTML += fieldHTML; + }); + + return formHTML; + } + + // Separate categories into regular, advanced, and unused + static categorizeConfigs(config) { + const groupedConfigs = this.groupConfigKeys(config); + const categoryOrder = this.extractCategoryOrder(config); + + const regularCategories = []; + const advancedCategories = []; + const unusedCategories = []; + + for (const category of categoryOrder) { + const keys = groupedConfigs[category]; + + if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') { + // Check if category has advanced items + const hasAdvanced = keys.some(key => { + const configItem = config[key] || {}; + return configItem.advanced === true; + }); + + // Check if category has unused items + const hasUnused = keys.some(key => { + const configItem = config[key] || {}; + return configItem.unused === true; + }); + + // Categorize based on the presence of advanced/unused flags + if (hasUnused) { + unusedCategories.push(category); + } else if (hasAdvanced) { + advancedCategories.push(category); + } else { + regularCategories.push(category); + } + } + } + + return { + groupedConfigs, + categoryOrder, + regularCategories, + advancedCategories, + unusedCategories + }; + } + + // Generate warning notice for requirements page + static generateRequirementsWarning() { + return ` +
    +
    +

    ⚠️ System Requirements Warning

    +

    Disabling any of the following system requirements may break LibrePortal functionality. Always create a backup before making changes.

    +
    +
    + `; + } + + // Generate toggle controls HTML for advanced/unused sections + static generateToggleControls(hasAdvanced = false, hasUnused = false) { + if (!hasAdvanced && !hasUnused) { + return ''; // Don't show danger zone if no content + } + + let formHTML = ` +
    +
    +

    ⚠️ Danger Zone

    +

    These options are for advanced users and may affect system stability

    +
    + +
    + `; + + if (hasAdvanced) { + formHTML += ` +
    + +
    + `; + } + + if (hasUnused) { + formHTML += ` +
    + +
    + `; + } + + formHTML += ` +
    +
    + `; + + return formHTML; + } + + // Generate advanced sections HTML + static async generateAdvancedSections(advancedCategories, groupedConfigs, config, getCategoryDescriptionCallback) { + if (advancedCategories.length === 0) { + return ''; + } + + let formHTML = ` + `; + return formHTML; + } + + // Generate unused sections HTML + static async generateUnusedSections(unusedCategories, groupedConfigs, config, getCategoryDescriptionCallback) { + if (unusedCategories.length === 0) { + return ''; + } + + let formHTML = ` + `; + return formHTML; + } + + // Toggle advanced sections visibility + static toggleAdvancedSections() { + const checkbox = document.getElementById('show-advanced'); + const advancedSections = document.getElementById('advanced-sections'); + + if (advancedSections) { + advancedSections.style.display = checkbox.checked ? 'block' : 'none'; + } + } + + // Toggle unused sections visibility + static toggleUnusedSections() { + const checkbox = document.getElementById('show-unused'); + const unusedSections = document.getElementById('unused-sections'); + + if (unusedSections) { + unusedSections.style.display = checkbox.checked ? 'block' : 'none'; + } + } +} + +// Export for global access +window.ConfigShared = ConfigShared; + +// Global toggle change function for checkbox handling +window.handleToggleChange = function(checkbox, key) { + //console.log(`Toggle changed: ${key} = ${checkbox.checked}`); + // This function can be extended to handle specific toggle logic + // For now, it just logs change +}; + +// Global flag to prevent multiple config reloads +window.isReloadingConfig = false; + +// Global function to handle CFG_INSTALL_MODE change +window.handleInstallModeChange = function(selectElement) { + // Prevent multiple simultaneous reloads + if (window.isReloadingConfig) { + //console.log('Config reload already in progress, ignoring...'); + return; + } + + const installMode = selectElement.value; + //console.log(`Install mode changed to: ${installMode}`); + + // Set flag to prevent multiple reloads + window.isReloadingConfig = true; + + // Use a longer delay and onchange instead of onblur to avoid the loop + setTimeout(() => { + if (window.configManager) { + // Clear cache to ensure we get fresh data with updated CFG_INSTALL_MODE + window.configManager.cache.clear(); + //console.log('Cache cleared for fresh config data'); + + // Get the current category from the URL or default to 'general' + const currentCategory = window.configCategory || 'general'; + //console.log(`Reloading config category: ${currentCategory}`); + window.configManager.renderConfig(currentCategory).finally(() => { + // Clear the flag after reload is complete + setTimeout(() => { + window.isReloadingConfig = false; + }, 500); // Extra delay to ensure DOM is fully settled + }); + } else if (window.configRouter) { + // Fallback to configRouter if configManager is not available + const currentCategory = window.configCategory || 'general'; + //console.log(`Using configRouter to reload: ${currentCategory}`); + window.configRouter.loadConfigComponentManual(currentCategory).finally(() => { + // Clear the flag after reload is complete + setTimeout(() => { + window.isReloadingConfig = false; + }, 500); // Extra delay to ensure DOM is fully settled + }); + } else { + console.warn('Neither configManager nor configRouter available for install mode change'); + window.isReloadingConfig = false; + } + }, 500); // Longer delay to avoid conflicts +}; + +// Global function to initialize git field visibility based on current CFG_INSTALL_MODE +window.initializeGitFieldVisibility = function() { + const installModeSelect = document.querySelector('select[name="CFG_INSTALL_MODE"]'); + if (installModeSelect) { + // Trigger the change handler to set initial visibility + handleInstallModeChange(installModeSelect); + } +}; + +// Global password toggle function for onclick handlers +window.togglePasswordVisibility = function(fieldId) { + ConfigShared.togglePasswordVisibility(fieldId); +}; diff --git a/containers/libreportal/frontend/js/components/config/config-sidebar.js b/containers/libreportal/frontend/js/components/config/config-sidebar.js new file mode 100755 index 0000000..9da7d3c --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-sidebar.js @@ -0,0 +1,96 @@ +// Config Sidebar - Handles sidebar population and navigation +class ConfigSidebar { + constructor() { + this.categoriesList = null; + } + + populateSidebar() { + //console.log('ConfigSidebar: Populating sidebar with categories...'); + + this.categoriesList = document.getElementById('config-categories-list'); + if (!this.categoriesList) { + console.error('ConfigSidebar: config-categories-list element not found'); + return; + } + + if (!window.configData || !window.configData.categories) { + console.error('ConfigSidebar: No config data available for sidebar'); + return; + } + + this.categoriesList.innerHTML = ''; + + // Convert categories object to array and sort by ORDER + const categoriesArray = Object.entries(window.configData.categories).map(([key, value]) => ({ + id: key, + ...value + })); + + // Sort by ORDER if available, otherwise by title + categoriesArray.sort(function(a, b) { + const orderA = parseInt(a.order) || 999; + const orderB = parseInt(b.order) || 999; + return orderA - orderB; + }); + + var self = this; // Preserve 'this' context + + categoriesArray.forEach(function(category) { + // Backup category has its own top-level page (/backup) which renders + // these same fields dynamically — hide it from the /config sidebar to + // avoid two surfaces for the same data. + if (category.id === 'backup') return; + + const categoryItem = document.createElement('div'); + categoryItem.className = 'category'; + categoryItem.setAttribute('data-category', category.id); + + // Use correct icon from our new structure + const iconName = category.icon || category.id; + const iconPath = '/icons/config/' + iconName + '.svg'; + + categoryItem.innerHTML = '' + category.title + ' ' + category.title; + + categoryItem.addEventListener('click', function() { + // Update URL without full page reload + const url = '/config?=' + category.id; + window.history.pushState({}, '', url); + + // Update active state + document.querySelectorAll('.category').forEach(function(item) { + item.classList.remove('active'); + }); + this.classList.add('active'); + + // Update global category and load dynamically + window.configCategory = category.id; + + // Load config dynamically without page refresh + if (window.configManager && typeof window.configManager.renderConfig === 'function') { + window.configManager.renderConfig(category.id); + } + }); + + self.categoriesList.appendChild(categoryItem); + }); + + // Set initial active category + this.setActiveCategory(window.configCategory || 'general'); + + //console.log('ConfigSidebar: Sidebar populated with ' + categoriesArray.length + ' categories'); + } + + setActiveCategory(categoryId) { + // Update active state + document.querySelectorAll('.category').forEach(function(item) { + item.classList.remove('active'); + }); + var activeItem = document.querySelector('[data-category="' + categoryId + '"]'); + if (activeItem) { + activeItem.classList.add('active'); + } + } +} + +// Export to global scope +window.ConfigSidebar = ConfigSidebar; diff --git a/containers/libreportal/frontend/js/components/config/config-utils.js b/containers/libreportal/frontend/js/components/config/config-utils.js new file mode 100755 index 0000000..91223cc --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-utils.js @@ -0,0 +1,111 @@ +// Config Utils - Utility functions for configuration management +class ConfigUtils { + constructor() { + // No initialization needed + } + + formatSubcategoryName(subcategoryName) { + return subcategoryName.replace(/_/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); + } + + cleanDescription(description) { + return description + .replace(/\*\*ADVANCED\*\*/g, '') + .replace(/\*\*UNUSED\*\*/g, '') + .replace(/^\s+|\s+$/g, '') // Trim whitespace + .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space + } + + filterSubcategoriesByType(configData, category) { + // Filter subcategories by category and separate into regular, advanced, and unused + var regularSubcategories = []; + var advancedSubcategories = []; + var unusedSubcategories = []; + + for (const [subcategoryName, subcategoryData] of Object.entries(configData)) { + if (subcategoryData.category === category) { + if (subcategoryData.description.includes('**ADVANCED**')) { + advancedSubcategories.push(subcategoryName); + } else if (subcategoryData.description.includes('**UNUSED**')) { + unusedSubcategories.push(subcategoryName); + } else { + regularSubcategories.push(subcategoryName); + } + } + } + + return { + regular: regularSubcategories, + advanced: advancedSubcategories, + unused: unusedSubcategories + }; + } + + async renderSectionedContent(formHTML, advancedSubcategories, unusedSubcategories, self, category, configData) { + // Add danger zone toggle controls if needed + if (advancedSubcategories.length > 0 || unusedSubcategories.length > 0) { + formHTML += this.generateToggleControls(advancedSubcategories.length > 0, unusedSubcategories.length > 0); + } + + // Render advanced sections (hidden by default). The old grouping + // header ("🛠️ Advanced Configuration") is gone — each subcategory + // self-identifies via the red "Advanced" badge on its title (see + // .is-advanced .domains-header h3::after in config.css). + if (advancedSubcategories.length > 0) { + formHTML += ''; + } + + // Render unused sections (hidden by default) + if (unusedSubcategories.length > 0) { + formHTML += ''; + } + + return formHTML; + } + + generateToggleControls(hasAdvanced = false, hasUnused = false) { + if (!hasAdvanced && !hasUnused) { + return ''; // Don't show danger zone if no content + } + + let formHTML = '

    ⚠️ Danger Zone

    These options are for advanced users and may affect system stability

    '; + + if (hasAdvanced) { + formHTML += '
    '; + } + + if (hasUnused) { + formHTML += '
    '; + } + + formHTML += '
    '; + + return formHTML; + } +} + +// Export to global scope +window.ConfigUtils = ConfigUtils; diff --git a/containers/libreportal/frontend/js/components/config/config-validator.js b/containers/libreportal/frontend/js/components/config/config-validator.js new file mode 100755 index 0000000..5f1510c --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-validator.js @@ -0,0 +1,248 @@ +// Config Validation System for LibrePortal Web UI +// Validates JSON config files before loading the main interface + +// Global validator instance +window.ConfigValidator = function() { + let validationResults = null; + let hasValidated = false; + + // Validate all config files + this.validateAllConfigs = async function() { + // Use client-side validation directly + return this.fallbackValidation(); + }; + + // Fallback client-side validation + this.fallbackValidation = async function() { + const results = { + valid: true, + errors: [], + warnings: [], + suggestions: [] + }; + + // Check if unified config file exists (file existence check only) + const configFiles = [ + { name: 'Unified System Config', path: 'data/config/generated/configs.json' } + ]; + + for (const config of configFiles) { + try { + const response = await fetch(config.path, { method: 'HEAD' }); + if (!response.ok) { + results.valid = false; + results.errors.push(`Config file '${config.name}' not found: ${config.path}`); + continue; + } + + //console.log(`Config file exists: ${config.name}`); + + } catch (error) { + results.valid = false; + results.errors.push(`Failed to check ${config.name}: ${error.message}`); + } + } + + // Add suggestions if there are issues + if (!results.valid) { + results.suggestions = [ + "Run 'libreportal run' to fix configuration issues", + "Check config file permissions and ownership", + "Ensure Docker is running and accessible" + ]; + } + + validationResults = results; + hasValidated = true; + return results; + }; + + // Show validation error message + this.showValidationError = function() { + if (!hasValidated) { + return; + } + + if (validationResults.valid) { + return; // No error to show + } + + // Create error overlay + const errorOverlay = document.createElement('div'); + errorOverlay.className = 'config-validation-overlay'; + errorOverlay.innerHTML = ` +
    +
    +

    ⚠️ Configuration Issues Detected

    + +
    +
    +
    +

    Errors:

    +
      + ${validationResults.errors.map(error => `
    • ${this.escapeHtml(error)}
    • `).join('')} +
    +
    + ${validationResults.warnings.length > 0 ? ` +
    +

    Warnings:

    +
      + ${validationResults.warnings.map(warning => `
    • ${this.escapeHtml(warning)}
    • `).join('')} +
    +
    + ` : ''} +
    +

    Suggestions:

    +
      + ${validationResults.suggestions.map(suggestion => `
    • ${this.escapeHtml(suggestion)}
    • `).join('')} +
    +
    +
    + + +
    +
    +
    + `; + + // Add styles + errorOverlay.innerHTML += ` + + `; + + document.body.appendChild(errorOverlay); + }; + + // Escape HTML to prevent XSS + this.escapeHtml = function(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Get validation status + this.isValid = function() { + return hasValidated && validationResults.valid; + }; + + // Get validation errors + this.getErrors = function() { + return hasValidated ? validationResults.errors : []; + }; + + // Get validation warnings + this.getWarnings = function() { + return hasValidated ? validationResults.warnings : []; + }; +}; diff --git a/containers/libreportal/frontend/js/components/config/domain-manager.js b/containers/libreportal/frontend/js/components/config/domain-manager.js new file mode 100755 index 0000000..189b9a3 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/domain-manager.js @@ -0,0 +1,646 @@ +// Domain Management - Handles domain-related functionality +class DomainManager { + constructor() { + this.domains = []; + } + + async checkTraefikInstallation() { + try { + const response = await fetch('/api/traefik/status'); + if (response.ok) { + const data = await response.json(); + return data.installed || false; + } + return false; + } catch (error) { + //console.log('DomainManager: Traefik status check failed, assuming not installed:', error.message); + return false; + } + } + + async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) { + const traefikStatus = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    + `; + + if (!traefikStatus.installed) { + html += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + html += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = configItems.map(item => item.key).filter(key => key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(key => { + const configItem = configItems.find(item => item.key === key) || {}; + const value = configItem.value || ''; + return value.trim() !== ''; + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + const isMaxDomains = domainKeysWithContent.length >= 9; + + domainKeysWithContent.forEach(key => { + const configItem = configItems.find(item => item.key === key) || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const fieldId = `config-${key}`; + + // Extract domain number + const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + html += ` +
    +
    + ${this.generateField(fieldId, key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input' + })} + +
    +
    +
    + `; + }); + + setTimeout(() => this.attachDnsChecks(), 0); + + html += ` +
    +
    + +
    +
    +
    +
    + `; + + return html; + } + + attachDnsChecks() { + const inputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + inputs.forEach((input) => { + if (input.dataset.dnsBound === '1') return; + input.dataset.dnsBound = '1'; + const status = document.querySelector(`[data-dns-for="${input.id}"]`); + if (!status) return; + let timer = null; + let ctrl = null; + const run = () => { + if (timer) clearTimeout(timer); + if (ctrl) ctrl.abort(); + const domain = input.value.trim().toLowerCase(); + if (!domain) { status.textContent = ''; status.className = 'setup-dns-status'; return; } + if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(domain)) { + status.textContent = ''; status.className = 'setup-dns-status'; + return; + } + status.textContent = 'Checking DNS…'; + status.className = 'setup-dns-status checking'; + timer = setTimeout(async () => { + ctrl = new AbortController(); + try { + const res = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, { signal: ctrl.signal }); + const data = await res.json(); + if (data.matches) { + status.textContent = `✓ ${domain} → ${data.server_ip} (this server)`; + status.className = 'setup-dns-status ok'; + } else { + const detail = data.domain_ip + ? `points to ${data.domain_ip}, this server is ${data.server_ip}` + : `does not resolve (server: ${data.server_ip || 'unknown'})`; + status.textContent = `⚠ ${domain} ${detail}. Traefik may not route this yet.`; + status.className = 'setup-dns-status warn'; + } + } catch (err) { + if (err.name !== 'AbortError') { + status.textContent = 'DNS check failed.'; + status.className = 'setup-dns-status warn'; + } + } + }, 500); + }; + input.addEventListener('input', run); + input.addEventListener('blur', run); + if (input.value) run(); + }); + } + + generateField(fieldId, key, value, title, description, options = {}) { + // Use the same field generation as ConfigShared + if (typeof ConfigShared !== 'undefined') { + return ConfigShared.generateField(fieldId, key, value, title, description, options); + } + + // Fallback field generation + const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com'; + return ` + + `; + } + + formatConfigLabel(key) { + // Special handling for domain configuration + if (key.startsWith('CFG_DOMAIN_')) { + const domainNum = key.replace('CFG_DOMAIN_', ''); + return `Domain ${domainNum}`; + } + + // Use ConfigShared if available + if (typeof ConfigShared !== 'undefined') { + return ConfigShared.formatConfigLabel(key); + } + + // Fallback formatting + return key.replace(/^CFG_/, '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); + } + + addNewDomain() { + //console.log('Add Domain button clicked!'); + + try { + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${this.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })} + +
    +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + this.updateDomainDeleteButtons(); + this.attachDnsChecks(); + } + } catch (error) { + console.error('Error adding new domain:', error); + } + } + + deleteDomain(domainKey, buttonElement) { + if (typeof window.DomainManager === 'undefined' || !window.DomainManager.deleteDomain) { + console.error('DomainManager not available for deleteDomain'); + return; + } + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update delete button states + this.updateDomainDeleteButtons(); + } + } catch (error) { + console.error('Error deleting domain:', error); + } + } + + updateDomainDeleteButtons() { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1) + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'; + } + } + }); + } +} + +// Standalone domain management functions - immediately available +window.addDomain = function() { + //console.log('Add Domain button clicked!'); + + try { + // Before adding new domain, validate that all existing domains have valid format + if (!canAddNewDomain()) { + // Find the first invalid domain and focus it with flash + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!validateDomainFormat(input, false)) { // Don't show notification here + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return; + } + } + return; + } + + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${typeof ConfigShared !== 'undefined' ? ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'validateDomainFormat(this, true)', + oninput: 'validateDomainFormat(this, true)', + onblur: 'validateDomainFormat(this, true)' + }) : ``} + +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + updateDomainDeleteButtons(); + updateAddDomainButton(); + } + } catch (error) { + console.error('Error adding new domain:', error); + } +}; + +window.deleteDomain = function(domainKey, buttonElement) { + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update add domain button state (re-enable if we're below 9 domains) + updateDomainDeleteButtons(); + updateAddDomainButton(); + } + } catch (error) { + console.error('Error deleting domain:', error); + } +}; + +// Helper function to update delete button states +function updateDomainDeleteButtons() { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1) + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'; + } + } + }); +} + +// Helper function to update add domain button state +function updateAddDomainButton() { + const addDomainBtn = document.getElementById('add-domain-btn'); + if (addDomainBtn) { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Check if we've reached the maximum of 9 domains + const isMaxDomains = allDomainNumbers.length >= 9; + + // Update button state + addDomainBtn.disabled = isMaxDomains; + addDomainBtn.className = `btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}`; + addDomainBtn.innerHTML = `${isMaxDomains ? '✓' : '+'}${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}`; + } +} + +// Validate domain format when user tries to add a new domain +function validateDomainFormat(input, showNotifications = true) { + const value = input.value.trim(); + //console.log('validateDomainFormat called with:', value, 'showNotifications:', showNotifications); + //console.log('window.notificationSystem available:', !!window.notificationSystem); + + if (!value) { + // Clear styling for empty fields + input.style.borderColor = '#dc3545'; + input.title = 'Domain cannot be empty'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show empty domain notification'); + window.notificationSystem.error('Domain cannot be empty'); + } else { + console.error('Domain cannot be empty - notification system not available'); + } + return false; // Empty fields are not valid for adding new domains + } + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(value); + + // Check for duplicates + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === value + ); + const hasDuplicate = duplicates.length > 0; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show invalid format notification'); + window.notificationSystem.error('Invalid domain format: "' + value + '". Please use a valid domain like example.com'); + } else { + console.error('Invalid domain format - notification system not available'); + } + return false; + } else if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Domain already exists'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show duplicate notification'); + window.notificationSystem.error('Domain "' + value + '" already exists'); + } else { + console.error('Domain already exists - notification system not available'); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } +} + +// Check if all domains are valid before allowing new domain addition +function canAddNewDomain() { + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + const domainValue = input.value.trim(); + + // Check if domain is empty + if (!domainValue) { + // Flash the empty input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification with safety check + if (window.notificationSystem) { + window.notificationSystem.error('Domain cannot be empty'); + } else { + console.error('Domain cannot be empty'); + } + return false; // Empty domain - don't allow adding new domain + } + + // Check for duplicates with notification (always show) + if (checkForDuplicateDomain(input, domainValue)) { + return false; + } + + // Check domain format with notification (always show) + if (checkForInvalidDomainFormat(input, domainValue)) { + return false; + } + } + return true; // All domains are valid and non-empty +} + +// Separate function to check for invalid format that always shows notifications +function checkForInvalidDomainFormat(input, domainValue) { + if (!domainValue) return false; + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(domainValue); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (window.notificationSystem) { + //console.log('Showing invalid domain format notification'); + window.notificationSystem.error('Invalid domain format: "' + domainValue + '". Please use a valid domain like example.com'); + } else { + console.error('Invalid domain format - notification system not available'); + } + return true; + } + + return false; +} + +// Separate function to check for duplicates that always shows notifications +function checkForDuplicateDomain(input, domainValue) { + if (!domainValue) return false; + + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === domainValue + ); + const hasDuplicate = duplicates.length > 0; + + if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Domain already exists'; + if (window.notificationSystem) { + //console.log('Showing duplicate domain notification'); + window.notificationSystem.error('Domain "' + domainValue + '" already exists'); + } else { + console.error('Domain already exists - notification system not available'); + } + return true; + } + + return false; +} diff --git a/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js b/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js new file mode 100755 index 0000000..a0ced79 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js @@ -0,0 +1,952 @@ +/** + * IP Whitelist Manager for LibrePortal + * Handles multiple IP whitelist fields with validation and management + */ + +class IPWhitelistManager { + constructor() { + this.maxWhitelistEntries = 20; // Maximum number of whitelist entries + } + + // Render IP whitelist section with add/delete functionality + async renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription) { + try { + // Check if Traefik is installed + const traefikStatus = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + + +
    + `; + + // Extract existing whitelist entries from config + const whitelistEntries = this.extractWhitelistEntries(configItems); + + // Get the original CFG_IPS_WHITELIST value for the hidden input + const whitelistItem = configItems.find(item => item.key === 'CFG_IPS_WHITELIST'); + const originalValue = whitelistItem ? whitelistItem.value : ''; + + if (whitelistEntries.length === 0) { + // Show empty state with add button + html += ` +
    +

    No IP whitelist entries configured. Add IP addresses, domains, or localhost to allow access to specific Traefik apps.

    +
    + +
    +
    + `; + } else { + // Render existing whitelist entries + whitelistEntries.forEach((entry, index) => { + const entryId = `config-CFG_IPS_WHITELIST_${index + 1}`; + const entryTitle = `Whitelist Entry ${index + 1}`; + + html += ` +
    +
    + ${this.generateField(entryId, `CFG_IPS_WHITELIST`, entry.value, entryTitle, '', { + placeholder: '192.168.1.100, example.com, or localhost', + className: 'whitelist-input', + onblur: 'window.validateWhitelistEntry(this, true)' + })} + +
    +
    + `; + }); + + // Add button for new entries + const isMaxEntries = whitelistEntries.length >= this.maxWhitelistEntries; + html += ` +
    +
    + +
    + `; + } + + html += ` +
    +
    +
    +
    + `; + + // After rendering, set the hidden input value and update button states + setTimeout(() => { + const hiddenInput = document.getElementById('config-CFG_IPS_WHITELIST'); + if (hiddenInput) { + hiddenInput.value = originalValue; + } + + // Update delete button states + this.updateWhitelistDeleteButtons(); + }, 100); + + return html; + } catch (error) { + console.error('Error rendering whitelist section:', error); + return '

    Error loading whitelist configuration.

    '; + } + } + + // Extract whitelist entries from config items + extractWhitelistEntries(configItems) { + const whitelistItem = configItems.find(item => item.key === 'CFG_IPS_WHITELIST'); + if (!whitelistItem || !whitelistItem.value) { + return []; + } + + // Split comma-separated values and filter out empty ones + const entries = whitelistItem.value + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0 && entry !== 'HOSTIPHERE'); + + return entries.map((entry, index) => ({ + key: `CFG_IPS_WHITELIST_${index + 1}`, // For field naming only + value: entry, + originalIndex: index // Keep track of original position + })); + } + + // Save whitelist entries back to single CFG_IPS_WHITELIST config + saveWhitelistEntries() { + try { + // Get only the visible whitelist input values (exclude hidden input) + const whitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('saveWhitelistEntries: Found inputs:', whitelistInputs.length); + whitelistInputs.forEach((input, index) => { + //console.log(` Input ${index}: id="${input.id}", value="${input.value}"`); + }); + + const values = Array.from(whitelistInputs) + .map(input => input.value.trim()) + .filter(value => value.length > 0); // Filter out empty values + + //console.log('saveWhitelistEntries: Filtered values:', values); + + // Find the hidden CFG_IPS_WHITELIST input and update it + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"][type="hidden"]'); + if (hiddenInput) { + hiddenInput.value = values.join(', '); + //console.log('Saved whitelist values:', values.join(', ')); + } + } catch (error) { + console.error('Error saving whitelist entries:', error); + } + } + + // Validate individual whitelist entry + validateWhitelistEntry(input, showNotifications = true) { + //console.log('validateWhitelistEntry called with:', input.value, 'showNotifications:', showNotifications); + + const value = input.value.trim(); + + // Clear previous validation styling + input.style.borderColor = ''; + input.style.animation = ''; + input.title = ''; + + if (!value) { + //console.log('Empty value - allowing for now'); + // Empty is allowed for now (validation happens on add) + return true; + } + + // Special case for localhost + if (value.toLowerCase() === 'localhost') { + //console.log('localhost detected - valid'); + return true; + } + + // Check for valid IP address or domain format + const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + // Fixed domain pattern - requires at least one dot and proper TLD + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + + const isValidIP = ipPattern.test(value) || ipv6Pattern.test(value); + const isValidDomain = domainPattern.test(value); + const isValidFormat = isValidIP || isValidDomain || value.toLowerCase() === 'localhost'; + + //console.log('Format validation:', { + //value: value, + //isValidIP: isValidIP, + //isValidDomain: isValidDomain, + //isValidFormat: isValidFormat + //}); + + if (!isValidFormat) { + //console.log('Invalid format detected - showing error'); + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (showNotifications && window.notificationSystem) { + //console.log('Showing notification for invalid format'); + window.notificationSystem.error(`Invalid whitelist format: "${value}". Please use a valid IP address, domain (e.g., example.com), or localhost`); + } else { + //console.log('Notification system not available or showNotifications is false'); + } + + input.title = 'Invalid IP address, domain, or localhost format (e.g., 192.168.1.100, example.com, or localhost)'; + return false; // IMPORTANT: Return false for invalid format + } + + // Check for duplicates + const allWhitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('Checking for duplicates among', allWhitelistInputs.length, 'inputs'); + + const duplicates = []; + + allWhitelistInputs.forEach(otherInput => { + if (otherInput !== input) { + const otherValue = otherInput.value.trim(); + if (otherValue.toLowerCase() === value.toLowerCase()) { + duplicates.push(otherValue); + } + } + }); + + //console.log('Found duplicates:', duplicates); + + if (duplicates.length > 0) { + //console.log('Duplicate detected - showing error'); + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (showNotifications && window.notificationSystem) { + //console.log('Showing notification for duplicate'); + window.notificationSystem.error(`"${value}" already exists in the whitelist`); + } + + input.title = 'This entry already exists in the whitelist'; + return false; // IMPORTANT: Return false for duplicates + } + + // Valid entry - save it + //console.log('Valid entry - saving'); + this.saveWhitelistEntries(); + return true; + } + + // Generate field HTML (similar to ConfigShared.generateField) + generateField(fieldId, fieldName, value, title, description, options = {}) { + const defaultOptions = { + type: 'text', + placeholder: '', + className: '', + required: false + }; + + const fieldOptions = { ...defaultOptions, ...options }; + + return ` +
    + + + ${description ? `${description}` : ''} +
    + `; + } + + // Check if Traefik is installed + async checkTraefikInstallation() { + try { + if (typeof DataLoader !== 'undefined') { + return await DataLoader.isAppInstalled('traefik'); + } + return false; + } catch (error) { + //console.log('Traefik check failed:', error.message); + return false; + } + } + + // Add new whitelist entry + addWhitelistEntry() { + //console.log('Add whitelist entry button clicked!'); + + try { + // Before adding new entry, validate that all existing entries have valid format + //console.log('About to call validateBeforeAddWhitelist()'); + + // Test if the function exists + if (typeof this.validateBeforeAddWhitelist !== 'function') { + console.error('validateBeforeAddWhitelist function does not exist!'); + return; + } + + const canAdd = this.validateBeforeAddWhitelist(); + //console.log('validateBeforeAddWhitelist() returned:', canAdd); + + if (!canAdd) { + //console.log('Validation failed - not adding new entry'); + return; // Validation failed, don't add new entry + } + + //console.log('Validation passed - proceeding with add'); + + // Find the hidden CFG_IPS_WHITELIST input + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"]'); + if (!hiddenInput) { + console.error('Hidden CFG_IPS_WHITELIST input not found!'); + return; + } + + // Get current values and split them + const currentValues = hiddenInput.value + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); // Don't filter out empty strings for counting + + //console.log('Current values:', currentValues); + //console.log('Current values length:', currentValues.length); + + // Check if we've reached the maximum + if (currentValues.length >= this.maxWhitelistEntries) { + //console.log('Maximum of ' + this.maxWhitelistEntries + ' whitelist entries reached'); + return; + } + + // Add a new empty entry + // Find the highest existing whitelist number from the DOM (like domain manager) + const allBlocks = document.querySelectorAll('.whitelist-building-block'); + const allNumbers = []; + allBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (match) { + allNumbers.push(parseInt(match[1])); + } + } + }); + + const highestNumber = allNumbers.length > 0 ? Math.max(...allNumbers) : 0; + const newEntryNumber = highestNumber + 1; + + //console.log('Highest existing number:', highestNumber, 'New entry number:', newEntryNumber); + + currentValues.push(''); + + // Update the hidden input + hiddenInput.value = currentValues.join(', '); + //console.log('Updated hidden input to:', hiddenInput.value); + + // Create new entry HTML and add to DOM + const newEntryHTML = ` +
    +
    +
    + + +
    + +
    +
    + `; + + // Add the new entry to the DOM + const whitelistContainer = document.querySelector('.whitelist-building-blocks'); + //console.log('Step 1: Found whitelist container:', !!whitelistContainer); + + if (whitelistContainer) { + // Remove empty state if it exists + const emptyState = whitelistContainer.querySelector('.whitelist-empty-state'); + //console.log('Step 2: Found empty state:', !!emptyState); + if (emptyState) { + emptyState.remove(); + //console.log('Step 3: Removed empty state'); + } + + // Add new entry + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newEntryHTML; + const newBlock = tempDiv.firstElementChild; + //console.log('Step 4: Created new block:', !!newBlock); + + whitelistContainer.appendChild(newBlock); + //console.log('Step 5: Added new block to container'); + + // Focus on the new input + const newInput = newBlock.querySelector('input'); + //console.log('Step 6: Found new input:', !!newInput); + if (newInput) { + newInput.focus(); + //console.log('Step 7: Focused new input'); + } + + // Update add button state + this.updateAddEntryButton(); + //console.log('Step 8: Updated add button state'); + + //console.log('Step 9: Add function completed successfully'); + } else { + //console.log('Step 1: Whitelist container not found!'); + } + + } catch (error) { + console.error('Error adding new whitelist entry:', error); + } + } + + // Check if all entries are valid before allowing new entry addition + validateBeforeAddWhitelist() { + //console.log('=== validateBeforeAddWhitelist START ==='); + + // Simple test - just check for empty entries + const allInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('Found', allInputs.length, 'inputs'); + + allInputs.forEach((input, index) => { + //console.log(`Input ${index}: value="${input.value}"`); + }); + + for (const input of allInputs) { + const entryValue = input.value.trim(); + + // Check if entry is empty + if (!entryValue) { + //console.log('Found empty entry - blocking add'); + // Flash the empty input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (window.notificationSystem) { + window.notificationSystem.error('Whitelist entry cannot be empty'); + } + //console.log('=== validateBeforeAddWhitelist END (false - empty) ==='); + return false; + } + + // Check entry format with notification (always show) + if (this.checkForInvalidWhitelistFormat(input, entryValue, false)) { + //console.log('Found invalid format - blocking add'); + //console.log('=== validateBeforeAddWhitelist END (false - invalid) ==='); + return false; + } + + // Check for duplicates with notification (always show) + if (this.checkForDuplicateWhitelistEntry(input, entryValue)) { + //console.log('Found duplicate entry - blocking add'); + //console.log('=== validateBeforeAddWhitelist END (false - duplicate) ==='); + return false; + } + } + + //console.log('=== validateBeforeAddWhitelist END (true) ==='); + return true; + } + + // Check for duplicate whitelist entries + checkForDuplicateWhitelistEntry(input, entryValue) { + const allWhitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + const duplicates = []; + + allWhitelistInputs.forEach(otherInput => { + if (otherInput !== input) { + const otherValue = otherInput.value.trim(); + if (otherValue.toLowerCase() === entryValue.toLowerCase()) { + duplicates.push(otherValue); + } + } + }); + + if (duplicates.length > 0) { + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (window.notificationSystem) { + window.notificationSystem.error(`"${entryValue}" already exists in the whitelist`); + } else { + console.error(`Duplicate whitelist entry: ${entryValue}`); + } + return true; + } + + return false; + } + + // Check for invalid whitelist entry format + checkForInvalidWhitelistFormat(input, entryValue, showNotifications = true) { + // Special case for localhost + if (entryValue.toLowerCase() === 'localhost') { + return false; // localhost is valid + } + + // Check for valid IP address or domain format + const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + // Fixed domain pattern - requires at least one dot and proper TLD + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + + const isValidIP = ipPattern.test(entryValue) || ipv6Pattern.test(entryValue); + const isValidDomain = domainPattern.test(entryValue); + + //console.log('checkForInvalidWhitelistFormat validation:', { + //entryValue: entryValue, + //isValidIP: isValidIP, + //isValidDomain: isValidDomain, + //isValidFormat: isValidIP || isValidDomain || entryValue.toLowerCase() === 'localhost' + //}); + + if (!isValidIP && !isValidDomain) { + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification only if requested + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid whitelist format: "${entryValue}". Please use a valid IP address, domain (e.g., example.com), or localhost`); + } else if (showNotifications) { + console.error(`Invalid whitelist format: ${entryValue}`); + } + return true; // Invalid format found + } + + return false; // Valid format + } + + // Delete whitelist entry + deleteWhitelistEntry(entryIndex, buttonElement) { + //console.log(`Delete whitelist entry button clicked for index: ${entryIndex}`); + + try { + // Get the actual whitelist number from the input ID + const entryBlock = buttonElement.closest('.whitelist-building-block'); + if (!entryBlock) { + console.error('Could not find entry block'); + return; + } + + const input = entryBlock.querySelector('input'); + if (!input) { + console.error('Could not find input in entry block'); + return; + } + + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + const whitelistNumber = match ? parseInt(match[1]) : 0; + + //console.log(`Actual whitelist number: ${whitelistNumber}`); + + // Find all whitelist numbers to determine the highest + const allBlocks = document.querySelectorAll('.whitelist-building-block'); + const allNumbers = []; + allBlocks.forEach(block => { + const blockInput = block.querySelector('input'); + if (blockInput) { + const blockId = blockInput.id; + const blockMatch = blockId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (blockMatch) { + allNumbers.push(parseInt(blockMatch[1])); + } + } + }); + + const highestNumber = Math.max(...allNumbers); + //console.log(`Highest whitelist number: ${highestNumber}`); + + // Only allow deletion if this is the highest numbered entry AND it's not #1 + if (whitelistNumber === 1) { + //console.log('Cannot delete entry #1'); + if (window.notificationSystem) { + window.notificationSystem.error('Entry 1 cannot be deleted'); + } + return; + } + + if (whitelistNumber !== highestNumber) { + //console.log('Can only delete the highest numbered entry'); + if (window.notificationSystem) { + window.notificationSystem.error('Can only delete the highest numbered entry'); + } + return; + } + + // Clear the input value first (like domain manager) + input.value = ''; + + // Remove the building block from DOM + entryBlock.remove(); + //console.log('Removed entry block from DOM'); + + // Update the hidden input with remaining values + this.saveWhitelistEntries(); + + // Update add button state + this.updateAddEntryButton(); + + // Update delete button states + this.updateWhitelistDeleteButtons(); + + } catch (error) { + console.error('Error deleting whitelist entry:', error); + } + } + + // Rebuild whitelist section after changes + rebuildWhitelistSection() { + try { + // Get the current config data + const configData = window.configManager.core.configData; + const networkData = configData.network || {}; + + // Get the current CFG_IPS_WHITELIST value + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"]'); + if (hiddenInput) { + networkData.CFG_IPS_WHITELIST = hiddenInput.value; + } + + // Find the whitelist section and re-render only that part + const whitelistSection = document.querySelector('.config-category'); + if (whitelistSection) { + // Create config items for the whitelist + const configItems = [{ + key: 'CFG_IPS_WHITELIST', + value: hiddenInput ? hiddenInput.value : '' + }]; + + // Re-render the whitelist section + this.renderWhitelistSection(configItems, 'IP Whitelist', 'Allow specific IPs for Specified Traefik Apps') + .then(html => { + whitelistSection.innerHTML = html; + + // Re-attach event listeners and set hidden input value + setTimeout(() => { + if (hiddenInput) { + const newHiddenInput = document.getElementById('config-CFG_IPS_WHITELIST'); + if (newHiddenInput) { + newHiddenInput.value = hiddenInput.value; + } + } + }, 100); + }); + } + } catch (error) { + console.error('Error rebuilding whitelist section:', error); + // Fallback to full render if partial fails + if (window.configManager) { + window.configManager.renderConfig('network'); + } + } + } + + // Update delete button states + updateWhitelistDeleteButtons() { + // Find all whitelist building blocks + const allWhitelistBlocks = document.querySelectorAll('.whitelist-building-block'); + + // Find all whitelist numbers from existing blocks (using input IDs since all have same name) + const allWhitelistNumbers = []; + allWhitelistBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (match) { + allWhitelistNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered whitelist can be deleted, but NEVER Entry 1) + allWhitelistBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-whitelist-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const whitelistNum = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + const whitelistNumber = whitelistNum ? parseInt(whitelistNum[1]) : 0; + + // Find the highest whitelist number among all visible blocks + const highestWhitelistNumber = Math.max(...allWhitelistNumbers); + + // Only highest numbered whitelist can be deleted, but NEVER Entry 1 + const canDelete = whitelistNumber === highestWhitelistNumber && whitelistNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-whitelist-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete + ? 'Delete whitelist entry' + : whitelistNumber === 1 + ? 'Entry 1 cannot be deleted' + : 'Can only delete highest numbered entry'; + } + } + }); + } + + // Update add entry button state + updateAddEntryButton() { + const addEntryBtn = document.getElementById('add-whitelist-btn'); + if (addEntryBtn) { + // Find all whitelist building blocks + const allWhitelistBlocks = document.querySelectorAll('.whitelist-building-block'); + + // Check if we've reached the maximum of 20 entries + const isMaxEntries = allWhitelistBlocks.length >= this.maxWhitelistEntries; + + // Update button state + addEntryBtn.disabled = isMaxEntries; + addEntryBtn.className = `btn ${isMaxEntries ? 'btn-secondary' : 'btn-primary'}`; + addEntryBtn.innerHTML = `${isMaxEntries ? '✓' : '+'}${isMaxEntries ? 'Maximum Entries Reached' : 'Add IP/Domain'}`; + } + + // Also update delete button states + this.updateWhitelistDeleteButtons(); + } + + // Check if all entries are valid before allowing new entry addition + canAddNewEntry() { + const allWhitelistInputs = document.querySelectorAll('input[name^="CFG_IPS_WHITELIST_"]'); + for (const input of allWhitelistInputs) { + const entryValue = input.value.trim(); + + // Check if entry is empty + if (!entryValue) { + // Flash empty input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification with safety check + if (window.notificationSystem) { + window.notificationSystem.error('Whitelist entry cannot be empty'); + } else { + console.error('Whitelist entry cannot be empty'); + } + return false; // Empty entry - don't allow adding new entry + } + + // Check for duplicates with notification (always show) + if (checkForDuplicateWhitelistEntry(input, entryValue)) { + return false; + } + + // Check entry format + if (!validateWhitelistEntry(input, false)) { // Don't show notifications during bulk check + // Flash invalid input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return false; // At least one entry has invalid format or duplicate + } + } + return true; // All entries are valid and non-empty + } +} + +// Separate function to check for duplicates that always shows notifications +function checkForDuplicateWhitelistEntry(input, entryValue) { + if (!entryValue) return false; + + const allWhitelistInputs = document.querySelectorAll('input[name^="CFG_IPS_WHITELIST_"]'); + const duplicates = Array.from(allWhitelistInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === entryValue + ); + const hasDuplicate = duplicates.length > 0; + + if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Whitelist entry already exists'; + if (window.notificationSystem) { + //console.log('Showing duplicate whitelist entry notification'); + window.notificationSystem.error('Whitelist entry "' + entryValue + '" already exists'); + } else { + console.error('Whitelist entry already exists - notification system not available'); + } + return true; + } + + return false; +} + +// Separate function to check for invalid format that always shows notifications +function checkForInvalidWhitelistFormat(input, entryValue) { + if (!entryValue) return false; + + // IP address regex (supports IPv4 and IPv6) + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/i; + // Domain regex (supports subdomains and multiple TLD levels) + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + + const isValidIP = ipRegex.test(entryValue); + const isValidDomain = domainRegex.test(entryValue); + const isValidFormat = isValidIP || isValidDomain; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid IP address or domain format (e.g., 192.168.1.100 or example.com)'; + if (window.notificationSystem) { + //console.log('Showing invalid whitelist format notification'); + window.notificationSystem.error('Invalid whitelist format: "' + entryValue + '". Please use a valid IP address or domain like 192.168.1.100 or example.com'); + } else { + console.error('Invalid whitelist format - notification system not available'); + } + return true; + } + + return false; +} + +// Validate whitelist entry format when user tries to add a new entry +function validateWhitelistEntry(input, showNotifications = true) { + const value = input.value.trim(); + //console.log('validateWhitelistEntry called with:', value, 'showNotifications:', showNotifications); + //console.log('window.notificationSystem available:', !!window.notificationSystem); + + if (!value) { + // Clear styling for empty fields + input.style.borderColor = '#dc3545'; + input.title = 'Whitelist entry cannot be empty'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show empty whitelist notification'); + window.notificationSystem.error('Whitelist entry cannot be empty'); + } else { + console.error('Whitelist entry cannot be empty - notification system not available'); + } + return false; // Empty fields are not valid for adding new entries + } + + // Special case for localhost + if (value.toLowerCase() === 'localhost') { + input.style.borderColor = ''; + input.title = ''; + return true; // localhost is always valid + } + + // IP address regex (supports IPv4 and IPv6) + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/i; + // Domain regex (supports subdomains and multiple TLD levels) + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + + const isValidIP = ipRegex.test(value); + const isValidDomain = domainRegex.test(value); + const isValidFormat = isValidIP || isValidDomain || value.toLowerCase() === 'localhost'; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid IP address, domain, or localhost format (e.g., 192.168.1.100, example.com, or localhost)'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show invalid whitelist format notification'); + window.notificationSystem.error('Invalid whitelist format: "' + value + '". Please use a valid IP address, domain, or localhost like 192.168.1.100, example.com, or localhost'); + } else { + console.error('Invalid whitelist format - notification system not available'); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } +} + +// Standalone whitelist management functions - immediately available +window.addWhitelistEntry = function() { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.addWhitelistEntry) { + return window.IPWhitelistManager.addWhitelistEntry(); + } + console.error('addWhitelistEntry called but IPWhitelistManager not available'); +}; + +window.deleteWhitelistEntry = function(entryKey, buttonElement) { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.deleteWhitelistEntry) { + return window.IPWhitelistManager.deleteWhitelistEntry(entryKey, buttonElement); + } + console.error('deleteWhitelistEntry called but IPWhitelistManager not available'); +}; + +window.validateWhitelistEntry = function(input, showNotifications) { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.validateWhitelistEntry) { + return window.IPWhitelistManager.validateWhitelistEntry(input, showNotifications); + } + console.error('validateWhitelistEntry called but IPWhitelistManager not available'); +}; + +// Standalone functions - immediately available +window.saveWhitelistEntries = function() { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.saveWhitelistEntries) { + return window.IPWhitelistManager.saveWhitelistEntries(); + } + console.error('saveWhitelistEntries called but IPWhitelistManager not available'); +}; + +// IP Whitelist Manager initialization is now handled by SystemLoader +// IPWhitelistManager instance will be created centrally diff --git a/containers/libreportal/frontend/js/components/config/toggle-manager.js b/containers/libreportal/frontend/js/components/config/toggle-manager.js new file mode 100755 index 0000000..3d508f0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/toggle-manager.js @@ -0,0 +1,319 @@ +// Universal Toggle Manager - Complete solution for all config toggles +class ToggleManager { + constructor() { + this.toggles = new Map(); + this.init(); + } + + init() { + //console.log('ToggleManager: Initializing...'); + + // Auto-discover all toggle configurations + this.discoverToggles(); + //console.log('ToggleManager: Discovered', this.toggles.size, 'toggle configurations'); + + // Debug: Log discovered toggles + if (this.toggles.size > 0) { + //console.log('ToggleManager: No toggles found - will retry after config loads...'); + // Set up a mutation observer to detect when config content is added + this.setupContentObserver(); + } else { + //console.log('ToggleManager: Discovered toggles:', Array.from(this.toggles.keys())); + } + + // Also set up observer in case more toggles are added later + this.setupContentObserver(); + + // Retry discovery after a short delay to handle timing issues + setTimeout(() => { + //console.log('ToggleManager: Retrying toggle discovery...'); + this.rediscoverToggles(); + }, 100); + } + + // Set up observer to detect when config content is loaded + setupContentObserver() { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + // Check if any new elements with data-toggle-config were added + const newToggles = Array.from(mutation.addedNodes).filter(node => node.nodeType === Node.ELEMENT_NODE && (node.dataset?.toggleConfig || node.querySelector('[data-toggle-config]'))); + + if (newToggles.length > 0) { + //console.log('ToggleManager: New toggle elements detected, re-discovering...'); + this.rediscoverToggles(); + observer.disconnect(); // Stop observing once we find toggles + } + } + }); + }); + + // Start observing the main content area + const contentContainer = document.getElementById('main-content') || document.querySelector('.main'); + if (contentContainer) { + observer.observe(contentContainer, { + childList: true, + subtree: true + }); + } + } + + // Re-discover toggles (called after content is loaded) + rediscoverToggles() { + //console.log('ToggleManager: Re-discovering toggles...'); + this.toggles.clear(); // Clear existing toggles + this.discoverToggles(); + //console.log('ToggleManager: Re-discovered', this.toggles.size, 'toggle configurations'); + + if (this.toggles.size > 0) { + //console.log('ToggleManager: Successfully discovered toggles:', Array.from(this.toggles.keys())); + } + } + + // Auto-discover toggle configurations from the page + discoverToggles() { + //console.log('ToggleManager: Looking for elements with [data-toggle-config]...'); + + // Find all elements with data-toggle-config attribute + const toggleElements = document.querySelectorAll('[data-toggle-config]'); + //console.log('ToggleManager: Found', toggleElements.length, 'elements with data-toggle-config'); + + toggleElements.forEach((element, index) => { + const config = element.dataset.toggleConfig; + const sectionId = element.dataset.sectionId; + const toggleType = element.dataset.toggleType || 'checkbox'; + + //console.log(`ToggleManager: Processing element ${index}:`, { + //config: config, + //sectionId: sectionId, + //toggleType: toggleType, + //element: element.tagName + (element.id ? '#' + element.id : '') + (element.name ? '[name=' + element.name + ']' : '') + //}); + + if (config && sectionId) { + this.toggles.set(config, { + config: config, + sectionId: sectionId, + toggleType: toggleType, + element: element + }); + //console.log(`ToggleManager: Registered toggle for config: ${config}`); + } else { + //console.log(`ToggleManager: Skipping element - missing config or sectionId`); + } + }); + } + + // Universal toggle function - works for any config option + toggle(configKey, isEnabled) { + //console.log('=== TOGGLE MANAGER DEBUG ==='); + //console.log('configKey:', configKey); + //console.log('isEnabled:', isEnabled); + + const toggle = this.toggles.get(configKey); + if (!toggle) { + console.error('ToggleManager: No toggle found for config:', configKey); + return false; + } + + //console.log('Toggle found:', toggle); + + const sectionContent = document.getElementById(toggle.sectionId); + const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + //console.log('sectionContent found:', !!sectionContent); + //console.log('fields found:', fields ? fields.length : 0); + + if (sectionContent && fields) { + if (isEnabled) { + //console.log('Enabling section...'); + sectionContent.classList.remove('hidden'); + fields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling section...'); + sectionContent.classList.add('hidden'); + fields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + + //console.log('=== TOGGLE MANAGER SUCCESS ==='); + return true; + } else { + console.error('ToggleManager: Section content or fields not found'); + return false; + } + } + + // Universal toggle section renderer - works for ANY config option + static renderToggleSection(configKey, configItems, displaySubcategory, subcategoryDescription, config) { + const isEnabled = configKey.value === 'true' || configKey.value === 'git'; + const sectionId = `${configKey.key.replace(/[^a-zA-Z0-9]/g, '-')}-${configKey.key}`; + const toggleId = `${configKey.key.toLowerCase()}-toggle`; + + // Determine toggle type and class based on config key + let toggleType = 'checkbox'; + let toggleClass = 'generic-master-toggle'; + + if (configKey.key.includes('INSTALL_MODE')) { + toggleType = 'select'; + toggleClass = 'git-master-toggle'; + } else if (configKey.key.includes('MAIL')) { + toggleType = 'checkbox'; + toggleClass = 'mail-master-toggle'; + } else if (configKey.key.includes('BACKUP_REMOTE_')) { + // Specifically target BACKUP_REMOTE_1_ENABLED, BACKUP_REMOTE_2_ENABLED, etc. + toggleType = 'checkbox'; + toggleClass = 'backup-remote-toggle'; + } + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + `; + + if (toggleType === 'select') { + // Git/Select toggle + html += ` + + + `; + } else { + // Checkbox toggle + html += ` + + `; + } + + html += ` +
    +
    +
    +
    + `; + + // Add all other fields (excluding the toggle key itself) + configItems.filter(item => item.key !== configKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + // Add special buttons for specific config types + if (configKey.key.includes('MAIL')) { + html += ` +
    + + +
    + `; + } + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Register a new toggle configuration + register(configKey, sectionId, toggleType = 'checkbox') { + this.toggles.set(configKey, { + config: configKey, + sectionId: sectionId, + toggleType: toggleType + }); + } + + // Get all registered toggles + getToggles() { + return Array.from(this.toggles.keys()); + } + + // Check if a toggle exists + hasToggle(configKey) { + return this.toggles.has(configKey); + } +} + +// Global instance +window.toggleManager = new ToggleManager(); +// Make static method available on instance too +window.toggleManager.renderToggleSection = ToggleManager.renderToggleSection; + +// Add method to manually trigger discovery when config is loaded +window.toggleManager.forceRediscover = function() { + //console.log('ToggleManager: Force re-discovering toggles...'); + window.toggleManager.rediscoverToggles(); +}; + +// Static access for backward compatibility +window.ConfigShared = window.ConfigShared || {}; +window.ConfigShared.toggleSection = function(sectionId, isEnabled) { + // Try to find the config key from the section + const toggleElements = document.querySelectorAll(`[data-section-id="${sectionId}"]`); + if (toggleElements.length > 0) { + const configKey = toggleElements[0].dataset.toggleConfig; + return window.toggleManager.toggle(configKey, isEnabled); + } else { + console.error('ConfigShared.toggleSection: No toggle found for section:', sectionId); + return false; + } +}; diff --git a/containers/libreportal/frontend/js/components/confirmation-dialog.js b/containers/libreportal/frontend/js/components/confirmation-dialog.js new file mode 100755 index 0000000..ff0a76e --- /dev/null +++ b/containers/libreportal/frontend/js/components/confirmation-dialog.js @@ -0,0 +1,167 @@ +/** + * Confirmation Dialog - Simple and Working + */ + +class ConfirmationDialog { + constructor() { + this.overlay = null; + this.dialog = null; + this.callback = null; + this.init(); + } + + init() { + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.className = 'confirmation-overlay'; + + // Create dialog as child of overlay + this.dialog = document.createElement('div'); + this.dialog.className = 'confirmation-dialog'; + + // Add dialog INSIDE overlay + this.overlay.appendChild(this.dialog); + + // Add overlay to body + document.body.appendChild(this.overlay); + + // Event listeners + this.overlay.addEventListener('click', () => this.hide()); + this.dialog.addEventListener('click', (e) => e.stopPropagation()); + + //console.log('Confirmation dialog initialized'); + } + + show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false) { + //console.log('Showing confirmation dialog'); + + this.callback = onConfirm; + + // Build dialog content + this.dialog.innerHTML = ` +
    +

    ${this.escapeHtml(title)}

    + +
    +
    +
    +
    ⚠️
    +
    ${this.escapeHtml(message)}
    +
    + ${showDataLossCheckbox ? ` +
    + +
    + ` : ''} +
    + + `; + + // Show dialog + this.overlay.classList.add('active'); + + // Debug: Check if class was added + //console.log('Active class added:', this.overlay.className); + //console.log('Has active class:', this.overlay.classList.contains('active')); + + // Handle checkbox if present + if (showDataLossCheckbox) { + const checkbox = document.getElementById('dataLossCheckbox'); + const confirmBtn = document.getElementById('confirmBtn'); + // Initial state + this.updateConfirmButton(checkbox.checked); + } + + // Escape key + const handleEscape = (e) => { + if (e.key === 'Escape') { + this.hide(); + document.removeEventListener('keydown', handleEscape); + } + }; + document.addEventListener('keydown', handleEscape); + + //console.log('Confirmation dialog shown'); + } + + updateConfirmButton(isChecked) { + const confirmBtn = document.getElementById('confirmBtn'); + if (confirmBtn) { + if (isChecked) { + confirmBtn.classList.add('confirmation-btn-ticked'); + confirmBtn.disabled = false; + } else { + confirmBtn.classList.remove('confirmation-btn-ticked'); + confirmBtn.disabled = true; + } + } + } + + confirm(showDataLossCheckbox) { + if (showDataLossCheckbox) { + const checkbox = document.getElementById('dataLossCheckbox'); + if (!checkbox.checked) { + return; + } + } + + //console.log('Confirmation dialog confirmed'); + if (this.callback) { + this.callback(); + } + this.hide(); + } + + hide() { + //console.log('Hiding confirmation dialog'); + //console.log('Before remove - classes:', this.overlay.className); + + this.overlay.classList.remove('active'); + + //console.log('After remove - classes:', this.overlay.className); + //console.log('Has active class after remove:', this.overlay.classList.contains('active')); + + this.callback = null; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +// Initialize +let confirmationDialog = null; + +// Initialize immediately when script loads +function initConfirmationDialog() { + if (!confirmationDialog) { + confirmationDialog = new ConfirmationDialog(); + window.confirmationDialog = confirmationDialog; + } +} + +// Confirmation dialog initialization is now handled by SystemLoader +// initConfirmationDialog() will be called centrally + +// Global function +window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox) => { + // Ensure dialog is initialized + initConfirmationDialog(); + + if (confirmationDialog) { + confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox); + } else { + // Fallback to native confirm + if (confirm(message)) { + onConfirm(); + } + } +}; diff --git a/containers/libreportal/frontend/js/components/dashboard.js b/containers/libreportal/frontend/js/components/dashboard.js new file mode 100755 index 0000000..d358d02 --- /dev/null +++ b/containers/libreportal/frontend/js/components/dashboard.js @@ -0,0 +1,128 @@ +// Dashboard functionality +// loadSystemInfo() and updateDiskChart() live in data-loader.js — that version +// uses waitForDashboardElements() so it doesn't fire before the dashboard HTML +// is in the DOM. Defining them here too overrode the safer version and produced +// the spurious "Disk chart elements not found" errors when called from non-dashboard pages. + +// Load installed apps and render icon grid on dashboard +async function loadInstalledApps() { + if (!window.apps || window.apps.length === 0) { + try { + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + const data = await response.json(); + window.apps = data.apps || []; + } catch (e) { + return; + } + } + renderInstalledApps(); +} + +function renderInstalledApps() { + const section = document.getElementById('frontpage-apps-section'); + const container = document.getElementById('frontpage-apps-container'); + if (!section || !container) return; + + const installed = (window.apps || []).filter(a => a.installed); + if (installed.length === 0) return; + + container.innerHTML = installed.map(app => createInstalledAppCard(app)).join(''); + section.style.display = ''; + + populateDashboardServiceButtons(installed); +} + +function createInstalledAppCard(app) { + const appName = app.command.split(' ').pop(); + let icon = app.icon || 'icons/apps/default.svg'; + if (!icon.startsWith('/')) icon = '/' + icon; + const shortName = app.name.split(' - ')[0].trim(); + + return ` +
    +
    + ${shortName} +
    +
    + ${shortName} +
    + `; +} + +async function populateDashboardServiceButtons(installedApps) { + let services = []; + + if (window.serviceButtons) { + if (window.serviceButtons.services.length === 0) await window.serviceButtons.loadServices(); + services = window.serviceButtons.services; + } else { + try { + const res = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + const data = await res.json(); + services = data.services || []; + } catch (e) { + return; + } + } + + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + + installedApps.forEach(app => { + const appName = app.command.split(' ').pop(); + const shortName = app.name.split(' - ')[0].trim(); + const overlay = document.getElementById(`frontpage-overlay-${appName}`); + if (!overlay) return; + + const appServices = services.filter(s => s.app === appName && s.buttonEnabled === true); + + // Multi-button render via the shared expandServiceLinks() helper. + const serviceButtons = appServices.flatMap(s => + window.expandServiceLinks(s).map(({ url, label }) => ` + + + + + + + ${label} + + `) + ).filter(Boolean).join(''); + + overlay.innerHTML = serviceButtons + ``; + }); +} + +// Setup event listeners +function setupEventListeners() { + setupMobileMenu(); + loadInstalledApps(); +} + +// Navigate to app page using SPA router +function navigateToApp(appName) { + // Use proper SPA navigation to the app page + if (window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + window.librePortalSPA.navigate(`/app?=${appName}`); + } else if (window.navigateToRoute && typeof window.navigateToRoute === 'function') { + window.navigateToRoute(`app?=${appName}`); + } else { + // Fallback to direct navigation + window.location.href = `/app?=${appName}`; + } +} + +// Filter apps by search term (removed - not used in dashboard) +function filterApps(searchTerm) { + //console.log('Filter apps functionality removed from dashboard'); +} + +// Filter apps by category (removed - not used in dashboard) +function filterAppsByCategory(category) { + //console.log('Filter apps by category functionality removed from dashboard'); +} + +// Populate category filter (removed - not used in dashboard) +function populateCategoryFilter() { + //console.log('Category filter population removed from dashboard'); +} diff --git a/containers/libreportal/frontend/js/components/eo-modal.js b/containers/libreportal/frontend/js/components/eo-modal.js new file mode 100644 index 0000000..acf4379 --- /dev/null +++ b/containers/libreportal/frontend/js/components/eo-modal.js @@ -0,0 +1,222 @@ +// eo-modal — unified modal helper. CSS in modal.css under ".eo-modal". +// +// API: +// const m = openEoModal({ +// id, size, className, // 'sm' | 'md' (default) | 'lg' +// icon, iconAlt, eyebrow, title, desc, // header +// body, // string | HTMLElement | array of either +// actions, // [{label, variant, onClick(modal)}] +// closeOnBackdrop = true, +// onClose, +// }); +// m.close(); // remove from DOM, fires onClose +// m.bodyEl; // the .eo-modal-body element (mutate as needed) +// m.contentEl; // the .eo-modal-content element +// m.el; // the backdrop .eo-modal element +// +// Section primitives (return HTML strings; pass as part of body): +// eoSection(title, content) +// eoBadgeRow([{icon, label, variant}]) variant: success|info|purple|warning|danger +// eoUrlList([{url, label}]) +// eoCredList([{title, username, password}]) +// eoEmpty(text) + +(function () { + function escHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + } + function escAttr(s) { + return escHtml(s).replace(/"/g, '"').replace(/'/g, '''); + } + function toBodyNode(input) { + if (input == null) return document.createTextNode(''); + if (typeof input === 'string') { + const t = document.createElement('div'); + t.style.display = 'contents'; + t.innerHTML = input; + return t; + } + if (Array.isArray(input)) { + const f = document.createDocumentFragment(); + input.forEach((p) => f.appendChild(toBodyNode(p))); + return f; + } + if (input instanceof HTMLElement || input instanceof DocumentFragment) return input; + return document.createTextNode(String(input)); + } + + window.openEoModal = function openEoModal(opts) { + opts = opts || {}; + const id = opts.id || `eo-modal-${Math.random().toString(36).slice(2, 9)}`; + const size = opts.size || 'md'; + + const existing = document.getElementById(id); + if (existing) existing.remove(); + + const root = document.createElement('div'); + root.className = `eo-modal ${opts.className || ''}`.trim(); + root.id = id; + root.dataset.size = size; + + const headerHasIcon = !!opts.icon; + const titleHtml = ` +
    + ${opts.eyebrow ? `
    ${escHtml(opts.eyebrow)}
    ` : ''} + ${opts.title ? `

    ${escHtml(opts.title)}

    ` : ''} + ${opts.desc ? `

    ${escHtml(opts.desc)}

    ` : ''} +
    `; + + const endIconHtml = opts.endIcon + ? `` + : ''; + + root.innerHTML = ` +
    + ${(opts.icon || opts.title || opts.eyebrow) ? ` +
    +
    + ${headerHasIcon ? `${escAttr(opts.iconAlt || '')}` : ''} + ${titleHtml} +
    + ${endIconHtml} + +
    ` : ''} +
    + ${(opts.actions && opts.actions.length) ? '' : ''} +
    `; + + const contentEl = root.querySelector('.eo-modal-content'); + const bodyEl = root.querySelector('.eo-modal-body'); + const footerEl = root.querySelector('.eo-modal-footer'); + const closeBtn = root.querySelector('.eo-modal-close'); + + bodyEl.appendChild(toBodyNode(opts.body)); + + const m = { + el: root, + contentEl, + bodyEl, + close: () => { + root.remove(); + if (typeof opts.onClose === 'function') { + try { opts.onClose(); } catch (e) { console.error(e); } + } + } + }; + + if (closeBtn) closeBtn.addEventListener('click', m.close); + if (opts.closeOnBackdrop !== false) { + root.addEventListener('click', (e) => { if (e.target === root) m.close(); }); + } + + if (footerEl && Array.isArray(opts.actions)) { + opts.actions.forEach((a) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `btn btn-${a.variant || 'secondary'}`; + btn.textContent = a.label || 'OK'; + btn.addEventListener('click', () => { + if (typeof a.onClick === 'function') a.onClick(m); + else m.close(); + }); + footerEl.appendChild(btn); + }); + } + + document.body.appendChild(root); + return m; + }; + + // ----- Section primitives ----- + + window.eoSection = function (title, content) { + return `
    + ${title ? `
    ${escHtml(title)}
    ` : ''} + ${content || ''} +
    `; + }; + + window.eoBadgeRow = function (badges) { + if (!Array.isArray(badges) || badges.length === 0) return ''; + const html = badges.map((b) => { + const variant = b.variant ? ` ${b.variant}` : ''; + return `${b.icon ? escHtml(b.icon) + ' ' : ''}${escHtml(b.label)}`; + }).join(''); + return `
    ${html}
    `; + }; + + window.eoUrlList = function (urls) { + if (!Array.isArray(urls) || urls.length === 0) return ''; + const arrow = ``; + const rows = urls.map((u) => ` + + ${escHtml(u.label)} + ${escHtml(u.url)} + ${arrow} + `).join(''); + return `
    ${rows}
    `; + }; + + window.eoCredList = function (creds) { + if (!Array.isArray(creds) || creds.length === 0) return ''; + const copyBtn = (val) => ``; + return creds.map((c) => { + const userLabel = c.userLabel + || (typeof c.username === 'string' && c.username.includes('@') ? 'Email' : 'User'); + const passLabel = c.passLabel || 'Pass'; + return ` +
    + ${c.title ? `
    ${escHtml(c.title)}
    ` : ''} + ${c.username != null ? `
    + ${escHtml(userLabel)} + ${escHtml(c.username)} + ${copyBtn(c.username)} +
    ` : ''} + ${c.password != null ? `
    + ${escHtml(passLabel)} + •••••••• + + ${copyBtn(c.password)} +
    ` : ''} +
    `; + }).join(''); + }; + + window.eoEmpty = function (text) { + return `

    ${escHtml(text || '')}

    `; + }; + + document.addEventListener('click', (e) => { + const btn = e.target.closest && e.target.closest('.eo-modal-cred-toggle'); + if (!btn) return; + const code = btn.parentElement && btn.parentElement.querySelector('.eo-cred-pass'); + if (!code) return; + const revealed = code.dataset.revealed === 'true'; + code.textContent = revealed ? '••••••••' : code.dataset.value; + code.dataset.revealed = revealed ? 'false' : 'true'; + btn.textContent = revealed ? 'Show' : 'Hide'; + }); + + // Copy-to-clipboard for cred rows. Briefly swaps the icon for a check. + document.addEventListener('click', (e) => { + const btn = e.target.closest && e.target.closest('.eo-modal-cred-copy'); + if (!btn) return; + const value = btn.dataset.copy || ''; + const done = () => { + btn.classList.add('copied'); + const original = btn.innerHTML; + btn.innerHTML = ``; + setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = original; }, 1100); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).then(done).catch(() => done()); + } else { + const ta = document.createElement('textarea'); + ta.value = value; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); } catch (_) {} + document.body.removeChild(ta); done(); + } + }); +})(); diff --git a/containers/libreportal/frontend/js/components/mobile-menu.js b/containers/libreportal/frontend/js/components/mobile-menu.js new file mode 100755 index 0000000..a491890 --- /dev/null +++ b/containers/libreportal/frontend/js/components/mobile-menu.js @@ -0,0 +1,70 @@ +// Mobile Menu Handler — wires the topbar burger to the slide-in drawer +// (#mobile-drawer). On pages that ship a page sidebar (#sidebar), the +// drawer borrows its contents while open so the user gets one unified +// nav surface on mobile. +function setupMobileMenu() { + const toggle = document.getElementById('mobile-menu-toggle'); + const drawer = document.getElementById('mobile-drawer'); + const overlay = document.getElementById('mobile-overlay'); + + if (!toggle || !drawer || !overlay) { + setTimeout(setupMobileMenu, 100); + return; + } + + const pageSection = document.getElementById('mobile-drawer-page-section'); + let borrowedNodes = []; + let sidebarOrigin = null; + + function borrowSidebar() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar || !pageSection) return; + sidebarOrigin = sidebar; + borrowedNodes = Array.from(sidebar.children); + borrowedNodes.forEach((node) => pageSection.appendChild(node)); + } + + function returnSidebar() { + if (!sidebarOrigin || borrowedNodes.length === 0) return; + borrowedNodes.forEach((node) => sidebarOrigin.appendChild(node)); + borrowedNodes = []; + sidebarOrigin = null; + } + + function openMenu() { + borrowSidebar(); + drawer.classList.add('mobile-open'); + overlay.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + + function closeMenu() { + drawer.classList.remove('mobile-open'); + overlay.classList.remove('active'); + document.body.style.overflow = ''; + returnSidebar(); + } + + function toggleMenu() { + if (drawer.classList.contains('mobile-open')) closeMenu(); + else openMenu(); + } + + toggle.addEventListener('click', toggleMenu); + overlay.addEventListener('click', closeMenu); + + // Close when any nav-item, sidebar-item, or category gets clicked + // (they trigger navigation, so the drawer should dismiss itself). + drawer.addEventListener('click', (e) => { + const dismisser = e.target.closest('.nav-item, .sidebar-item, .category'); + if (dismisser) closeMenu(); + }); + + window.addEventListener('resize', () => { + if (window.innerWidth > 768 && drawer.classList.contains('mobile-open')) { + closeMenu(); + } + }); + + window.closeMobileMenu = closeMenu; +} diff --git a/containers/libreportal/frontend/js/components/notifications.js b/containers/libreportal/frontend/js/components/notifications.js new file mode 100755 index 0000000..d3714ff --- /dev/null +++ b/containers/libreportal/frontend/js/components/notifications.js @@ -0,0 +1,448 @@ +/** + * Enhanced Notification System for LibrePortal + * Provides consistent, modular notifications with app icons and proper layout + */ +class NotificationSystem { + constructor() { + this.container = null; + this.init(); + } + + init() { + // Create notification container + this.container = document.createElement('div'); + this.container.className = 'notification-container'; + document.body.appendChild(this.container); + + // Restore any pending notifications from localStorage + this.restoreNotifications(); + } + + /** + * Show a notification with consistent layout + * Layout: [Type Icon] [App Icon] [Message] [Action Button] [Close] + * + * `customIcon` is an optional override for the leftmost icon slot — when + * supplied, the notification renders that string/HTML there instead of + * the level-derived SVG (success tick / error cross / warning triangle). + * Used by task notifications so the icon reflects the task *type* + * (install ✅, backup 💾, restore 📦, …) rather than just success/fail. + */ + show(message, type = 'info', appName = null, appUrl = null, appIcon = null, customIcon = null) { + const notification = this.createNotificationElement(type, message, appName, appUrl, appIcon, customIcon); + this.addNotificationToContainer(notification); + this.saveNotificationToStorage({ message, type, appName, appUrl, appIcon, customIcon }); + this.setupAutoRemove(notification); + return notification; + } + + /** + * Create notification element with consistent structure + */ + createNotificationElement(type, message, appName, appUrl, appIcon, customIcon = null) { + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + + // Add data attributes for dynamic sizing + if (appIcon) { + notification.setAttribute('data-has-app', 'true'); + } + if (appName && appUrl) { + notification.setAttribute('data-has-action', 'true'); + } + + const typeIcon = customIcon != null && customIcon !== '' ? customIcon : this.getIcon(type); + const content = this.buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type); + + notification.innerHTML = content; + + // Attach event listeners dynamically for action button + if (appName && appUrl) { + const actionBtn = notification.querySelector('.notification-action-btn'); + if (actionBtn) { + actionBtn.addEventListener('click', (e) => { + console.log('🔗 Notification action button clicked for URL:', appUrl); + e.preventDefault(); + e.stopPropagation(); + if (window.handleNotificationNavigation) { + window.handleNotificationNavigation(appUrl); + } else { + console.error('❌ handleNotificationNavigation not available'); + } + }); + // Remove any other click handlers that might interfere + actionBtn.style.cursor = 'pointer'; + } else { + console.warn('⚠️ Action button not found in notification'); + } + } + + // Attach event listener for close button + const closeBtn = notification.querySelector('.notification-close'); + if (closeBtn) { + closeBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + notification.remove(); + }); + } + + return notification; + } + + /** + * Build notification HTML content + */ + buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type) { + let content = '
    '; + + // Type icon (always first) + content += `
    ${typeIcon}
    `; + + // App icon (if provided) + if (appIcon) { + content += ` +
    + ${appName} +
    + `; + } + + // Message section + content += `
    ${message}
    `; + + // Action button (context-aware) + if (appName && appUrl) { + let buttonText = 'Manage'; + if (appUrl.includes('task=')) { + buttonText = 'View Task'; + } else if (type === 'success' && message.includes('install')) { + buttonText = 'Configure'; + } else if (type === 'info' || type === 'warning') { + buttonText = 'View Task'; + } + + content += ` + + `; + } + + // Close button (always last) + content += ` + +
    `; + + return content; + } + + /** + * Get icon based on notification type + */ + getIcon(type) { + const icons = { + success: '', + error: '', + warning: '', + info: '', + uninstall: '' + }; + return icons[type] || icons.info; + } + + /** + * Add notification to container with animation + */ + addNotificationToContainer(notification) { + this.container.appendChild(notification); + + // Trigger animation + setTimeout(() => { + notification.classList.add('notification-show'); + }, 10); + } + + /** + * Setup auto-remove after 10 seconds + */ + setupAutoRemove(notification) { + setTimeout(() => { + if (notification.parentElement) { + notification.classList.add('notification-hide'); + setTimeout(() => { + if (notification.parentElement) { + notification.remove(); + } + }, 300); + } + }, 10000); + } + + /** + * Save notification to localStorage for cross-page persistence + */ + saveNotificationToStorage(notificationData) { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + notificationData.id = Date.now().toString(); + notificationData.timestamp = Date.now(); + notifications.push(notificationData); + + // Keep only last 5 notifications to avoid clutter + if (notifications.length > 5) { + notifications.shift(); + } + + localStorage.setItem('libreportal_notifications', JSON.stringify(notifications)); + } catch (error) { + console.error('Error saving notification to localStorage:', error); + } + } + + /** + * Restore notifications from localStorage on page load + */ + restoreNotifications() { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + const now = Date.now(); + + notifications.forEach(notificationData => { + const age = now - notificationData.timestamp; + const remainingTime = 10000 - age; // 10 seconds + + if (remainingTime > 0) { + const notification = this.createNotificationElement( + notificationData.type, + notificationData.message, + notificationData.appName, + notificationData.appUrl, + notificationData.appIcon, + notificationData.customIcon + ); + + this.addNotificationToContainer(notification); + this.setupAutoRemove(notification, remainingTime); + } + }); + } catch (error) { + console.error('Error restoring notifications:', error); + } + } + + /** + * Remove notification from localStorage + */ + removeNotification(notificationId) { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + const filteredNotifications = notifications.filter(n => n.id !== notificationId); + localStorage.setItem('libreportal_notifications', JSON.stringify(filteredNotifications)); + } catch (error) { + console.error('Error removing notification:', error); + } + } + + /** + * Convenience methods + */ + success(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'success', appName, appUrl, appIcon); + } + + error(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'error', appName, appUrl, appIcon); + } + + warning(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'warning', appName, appUrl, appIcon); + } + + info(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'info', appName, appUrl, appIcon); + } + + /** + * Show install notification with short app name + */ + showInstallNotification(appName, appUrl, appIcon = null) { + const shortName = appName.split(' - ')[0]; + const message = `${shortName} has been installed.`; + return this.show(message, 'success', appName, appUrl, appIcon); + } + + /** + * Show uninstall notification with short app name + */ + showUninstallNotification(appName, appIcon = null) { + const shortName = appName.split(' - ')[0]; + const message = `${shortName} has been uninstalled.`; + return this.show(message, 'uninstall', appName, null, appIcon); + } +} + +// Notification system initialization is now handled by SystemLoader +// NotificationSystem instance will be created centrally + +// Resolve a slug (e.g. "ipinfo") to its proper display name from window.apps +// (e.g. "IPInfo"). Falls back to a capitalized slug if window.apps isn't loaded +// or no match is found. The slug is the trailing token of an app's `command`. +window.getAppDisplayName = function (slug) { + if (!slug) return ''; + const apps = window.apps || []; + const match = apps.find(a => { + const command = a.command || ''; + return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); + }); + if (match && match.name) { + return match.name.split(' - ')[0].trim(); + } + return slug.charAt(0).toUpperCase() + slug.slice(1); +}; + +// Expose helper functions globally +window.ensureNotificationSystem = () => { + if (!window.notificationSystem) { + console.warn('Notification system not initialized, creating new instance'); + window.notificationSystem = new NotificationSystem(); + } + return window.notificationSystem; +}; + +window.removeNotification = (notificationId) => { + if (window.notificationSystem) { + window.notificationSystem.removeNotification(notificationId); + } +}; + +window.handleNotificationNavigation = (url) => { + try { + console.log('🔗 handleNotificationNavigation called with URL:', url); + + // Parse the URL to extract task ID and app name + const urlObj = new URL(url, window.location.origin); + const urlParams = new URLSearchParams(urlObj.search); + + // Extract app name from ?= parameter (the key is empty string, = is the separator) + const appName = urlParams.get('') || urlParams.get('='); + const taskId = urlParams.get('task'); + const tab = urlParams.get('tab') || 'tasks'; + + console.log('🔗 Parsed URL:', { url, appName, taskId, tab, currentPath: window.location.pathname }); + + // Check if we're on an app page or tasks page + const currentPath = window.location.pathname; + + if (currentPath.includes('/app') && appName) { + console.log('🔗 On app page, appTabbedManager available:', !!window.appTabbedManager); + console.log('🔗 Current app:', window.appTabbedManager?.currentApp, 'Target app:', appName); + + // We're on an app page - navigate to the specified app and tab + if (window.appTabbedManager) { + // Update the URL to the target app/tab/task + const newUrl = `/app?=${appName}&tab=${tab}&task=${taskId}`; + console.log('🔗 Pushing state to URL:', newUrl); + window.history.pushState({}, '', newUrl); + + // If already on this app, just switch tab and highlight task + if (window.appTabbedManager.currentApp === appName) { + console.log('🔗 Same app, switching to tab:', tab); + window.appTabbedManager.switchTab(tab); + if (tab === 'tasks' && taskId) { + // Wait for tasks to load and render, then open the task details + setTimeout(() => { + console.log('🔗 Opening task details for:', taskId); + if (typeof window.toggleAppTaskDetails === 'function') { + window.toggleAppTaskDetails(taskId); + // Scroll to task after opening details + if (window.appTabbedManager && window.appTabbedManager.scrollToTask) { + window.appTabbedManager.scrollToTask(taskId); + } + } + }, 800); + } + console.log('🔗 Navigation completed successfully'); + } else { + // Different app - need to load the full app detail + console.log('🔗 Different app, loading app detail for:', appName); + window.appTabbedManager.showAppDetail(appName); + // Schedule the tab switch and task highlight after app loads + setTimeout(() => { + if (window.appTabbedManager) { + console.log('🔗 Switching to tab:', tab); + window.appTabbedManager.switchTab(tab); + if (tab === 'tasks' && taskId) { + // Wait for tasks to load and render, then open the task details + setTimeout(() => { + console.log('🔗 Opening task details for:', taskId); + if (typeof window.toggleAppTaskDetails === 'function') { + window.toggleAppTaskDetails(taskId); + // Scroll to task after opening details + if (window.appTabbedManager && window.appTabbedManager.scrollToTask) { + window.appTabbedManager.scrollToTask(taskId); + } + } + }, 800); + } + } + }, 500); + console.log('🔗 Navigation to different app started'); + } + return true; + } else { + console.warn('⚠️ appTabbedManager not available'); + } + } else if (currentPath.includes('/tasks')) { + // We're on the tasks page, navigate to the specified task + if (taskId) { + console.log('🔗 On tasks page, opening task:', taskId); + window.history.pushState({}, '', `/tasks?=all&task=${taskId}`); + setTimeout(() => { + if (typeof window.toggleTaskDetails === 'function') { + console.log('🔗 Opening task details for:', taskId); + window.toggleTaskDetails(taskId); + } + }, 300); + return true; + } + } else { + // Not on app or tasks page - navigate to the app's tasks tab + if (appName && tab) { + window.history.pushState({}, '', `/app?=${appName}&tab=${tab}&task=${taskId}`); + // Let the SPA handle the navigation + if (window.appTabbedManager) { + window.appTabbedManager.showAppDetail(appName); + setTimeout(() => { + if (window.appTabbedManager) { + window.appTabbedManager.switchTab(tab); + if (window.tasksManager && taskId) { + window.tasksManager.highlightedTaskId = taskId; + if (window.tasksManager.loadTaskLogs) { + window.tasksManager.loadTaskLogs(taskId); + } + } + } + }, 500); + return true; + } + } + } + + // If we get here and no managers were available, fallback + console.warn('⚠️ Falling back to page reload for URL:', url); + window.location.href = url; + return false; + } catch (error) { + console.error('❌ Error handling notification navigation:', error); + // Fallback to direct navigation if parsing fails + console.warn('⚠️ Falling back to page reload due to error for URL:', url); + window.location.href = url; + return false; + } +}; diff --git a/containers/libreportal/frontend/js/components/task/task-actions.js b/containers/libreportal/frontend/js/components/task/task-actions.js new file mode 100755 index 0000000..3720bb0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-actions.js @@ -0,0 +1,407 @@ +/** + * Task Actions - Action implementations for individual task operations + * Handles app installations, management operations, etc. + */ + +class TaskActions { + constructor(tasksManager, commands) { + this.tasksManager = tasksManager; + this.commands = commands; + } + + /** + * Install an application + */ + async installApp(appName, config = '', resetNetwork = false) { + try { + this.commands.validateCommand('install', { appName }); + + if (resetNetwork) { + const parts = ['libreportal', 'app', 'install', appName]; + if (config) parts.push(`'${config.replace(/'/g, "'\\''")}'`); + parts.push('--reset-network'); + return await this.executeTask('install', appName, parts.join(' ')); + } + return await this.executeTask('install', appName, config); + } catch (error) { + throw new Error(`Failed to install ${appName}: ${error.message}`); + } + } + + /** + * Uninstall an application + */ + async uninstallApp(appName, deleteImage = false, deleteTasks = false) { + try { + this.commands.validateCommand('uninstall', { appName }); + // Build a verbatim command when the user opted into any flag so the + // --delete-images / --delete-tasks switches survive executeTask's + // standard command-builder (which would otherwise quote them as config args). + const flags = []; + if (deleteImage) flags.push('--delete-images'); + if (deleteTasks) flags.push('--delete-tasks'); + if (flags.length) { + const command = `libreportal app uninstall ${appName} ${flags.join(' ')}`; + return await this.executeTask('uninstall', appName, command); + } + return await this.executeTask('uninstall', appName); + } catch (error) { + throw new Error(`Failed to uninstall ${appName}: ${error.message}`); + } + } + + /** + * Restart an application + */ + async restartApp(appName) { + try { + this.commands.validateCommand('restart', { appName }); + return await this.executeTask('restart', appName); + } catch (error) { + throw new Error(`Failed to restart ${appName}: ${error.message}`); + } + } + + /** + * Start an application + */ + async startApp(appName) { + try { + this.commands.validateCommand('start', { appName }); + return await this.executeTask('start', appName); + } catch (error) { + throw new Error(`Failed to start ${appName}: ${error.message}`); + } + } + + /** + * Stop an application + */ + async stopApp(appName) { + try { + this.commands.validateCommand('stop', { appName }); + return await this.executeTask('stop', appName); + } catch (error) { + throw new Error(`Failed to stop ${appName}: ${error.message}`); + } + } + + /** + * Create backup for an application + */ + async backupApp(appName, customPassword = '') { + try { + this.commands.validateCommand('backup', { appName }); + return await this.executeTask('backup', appName, customPassword); + } catch (error) { + throw new Error(`Failed to backup ${appName}: ${error.message}`); + } + } + + async restoreApp(appName, location, backupFile, password) { + try { + if (!appName) throw new Error('appName is required'); + if (!backupFile) throw new Error('backupFile is required'); + + const parts = ['libreportal', 'app', 'restore', appName]; + if (location) parts.push(location); + parts.push(backupFile); + if (password) parts.push(password); + return await this.executeTask('restore', appName, parts.join(' ')); + } catch (error) { + throw new Error(`Failed to restore ${appName}: ${error.message}`); + } + } + + /** + * Delete backup file for an application + */ + async deleteBackup(appName, backupFile, deleteRemote1 = 'false', deleteRemote2 = 'false') { + try { + // Build the command with all parameters + const command = `libreportal backup app delete ${appName} ${backupFile} ${deleteRemote1} ${deleteRemote2}`; + + // Create task directly with the full command + // createTask will handle monitoring + const task = await this.tasksManager.taskManager.createTask(command, 'delete', appName, backupFile); + + // Emit taskCreated event for AppTabbedManager to track the task + // This is needed for tab re-enabling on task completion + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: 'delete', + timestamp: Date.now() + } + })); + + return task; + } catch (error) { + throw new Error(`Failed to delete backup ${backupFile}: ${error.message}`); + } + } + + /** + * Delete all backup files for an application + */ + async deleteAllBackups(appName, deleteRemote1 = 'false', deleteRemote2 = 'false') { + try { + // Build the command with all parameters + const command = `libreportal backup app delete_all ${appName} ${deleteRemote1} ${deleteRemote2}`; + + // Create task directly with the full command + // createTask will handle monitoring + const task = await this.tasksManager.taskManager.createTask(command, 'delete_all', appName, 'all'); + + // Emit taskCreated event for AppTabbedManager to track the task + // This is needed for tab re-enabling on task completion + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: 'delete_all', + timestamp: Date.now() + } + })); + + return task; + } catch (error) { + throw new Error(`Failed to delete all backups for ${appName}: ${error.message}`); + } + } + + /** + * Update application configuration + */ + async updateConfig(appName) { + try { + this.commands.validateCommand('update_config', { appName }); + + await this.executeTask('update_config', appName); + return; + } catch (error) { + throw new Error(`Failed to update config for ${appName}: ${error.message}`); + } + } + +async configUpdate(changes) { + try { + if (!changes) throw new Error('No changes to save'); + const command = `libreportal config update ${changes}`; + return await this.executeTask('config_update', 'system', command); + } catch (error) { + throw new Error(`Failed to update configuration: ${error.message}`); + } + } + + /** + * Run a per-app tool from the Tools tab. Builds: + * libreportal app tool '' + * argsString is pipe-encoded: key=value|key=value (matches the install + * config pattern so the bash side can use the same parser). + */ + async runTool(appName, toolName, toolArgs = '', toolLabel = '') { + try { + if (!appName) throw new Error('appName is required'); + if (!toolName) throw new Error('toolName is required'); + + const safeTool = String(toolName).replace(/[^A-Za-z0-9_.-]/g, ''); + if (!safeTool) throw new Error('Invalid tool name'); + + const parts = ['libreportal', 'app', 'tool', appName, safeTool]; + if (toolArgs) { + parts.push(`'${String(toolArgs).replace(/'/g, "'\\''")}'`); + } + return await this.executeTask('tool', appName, parts.join(' '), toolLabel); + } catch (error) { + throw new Error(`Failed to run tool ${toolName} on ${appName}: ${error.message}`); + } + } + + /** + * System operations + */ + async systemUpdate() { + try { + await this.executeTask('system_update', 'system'); + return; + } catch (error) { + throw new Error(`Failed to update system: ${error.message}`); + } + } + + /** + * Create a task object + */ + createTask(type, command, metadata = {}) { + const task = { + id: Date.now().toString(), + command: command, + type: type, + status: 'running', + createdAt: new Date().toISOString(), + output: `Starting ${type} operation...`, + error: null, + ...metadata + }; + + return task; + } + + /** + * Execute a task with enhanced event emission + */ + async executeTask(action, appName, config = '', displayLabel = '') { + try { + // If config is a full `libreportal …` command, use it verbatim. Otherwise + // build the standard form and single-quote the config arg. + let command; + if (config && config.startsWith('libreportal')) { + command = config; + } else { + command = `libreportal app ${action} ${appName}`; + if (config) command += ` '${config.replace(/'/g, "'\\''")}'`; + } + + // Create task — POSTs to /api/tasks and returns the authoritative task. + const task = await this.tasksManager.taskManager.createTask(command, action, appName, config); + + // Hook the UI side-effects (auto-expand, log streaming, status interval). + // The bus also delivers `taskCreated` from the SSE feed; AppTabbedManager + // dedupes by appName|action so the double-fire is harmless. + if (this.tasksManager && typeof this.tasksManager.monitorTask === 'function') { + this.tasksManager.monitorTask(task.id, appName, action); + } + + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // Show success notification + let appData = null; + try { + appData = this.commands.getAppData ? this.commands.getAppData(appName) : null; + } catch (error) { + console.warn('Could not get app data:', error); + } + const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; + + let taskUrl; + const currentUrl = window.location.href; + + if (currentUrl.includes('/app?=') && appName) { + taskUrl = `/app?=${appName}&tab=tasks&task=${task.id}`; + } else { + taskUrl = `/tasks?=all&task=${task.id}`; + } + + if (window.notificationSystem) { + const displayName = window.getAppDisplayName ? window.getAppDisplayName(appName) : appName; + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + const headline = displayLabel + ? `${displayLabel} task started!` + : `${action.charAt(0).toUpperCase() + action.slice(1)} task started!`; + window.notificationSystem.show( + `App: ${displayName}
    ${headline}`, + 'success', + appName, + taskUrl, + appIcon, + customIcon + ); + } + + return task; + } catch (error) { + console.error(`❌ Failed to execute ${action} task for ${appName}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`); + } + throw error; + } + } + + /** + * Execute task monitoring (separated to avoid duplicate task creation) + */ + async executeTaskMonitoring(task, appName, action) { + // Emit task creation event for AppTabbedManager + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // console.log(`✅ Task created: ${task.id} for ${action} on ${appName}`); + + // Set up monitoring for this specific task + this.tasksManager.monitorTask(task.id, appName, action); + + // Try to get app data for better notifications + let appData = null; + try { + appData = this.commands.getAppData ? this.commands.getAppData(appName) : null; + } catch (error) { + console.warn('Could not get app data:', error); + } + const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; + + // Smart URL generation - always include app name + let taskUrl; + const currentUrl = window.location.href; + // console.log('🔍 TaskActions: Current URL:', currentUrl); + + // Always generate URL with app name for proper navigation + if (currentUrl.includes('/app?=') && appName) { + // We're on an app page, maintain app context + taskUrl = `/app?=${appName}&tab=tasks&task=${task.id}`; + } else { + // We're on main tasks page, use normal URL + taskUrl = `/tasks?=all&task=${task.id}`; + } + + // Show success notification with app icon and direct link + if (window.notificationSystem) { + const displayName = window.getAppDisplayName ? window.getAppDisplayName(appName) : appName; + window.notificationSystem.show( + `App: ${displayName}
    + ${action.charAt(0).toUpperCase() + action.slice(1)} task started!`, + 'success', + appName, + taskUrl, + appIcon + ); + } + + return task; + } + + /** + * Update task in the manager + */ + updateTaskInManager(updatedTask) { + const taskIndex = this.tasksManager.tasks.findIndex(t => t.id === updatedTask.id); + if (taskIndex !== -1) { + this.tasksManager.tasks[taskIndex] = updatedTask; + this.tasksManager.renderTasks(); + this.tasksManager.updateStats(); + this.tasksManager.updateSidebarCounts(); + this.tasksManager.generateAppCategories(); + } + } +} + +// Export for use +window.TaskActions = TaskActions; diff --git a/containers/libreportal/frontend/js/components/task/task-commands.js b/containers/libreportal/frontend/js/components/task/task-commands.js new file mode 100755 index 0000000..e33fd0c --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-commands.js @@ -0,0 +1,360 @@ +/** + * Task Commands - Pre-built command templates and execution + * Handles SSH command execution and validation for individual tasks + */ + +class TaskCommands { + constructor() { + this.commandTemplates = { + // App Commands (✅ IMPLEMENTED) + install: 'libreportal app install {appName} {config}', + uninstall: 'libreportal app uninstall {appName}', + restart: 'libreportal app restart {appName}', + start: 'libreportal app start {appName}', + stop: 'libreportal app stop {appName}', + backup: 'libreportal app backup {appName}', + status: 'libreportal app status {appName}', + + // Docker Compose Management (✅ IMPLEMENTED) + up: 'libreportal app up {appName}', + down: 'libreportal app down {appName}', + reload: 'libreportal app reload {appName}', + + // System Commands (✅ IMPLEMENTED) + system_status: 'libreportal system status', + system_update: 'libreportal system update', + system_reset: 'libreportal system reset', + + // Future Commands (❌ NOT YET IMPLEMENTED) + // restore: 'libreportal app restore {appName} {backupId}', + // update_config: 'libreportal config update {appName}', + // system_info: 'libreportal system info', + // system_disk: 'libreportal system disk', + // system_memory: 'libreportal system memory' + }; + + this.commandStatus = { + // ✅ Available in CLI + install: 'implemented', + uninstall: 'implemented', + restart: 'implemented', + start: 'implemented', + stop: 'implemented', + backup: 'implemented', + status: 'implemented', + up: 'implemented', + down: 'implemented', + reload: 'implemented', + system_status: 'implemented', + system_update: 'implemented', + system_reset: 'implemented', + + // ❌ Not yet implemented in CLI + restore: 'not_implemented', + update_config: 'not_implemented', + system_info: 'not_implemented', + system_disk: 'not_implemented', + system_memory: 'not_implemented' + }; + } + + /** + * Generate command from template with parameters + */ + generateCommand(type, params = {}) { + const template = this.commandTemplates[type]; + if (!template) { + throw new Error(`Unknown command type: ${type}`); + } + + let command = template; + Object.keys(params).forEach(key => { + const value = params[key] || ''; + command = command.replace(`{${key}}`, value); + }); + + // Clean up double spaces and trailing spaces + command = command.replace(/\s+/g, ' ').trim(); + + return command; + } + + /** + * Check if command is implemented in CLI + */ + isCommandImplemented(type) { + return this.commandStatus[type] === 'implemented'; + } + + /** + * Get command status + */ + getCommandStatus(type) { + return this.commandStatus[type] || 'unknown'; + } + + /** + * Get only implemented commands + */ + getImplementedCommands() { + return Object.keys(this.commandStatus).filter(cmd => this.commandStatus[cmd] === 'implemented'); + } + + /** + * Get all commands with their status + */ + getAllCommandsWithStatus() { + return Object.keys(this.commandStatus).map(cmd => ({ + command: cmd, + template: this.commandTemplates[cmd], + status: this.commandStatus[cmd] + })); + } + + /** + * Validate command and check if implemented + */ + validateCommand(type, params) { + // Check if command exists + const template = this.commandTemplates[type]; + if (!template) { + throw new Error(`Unknown command type: ${type}`); + } + + // Check if command is implemented + if (!this.isCommandImplemented(type)) { + throw new Error(`Command '${type}' is not yet implemented in the CLI system`); + } + + const requiredParams = { + install: ['appName'], // config is optional + uninstall: ['appName'], + restart: ['appName'], + start: ['appName'], + stop: ['appName'], + backup: ['appName'], + status: ['appName'], + up: ['appName'], + down: ['appName'], + reload: ['appName'] + }; + + const required = requiredParams[type] || []; + const missing = required.filter(param => !params[param]); + + if (missing.length > 0) { + throw new Error(`Missing required parameters: ${missing.join(', ')}`); + } + + return true; + } + + /** + * Execute command via API + */ + async executeCommand(command, taskId) { + try { + //// // console.log(`🚀 Executing command: ${command}`); + + // Add task to local queue using generic endpoint + const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const taskFileName = `${taskId}.json`; + const taskFilePath = `tasks/queue/${taskFileName}`; + + // console.log('🔍 Creating task file:', taskFilePath); + // console.log('🔍 Task ID:', taskId); + + const task = { + id: taskId, + type: 'install', + app: this.extractAppName(command), + command: command, + config: null, + status: 'queued', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + // console.log('🔍 Task object:', task); + + const response = await fetch('/write-file', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + path: taskFilePath, + content: JSON.stringify(task, null, 2) + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error(`❌ API Error Response:`, errorData); + throw new Error(`Command execution failed: ${response.statusText} - ${errorData.error || 'Unknown error'}`); + } + + const result = await response.json(); + //// // console.log(`✅ Task queued successfully:`, result); + + return { + success: true, + output: `Task queued: ${command}`, + taskId: result.taskId, + exitCode: 0, + queued: true + }; + + } catch (error) { + console.error(`❌ Command execution failed:`, error); + throw new Error(`Command execution failed: ${error.message}`); + } + } + + /** + * Extract app name from command + */ + extractAppName(command) { + const match = command.match(/libreportal app (\w+) (.+)/); + return match ? match[2] : 'unknown'; + } + + /** + * Execute command via file-based task system + */ + async executeCommand(command, taskId) { + //// // console.log(`🔧 Creating task for command: ${command}`); + + try { + // Create task file in queue + const task = { + id: taskId, + command: command, + status: 'queued', + created: new Date().toISOString() + }; + + // Extract app name from command for better task tracking + const appNameMatch = command.match(/libreportal app (\w+) (\w+)/); + if (appNameMatch) { + task.app = appNameMatch[2]; + task.type = appNameMatch[1]; + } + + // Write task file to queue + await this.createTaskFile(task); + + //// // console.log(`✅ Task created: ${taskId}`); + + return { + success: true, + taskId: taskId, + message: 'Task queued for execution' + }; + + } catch (error) { + console.error(`❌ Failed to create task:`, error); + throw new Error(`Task creation failed: ${error.message}`); + } + } + + /** + * Create task file in queue directory + */ + async createTaskFile(task) { + const taskFileName = `${task.id}.json`; + const taskFilePath = `tasks/queue/${taskFileName}`; + + try { + // Write task file using generic endpoint + const response = await fetch('/write-file', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + path: taskFilePath, + content: JSON.stringify(task, null, 2) + }) + }); + + if (!response.ok) { + throw new Error(`Failed to create task file: ${response.statusText}`); + } + + return await response.json(); + + } catch (error) { + // Fallback: create file directly (for development) + console.warn('API not available, using fallback method'); + await this.writeTaskFileDirectly(task); + } + } + + /** + * Direct task file creation (fallback method) + */ + async writeTaskFileDirectly(task) { + // This would be implemented server-side + // For now, we'll simulate the task creation + //// // console.log(`Task file created: ${task.id}.json`); + return { success: true, taskId: task.id }; + } + + /** + * Get available command types + */ + getCommandTypes() { + return Object.keys(this.commandTemplates); + } + + /** + * Get command template for a type + */ + getCommandTemplate(type) { + return this.commandTemplates[type]; + } + + /** + * Get app data for icon and URL information + */ + getAppData(appName) { + if (window.apps && Array.isArray(window.apps)) { + const target = String(appName || '').toLowerCase(); + const app = window.apps.find(app => { + const appCommandName = (app.command || '').split(' ').pop(); + return appCommandName.toLowerCase() === target; + }); + if (app) return app; + } + + // Fallback: create minimal app data + return { + name: appName, + icon: `icons/apps/${appName}.svg`, + command: `libreportal app install ${appName}` + }; + } + + /** + * Trigger enhanced notification with app icon and action button + * + * `customIcon` is forwarded to NotificationSystem.show so callers can + * supply a task-type emoji (install ✅, backup 💾, etc.) for the leftmost + * icon slot — same style every other task-related notification uses. + */ + triggerNotification(message, type = 'info', appName = null, appUrl = null, appIcon = null, customIcon = null) { + if (window.notificationSystem && typeof window.notificationSystem.show === 'function') { + // Use enhanced notification system + window.notificationSystem.show(message, type, appName, appUrl, appIcon, customIcon); + } else if (typeof ConfigShared !== 'undefined' && ConfigShared.showNotification) { + // Fallback to basic notification + ConfigShared.showNotification(message, type); + } else { + //// // console.log(`🔔 ${type.toUpperCase()}: ${message}`); + } + } +} + +// Export for use +window.TaskCommands = TaskCommands; diff --git a/containers/libreportal/frontend/js/components/task/task-event-bus.js b/containers/libreportal/frontend/js/components/task/task-event-bus.js new file mode 100755 index 0000000..3b75415 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-event-bus.js @@ -0,0 +1,139 @@ +/** + * TaskEventBus — single SSE connection for the page. + * + * Connects to /api/tasks/events. Translates the server's SSE events + * (task.upsert, task.deleted, task.log) into the existing window-level + * CustomEvents that the rest of the UI already listens for: + * - taskCreated (when a brand-new task appears) + * - taskUpdated (status change while still active) + * - taskCompleted (status -> completed | failed | cancelled) + * - taskLog (new log lines for a running task) + * - taskDeleted (task removed) + * + * The bus also exposes a `tasks` Map keyed by id holding the latest known + * task object — components can read this synchronously instead of fetching. + */ + +class TaskEventBus { + constructor() { + this.tasks = new Map(); // id -> latest task object + this.eventSource = null; + this.reconnectTimer = null; + this.connected = false; + + // Track previous status per task so we can decide created vs updated vs completed. + this._lastStatus = new Map(); + } + + start() { + if (this.eventSource) return; + this._open(); + } + + stop() { + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } + this.connected = false; + } + + // Convenience accessors used by UI components. + getTask(id) { return this.tasks.get(id) || null; } + getRunningTasks() { + const out = []; + for (const t of this.tasks.values()) { + if (t.status === 'running' || t.status === 'queued' || t.status === 'pending') out.push(t); + } + return out; + } + getRunningForApp(appName) { + return this.getRunningTasks().filter(t => t.app === appName); + } + + // ---- internals -------------------------------------------------------- + + _open() { + try { + this.eventSource = new EventSource('/api/tasks/events'); + } catch (err) { + this._scheduleReconnect(); + return; + } + + this.eventSource.addEventListener('ready', () => { + this.connected = true; + window.dispatchEvent(new CustomEvent('taskBusReady')); + }); + + this.eventSource.addEventListener('task.upsert', (e) => { + let task; try { task = JSON.parse(e.data); } catch { return; } + if (!task || !task.id) return; + this._handleUpsert(task); + }); + + this.eventSource.addEventListener('task.deleted', (e) => { + let payload; try { payload = JSON.parse(e.data); } catch { return; } + if (!payload || !payload.id) return; + this.tasks.delete(payload.id); + this._lastStatus.delete(payload.id); + window.dispatchEvent(new CustomEvent('taskDeleted', { detail: { id: payload.id } })); + }); + + this.eventSource.addEventListener('task.log', (e) => { + let payload; try { payload = JSON.parse(e.data); } catch { return; } + if (!payload || !payload.id || typeof payload.chunk !== 'string') return; + window.dispatchEvent(new CustomEvent('taskLog', { + detail: { id: payload.id, chunk: payload.chunk } + })); + }); + + this.eventSource.onerror = () => { + // Browser will auto-retry, but we want a deterministic backoff on top + // so we don't hammer the server during a long outage. + this.connected = false; + this.eventSource && this.eventSource.close(); + this.eventSource = null; + this._scheduleReconnect(); + }; + } + + _scheduleReconnect() { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this._open(); + }, 3000); + } + + _handleUpsert(task) { + const prevStatus = this._lastStatus.get(task.id); + const isNew = !this.tasks.has(task.id); + this.tasks.set(task.id, task); + this._lastStatus.set(task.id, task.status); + + const detail = { + taskId: task.id, + appName: task.app || null, + action: task.type || 'unknown', + status: task.status, + task, + timestamp: Date.now() + }; + + if (isNew) { + window.dispatchEvent(new CustomEvent('taskCreated', { detail })); + } + + const isTerminal = task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'; + const wasTerminal = prevStatus === 'completed' || prevStatus === 'failed' || prevStatus === 'cancelled'; + + if (isTerminal && !wasTerminal) { + window.dispatchEvent(new CustomEvent('taskCompleted', { detail })); + } else if (!isNew) { + window.dispatchEvent(new CustomEvent('taskUpdated', { detail })); + } + } +} + +// One instance per page. +window.taskEventBus = window.taskEventBus || new TaskEventBus(); +window.TaskEventBus = TaskEventBus; diff --git a/containers/libreportal/frontend/js/components/task/task-global-functions.js b/containers/libreportal/frontend/js/components/task/task-global-functions.js new file mode 100755 index 0000000..50bc28a --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-global-functions.js @@ -0,0 +1,28 @@ +/** + * Global Functions for Individual Task Actions + * Extends the tasks manager with global action functions for individual tasks + */ + +// Task global functions initialization is now handled by SystemLoader +// Global task functions will be set up centrally when TasksManager is available + +function setupTaskGlobalFunctions() { + if (window.TasksManager || window.tasksManager) { + // Use whichever instance is available + const tasksManager = window.tasksManager || window.TasksManager; + + // Add modular action functions to global scope + window.installApp = (appName, config = '') => tasksManager.router.routeAction('install', { appName, config }); + window.uninstallApp = (appName) => tasksManager.router.routeAction('uninstall', { appName }); + window.restartApp = (appName) => tasksManager.router.routeAction('restart', { appName }); + window.startApp = (appName) => tasksManager.router.routeAction('start', { appName }); + window.stopApp = (appName) => tasksManager.router.routeAction('stop', { appName }); + window.backupApp = (appName) => tasksManager.router.routeAction('backup', { appName }); + window.updateConfig = (appName) => tasksManager.router.routeAction('update_config', { appName }); + window.systemUpdate = () => tasksManager.router.routeAction('system_update'); + + //console.log('✅ Task action functions registered globally'); + } else { + console.warn('⚠️ TasksManager not found, action functions not registered'); + } +} diff --git a/containers/libreportal/frontend/js/components/task/task-manager.js b/containers/libreportal/frontend/js/components/task/task-manager.js new file mode 100755 index 0000000..3182018 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-manager.js @@ -0,0 +1,178 @@ +/** + * TaskManager — thin client over /api/tasks. + * + * Replaces the old direct-file-operation approach. We never touch task files + * or the queue from the browser anymore — the server owns them. State updates + * arrive via SSE through TaskEventBus; this class just performs CRUD. + */ + +class TaskManager { + constructor() {} + + /** + * Create a new task. Returns the server's authoritative task object. + * Side effect: a `taskCreated` window event will fire from the SSE bus + * within milliseconds — but the POST response also contains the task, + * so callers that need the id immediately can use the return value. + */ + async createTask(command, type = 'custom', app = null, config = '') { + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, type, app, config }) + }); + if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`); + const task = await res.json(); + + if (window.tasksManager) window.tasksManager.highlightedTaskId = task.id; + return task; + } + + async getTask(taskId) { + if (window.taskEventBus) { + const cached = window.taskEventBus.getTask(taskId); + if (cached) return cached; + } + const res = await fetch(`/api/tasks/${taskId}`); + if (res.status === 404) return null; + if (!res.ok) return null; + return res.json(); + } + + async getTaskSummary(taskId) { + const task = await this.getTask(taskId); + if (!task) return null; + return { + id: task.id, + command: task.command, + type: task.type, + app: task.app, + config: task.config, + status: task.status, + createdAt: task.created_at || task.createdAt, + startedAt: task.started_at || task.startedAt, + completedAt: task.completed_at || task.completedAt, + hasOutput: false, + hasError: !!task.error_message, + outputLength: 0, + errorLength: task.error_message ? task.error_message.length : 0 + }; + } + + async listTasks() { + const res = await fetch('/api/tasks'); + if (!res.ok) return []; + return res.json(); + } + + async cancelTask(taskId) { + const res = await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' }); + if (!res.ok) throw new Error(`Cancel failed: HTTP ${res.status}`); + return res.json(); + } + + async deleteTask(taskId, { force = false } = {}) { + const url = force + ? `/api/tasks/${taskId}?force=1` + : `/api/tasks/${taskId}`; + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) throw new Error(`Delete failed: HTTP ${res.status}`); + return true; + } + + /** + * Read the task log. Returns the last `maxLines` lines as an array. + * The SSE bus delivers incremental log chunks via `taskLog` events; this + * is for "open the modal and dump the full output" use-cases. + */ + async readTaskLog(taskId, maxLines = 1000) { + try { + const res = await fetch(`/api/tasks/${taskId}/log`); + if (!res.ok) return []; + const text = await res.text(); + const lines = text.split('\n'); + return lines.length > maxLines ? lines.slice(-maxLines) : lines; + } catch { return []; } + } + + async readFullTaskLog(taskId) { + try { + const res = await fetch(`/api/tasks/${taskId}/log`); + if (!res.ok) return ''; + return res.text(); + } catch { return ''; } + } + + /** + * Lightweight log streamer for callers that want a callback per chunk. + * Backed by SSE: registers a window listener for `taskLog` events that + * match the requested taskId. + */ + streamTaskLog(taskId, onNewLines, onError) { + let stopped = false; + const handler = (event) => { + if (stopped) return; + const detail = event.detail || {}; + if (detail.id !== taskId || typeof detail.chunk !== 'string') return; + try { + const lines = detail.chunk.split('\n').filter(l => l.length > 0); + if (lines.length > 0) onNewLines(lines); + } catch (err) { onError && onError(err); } + }; + window.addEventListener('taskLog', handler); + return { + stop: () => { stopped = true; window.removeEventListener('taskLog', handler); }, + isStreaming: () => !stopped + }; + } + + /** + * ANSI -> HTML colour parser (kept from the previous TaskManager so log + * rendering doesn't lose formatting). + */ + parseAnsiColors(text) { + const ansiRegex = /\x1b\[[0-9;]*m/g; + const colorMap = { + '30':'black','31':'red','32':'green','33':'yellow','34':'blue','35':'magenta','36':'cyan','37':'white', + '90':'darkgray','91':'lightred','92':'lightgreen','93':'lightyellow','94':'lightblue','95':'lightmagenta','96':'lightcyan','97':'lightwhite' + }; + const bgColorMap = { + '40':'black','41':'red','42':'green','43':'yellow','44':'blue','45':'magenta','46':'cyan','47':'white', + '100':'darkgray','101':'lightred','102':'lightgreen','103':'lightyellow','104':'lightblue','105':'lightmagenta','106':'lightcyan','107':'lightwhite' + }; + let result = ''; + let cursor = 0; + let m; + while ((m = ansiRegex.exec(text)) !== null) { + result += text.substring(cursor, m.index); + const codes = m[0].slice(2, -1).split(';'); + const styles = []; + let reset = false; + for (const code of codes) { + if (code === '0' || code === '') reset = true; + else if (code === '1') styles.push('font-weight: bold'); + else if (code === '4') styles.push('text-decoration: underline'); + else if (colorMap[code]) styles.push(`color: ${colorMap[code]}`); + else if (bgColorMap[code]) styles.push(`background-color: ${bgColorMap[code]}`); + } + if (styles.length) result += ``; + else if (reset) result += ''; + cursor = m.index + m[0].length; + } + result += text.substring(cursor); + const open = (result.match(//g) || []).length; + result += ''.repeat(Math.max(0, open - close)); + return result; + } + + generateTaskId() { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = TaskManager; +} else { + window.TaskManager = TaskManager; +} diff --git a/containers/libreportal/frontend/js/components/task/task-router.js b/containers/libreportal/frontend/js/components/task/task-router.js new file mode 100755 index 0000000..a2ee193 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-router.js @@ -0,0 +1,227 @@ +/** + * Task Router - Action routing and dispatch for individual tasks + * Handles action routing, queuing, and error management + */ + +class TaskRouter { + constructor(tasksManager, actions) { + this.tasksManager = tasksManager; + this.actions = actions; + this.commandQueue = []; + this.isProcessing = false; + } + + /** + * Route action to appropriate handler + */ + async routeAction(action, params = {}) { + try { + //console.log(`🎯 Routing action: ${action}`, params); + + switch (action) { + case 'install': + return await this.actions.installApp(params.appName, params.config, params.resetNetwork); + + case 'uninstall': + return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks); + + case 'restart': + return await this.actions.restartApp(params.appName); + + case 'start': + return await this.actions.startApp(params.appName); + + case 'stop': + return await this.actions.stopApp(params.appName); + + case 'backup': + return await this.actions.backupApp(params.appName, params.customPassword); + + case 'restore': + return await this.actions.restoreApp( + params.appName, + params.location, + params.backupFile, + params.password + ); + + case 'delete': + return await this.actions.deleteBackup(params.appName, params.backupFile, params.deleteRemote1, params.deleteRemote2); + + case 'delete_all': + return await this.actions.deleteAllBackups(params.appName, params.deleteRemote1, params.deleteRemote2); + + case 'update_config': + return await this.actions.updateConfig(params.appName); + + case 'config_update': + return await this.actions.configUpdate(params.changes); + + case 'system_update': + return await this.actions.systemUpdate(); + + case 'tool': + return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel); + + default: + throw new Error(`Unknown action: ${action}`); + } + } catch (error) { + console.error(`❌ Action routing failed: ${error.message}`); + throw error; + } + } + + /** + * Queue action for execution + */ + queueAction(action, params = {}) { + const queuedTask = { + id: Date.now().toString(), + action: action, + params: params, + timestamp: new Date().toISOString(), + status: 'queued' + }; + + this.commandQueue.push(queuedTask); + //console.log(`📋 Queued action: ${action}`, params); + + // Start processing if not already running + if (!this.isProcessing) { + this.processQueue(); + } + + return queuedTask.id; + } + + /** + * Process the command queue + */ + async processQueue() { + if (this.isProcessing || this.commandQueue.length === 0) { + return; + } + + this.isProcessing = true; + //console.log('🔄 Processing command queue...'); + + while (this.commandQueue.length > 0) { + const queuedTask = this.commandQueue.shift(); + + try { + //console.log(`⚡ Executing queued action: ${queuedTask.action}`); + await this.routeAction(queuedTask.action, queuedTask.params); + + queuedTask.status = 'completed'; + //console.log(`✅ Completed queued action: ${queuedTask.action}`); + + } catch (error) { + queuedTask.status = 'failed'; + queuedTask.error = error.message; + console.error(`❌ Failed queued action: ${queuedTask.action}`, error); + + if (window.notificationSystem) { + // Pull the per-action icon if we know it; falls back to the + // generic error SVG via customIcon=null. + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: queuedTask.action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Queued action failed: ${error.message}`, + 'error', + null, + null, + null, + customIcon + ); + } + } + } + + this.isProcessing = false; + //console.log('✅ Command queue processing complete'); + } + + /** + * Get queue status + */ + getQueueStatus() { + return { + isProcessing: this.isProcessing, + queueLength: this.commandQueue.length, + queuedTasks: [...this.commandQueue] + }; + } + + /** + * Clear the queue + */ + clearQueue() { + const clearedCount = this.commandQueue.length; + this.commandQueue = []; + + if (window.notificationSystem) { + const customIcon = '🗑️'; + window.notificationSystem.show( + `Cleared ${clearedCount} queued actions`, + 'info', + null, + null, + null, + customIcon + ); + } + + return clearedCount; + } + + /** + * Get available actions + */ + getAvailableActions() { + return [ + 'install', + 'uninstall', + 'restart', + 'start', + 'stop', + 'backup', + 'delete', + 'delete_all', + 'update_config', + 'system_update', + 'tool' + ]; + } + + /** + * Validate action parameters + */ + validateAction(action, params) { + const requiredParams = { + install: ['appName'], + uninstall: ['appName'], + restart: ['appName'], + start: ['appName'], + stop: ['appName'], + backup: ['appName'], + delete: ['appName', 'backupFile'], + update_config: ['appName'], + tool: ['appName', 'toolName'] + }; + + const required = requiredParams[action] || []; + const missing = required.filter(param => !params[param]); + + if (missing.length > 0) { + throw new Error(`Missing required parameters for ${action}: ${missing.join(', ')}`); + } + + return true; + } +} + +// Export for use +window.TaskRouter = TaskRouter; diff --git a/containers/libreportal/frontend/js/components/tasks/tasks-manager.js b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js new file mode 100755 index 0000000..476b197 --- /dev/null +++ b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js @@ -0,0 +1,2457 @@ +/** + * Tasks Manager - Tasks Page UI Management + * Handles the tasks page display, filtering, and UI interactions + * Uses individual task operations from the task/ folder + */ +class TasksManager { + constructor() { + this.tasks = []; + this.currentCategory = 'all'; + this.highlightedTaskId = null; + this.taskManager = new TaskManager(); + this.init(); + + // Start global live log updater + this.startGlobalLiveLogUpdater(); + + this.refreshInterval = null; + this.activeLogStreams = new Map(); // Track active log streams + this.autoRefreshIntervals = new Map(); // Track auto-refresh intervals for running tasks + + // Initialize modular components for individual task operations (only if available) + try { + this.commands = new TaskCommands(); + this.actions = new TaskActions(this, this.commands); + this.router = new TaskRouter(this, this.actions); + this.taskManager = new TaskManager(); + } catch (error) { + console.warn('⚠️ Task system components not yet loaded, will initialize later:', error.message); + this.commands = null; + this.actions = null; + this.router = null; + this.taskManager = null; + } + + // Initialize from URL parameters + this.initializeFromURL(); + + // Load tasks and setup (only if task system is available) + if (this.commands) { + this.loadTasks(); + } else { + //// // console.log('⏳ Task system will be initialized later'); + } + + // Setup global functions + this.setupGlobalFunctions(); + + // Subscribe to the SSE bus once for the page so every visible task row + // reacts to status changes, not just ones spawned in this session. + this.setupTaskBusListeners(); + + // Setup mobile menu + this.setupMobileMenu(); + + //// // console.log('✅ TasksManager initialized with modular architecture'); + } + + // Initialize task system after scripts are loaded + initializeTaskSystem() { + try { + //// // console.log('🔧 Initializing task system components...'); + + // Check if TaskManager is available + if (typeof TaskManager === 'undefined') { + console.warn('⚠️ TaskManager not available yet, deferring initialization'); + return false; + } + + this.commands = new TaskCommands(); + this.actions = new TaskActions(this, this.commands); + this.router = new TaskRouter(this, this.actions); + this.taskManager = new TaskManager(); // Add TaskManager for task operations + + // Now load tasks since system is ready + this.loadTasks(); + + //// // console.log('✅ Task system initialized successfully'); + return true; + } catch (error) { + console.error('❌ Failed to initialize task system:', error); + return false; + } + } + + // Main initialization method for the tasks page + async init() { + //// // console.log('🔧 Initializing TasksManager...'); + + // Initialize task system if not already done + if (!this.taskManager) { + let initialized = false; + let attempts = 0; + const maxAttempts = 5; + + while (!initialized && attempts < maxAttempts) { + //// // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); + initialized = this.initializeTaskSystem(); + + if (!initialized) { + attempts++; + await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms + } + } + + if (!initialized) { + console.warn('⚠️ Task system initialization failed after retries'); + } + } + + // Setup refresh interval + this.setupRefreshInterval(); + + // Reconstructor() { + this.tasks = []; + this.taskManager = new TaskManager(); + this.activeLogStreams = new Map(); // Track active log streams + // console.log('🔍 TasksManager initialized'); + } + + initializeFromURL() { + const currentUrl = new URL(window.location.href); + const searchParams = currentUrl.searchParams; + + // Check if we're on the main tasks page (not app page) + const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname === '/tasks.html'; + + if (isMainTasksPage) { + // On main tasks page, get category from URL + this.currentCategory = searchParams.get('') || 'all'; + + // Only check for specific task parameter if we're not coming from an app page + const taskParam = searchParams.get('task'); + if (taskParam) { + // console.log(`🎯 Found task parameter in URL: ${taskParam} on main tasks page`); + this.highlightedTaskId = taskParam; + } else { + // Clear any existing highlighted task when on main tasks page without task param + this.highlightedTaskId = null; + // console.log(`🎯 Clearing highlighted task on main tasks page`); + } + } else { + // Not on main tasks page, get default filter from localStorage + this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all'; + this.highlightedTaskId = null; // Always clear when not on tasks page + } + + // console.log(`🎯 Tasks category from URL: ${this.currentCategory}`); + } + + updateURL(category, taskId = null) { + // Update URL without page reload and without hash + let newURL = `/tasks?=${category}`; + if (taskId) { + newURL += `&task=${taskId}`; + } + + // Prevent the SPA from interfering + if (window.librePortalSPA) { + window.librePortalSPA.currentRoute = newURL; + } + + // Use a timeout to avoid conflicts with SPA routing + setTimeout(() => { + window.history.pushState({ category, taskId }, '', newURL); + }, 0); + } + + async init() { + //// // console.log('🔧 Initializing TasksManager...'); + + // Load initial tasks and refresh sidebar counts + await this.loadTasks(); + + // Force a refresh to ensure latest data + // console.log('🔄 Refreshing tasks data on initialization...'); + await this.loadTasks(); + + // Setup auto-refresh + this.setupAutoRefresh(); + + // Setup global functions + this.setupGlobalFunctions(); + + // Subscribe to the SSE bus once for the page so every visible task row + // reacts to status changes, not just ones spawned in this session. + this.setupTaskBusListeners(); + + // Setup mobile menu + this.setupMobileMenu(); + + //// // console.log('✅ TasksManager initialized'); + } + + async refreshTasks() { + // Show refresh notification + const refreshNotification = window.notificationSystem.info( + '🔄 Refreshing tasks...', + 'Tasks', + null, + null + ); + + try { + await this.loadTasks(); + + // Remove refresh notification and show success + if (refreshNotification && refreshNotification.remove) { + refreshNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.success( + '🔄 Tasks refreshed successfully', + 'Tasks', + null, + null + ); + } + } catch (error) { + console.error('Error refreshing tasks:', error); + + // Remove refresh notification and show error + if (refreshNotification && refreshNotification.remove) { + refreshNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.error( + `⚠️ Failed to refresh tasks: ${error.message}`, + 'Tasks', + null, + null + ); + } + } + } + + async loadTasks() { + try { + //// // console.log('🔄 Loading tasks from file system...'); + + // Check if task system is available + if (!this.taskManager) { + console.warn('⚠️ Task system not yet initialized, skipping task loading'); + this.tasks = []; + return; + } + + // Get tasks using new system + // console.log('📥 Getting tasks using new queue system...'); + + // Get queue and current status + let queue = []; + let current = {}; + + try { + const queueResponse = await fetch('/read-file?path=tasks/queue.json'); + if (queueResponse.ok) { + const queueText = await queueResponse.text(); + if (queueText.trim()) { // Only parse if not empty + try { + queue = JSON.parse(queueText); + } catch (parseError) { + console.warn('⚠️ Invalid queue.json format, starting with empty queue'); + queue = []; + } + } + } + } catch (error) { + // console.log('📝 Queue file not found, starting with empty queue'); + } + + try { + const currentResponse = await fetch('/read-file?path=tasks/current.json'); + if (currentResponse.ok) { + const currentText = await currentResponse.text(); + if (currentText.trim()) { // Only parse if not empty + try { + current = JSON.parse(currentText); + } catch (parseError) { + console.warn('⚠️ Invalid current.json format, treating as empty'); + current = {}; + } + } + } + } catch (error) { + // console.log('📝 Current file not found, no current task'); + } + + // Load individual task files + const allTasks = []; + + // Add queued tasks + for (const taskId of queue) { + try { + const task = await this.taskManager.getTask(taskId); + if (task) allTasks.push(task); + } catch (error) { + console.warn(`⚠️ Failed to load queued task ${taskId}:`, error); + } + } + + // Add current task if different from queue + if (current.id && !queue.includes(current.id)) { + try { + const task = await this.taskManager.getTask(current.id); + if (task) allTasks.push(task); + } catch (error) { + console.warn(`⚠️ Failed to load current task ${current.id}:`, error); + } + } + + // Scan tasks folder for all task files (including completed ones) - OPTIMIZED + try { + // console.log('🔍 Scanning tasks folder for all task files...'); + const tasksResponse = await fetch('/read-directory?path=tasks'); + if (tasksResponse.ok) { + const files = await tasksResponse.json(); + const taskFiles = files.filter(file => + file.endsWith('.json') && + file !== 'queue.json' && + file !== 'current.json' + ); + + // console.log(`📁 Found ${taskFiles.length} task files in folder`); + + // OPTIMIZATION: Batch load tasks instead of individual calls + const missingTaskIds = taskFiles + .map(file => file.replace('.json', '')) + .filter(taskId => !allTasks.find(task => task.id === taskId)); + + if (missingTaskIds.length > 0) { + // console.log(`📦 Batch loading ${missingTaskIds.length} missing tasks...`); + try { + const batchResponse = await fetch('/read-tasks-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskIds: missingTaskIds }) + }); + + if (batchResponse.ok) { + const batchTasks = await batchResponse.json(); + batchTasks.forEach(task => { + if (task) { + allTasks.push(task); + // console.log(`✅ Added completed task ${task.id} from batch load`); + } + }); + } else { + // Fallback to individual loading if batch endpoint not available + // console.log('⚠️ Batch endpoint not available, falling back to individual loading'); + await this.loadTasksIndividually(missingTaskIds, allTasks); + } + } catch (error) { + console.warn('⚠️ Batch loading failed, falling back to individual loading:', error); + await this.loadTasksIndividually(missingTaskIds, allTasks); + } + } + } + } catch (error) { + console.warn('⚠️ Failed to scan tasks folder:', error); + } + + //// // console.log('📊 Task counts:', { + //queued: queuedTasks.length, + //processing: processingTasks.length, + //completed: completedTasks.length + //}); + + // Combine all tasks + this.tasks = allTasks; + + // Sort by creation time (newest first) + this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + // console.log(`✅ Loaded ${this.tasks.length} tasks`); + //// // console.log('📋 All tasks:', this.tasks); + + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + } catch (error) { + console.error('❌ Failed to load tasks:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to load tasks: ${error.message}`); + } + this.tasks = []; + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + } + } + + async loadTasksIndividually(taskIds, allTasks) { + // Fallback method for individual task loading + for (const taskId of taskIds) { + try { + const task = await this.taskManager.getTask(taskId); + if (task) { + allTasks.push(task); + // console.log(`✅ Added completed task ${taskId} from individual load`); + } + } catch (error) { + console.warn(`⚠️ Failed to load task ${taskId}:`, error); + } + } + } + + renderTasks() { + const container = document.getElementById('tasks-list'); + if (!container) return; + + // Capture which task panels are currently open and where the user + // is scrolled to *before* the innerHTML rebuild. Without this, + // every refresh slams every expanded log shut and snaps to the top + // — which makes watching a live task feel hostile. + const expandedIds = new Set(); + container.querySelectorAll('.task-details.task-details-open').forEach(el => { + const taskId = (el.id || '').replace(/^details-/, ''); + if (taskId) expandedIds.add(taskId); + }); + const scrollParent = container.closest('.main') || document.scrollingElement || document.documentElement; + const savedScrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY; + + // Filter tasks based on current category and specific task + let filteredTasks = this.filterTasksByCategory(this.tasks, this.currentCategory); + + // Note: Don't filter out other tasks when one is highlighted + // highlightedTaskId is only for auto-expansion, not for filtering + + if (filteredTasks.length === 0) { + let message; + if (this.highlightedTaskId) { + message = `Task ${this.highlightedTaskId} not found`; + } else { + const categoryName = this.getCategoryDisplayName(this.currentCategory); + message = `No ${categoryName.toLowerCase()} tasks found`; + } + + container.innerHTML = ` +
    +
    +
    + info + $ ${message} + just now +
    +
    +
    +
    + +
    +
    Run a task or install an application to see your task list here
    +
    +
    + `; + return; + } + + // Sort tasks by creation time (newest first) + const sortedTasks = filteredTasks.sort((a, b) => + new Date(b.createdAt) - new Date(a.createdAt) + ); + + const html = sortedTasks.map(task => this.renderTask(task)).join(''); + container.innerHTML = html; + + // Re-open everything that was open before the rebuild and re-attach + // log streaming so live tasks keep updating without an extra click. + expandedIds.forEach(taskId => { + const details = document.getElementById(`details-${taskId}`); + if (!details) return; + details.style.display = 'block'; + details.classList.add('task-details-open'); + const btn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + if (btn) btn.classList.add('expanded'); + + const t = this.tasks.find(x => x.id === taskId); + if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) { + if (typeof this.startLogStreaming === 'function') this.startLogStreaming(taskId, t); + } else { + if (typeof this.loadTaskLogs === 'function') this.loadTaskLogs(taskId); + } + }); + + // Restore scroll. Defer one frame so the browser has laid out the + // new content height before we scroll back into it. + if (scrollParent) { + requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; }); + } + } + + filterTasksByCategory(tasks, category) { + switch (category) { + case 'all': + return tasks; + + case 'queued': + case 'running': + case 'completed': + case 'failed': + return tasks.filter(task => task.status === category); + + case 'install': + case 'uninstall': + return tasks.filter(task => task.type === category); + + case 'management': + return tasks.filter(task => + ['restart', 'start', 'stop'].includes(task.type) + ); + + case 'backup': + return tasks.filter(task => + ['backup', 'restore', 'delete'].includes(task.type) + ); + + case 'config': + return tasks.filter(task => task.type === 'update_config'); + + default: + // Assume it's an app name + return tasks.filter(task => task.app === category); + } + } + + getCategoryDisplayName(category) { + const displayNames = { + 'all': 'All Tasks', + 'queued': 'Queued', + 'running': 'Running', + 'completed': 'Completed', + 'failed': 'Failed', + 'install': 'Install', + 'uninstall': 'Uninstall', + 'management': 'Management', + 'backup': 'Backups', + 'config': 'Configuration', + 'libreportal': 'LibrePortal' + }; + + return displayNames[category] || category.charAt(0).toUpperCase() + category.slice(1); + } + + renderTask(task) { + // console.log(`🔍 renderTask called with task:`, task); + + // Debug undefined status + if (!task.status) { + console.warn(`⚠️ Task ${task.id} has undefined status:`, task); + } + + const statusClass = `status-${task.status || 'unknown'}`; + const timeAgo = this.getTimeAgo(task.createdAt); + const isRunning = task.status === 'running'; + const isFailed = task.status === 'failed'; + const hasOutput = task.output && task.output.length > 0; + const hasError = task.error && task.error.length > 0; + const hasLogs = task.log && Array.isArray(task.log) && task.log.length > 0; + + // console.log(`🔍 Task fields check:`, { + //hasOutput: hasOutput, + //hasError: hasError, + //hasLogs: hasLogs, + //isRunning: isRunning, + //outputLength: task.output ? task.output.length : 0, + //error: task.error, + //logCount: task.log ? task.log.length : 0 + //}); + + const executionTime = task.startedAt && task.completedAt ? + this.calculateExecutionTime(task.startedAt, task.completedAt) : null; + + // console.log('🔍 renderTask debug:', { + //taskStatus: task.status, + //statusClass: statusClass, + //statusDisplay: task.status ? task.status.toUpperCase() : 'UNKNOWN' + //}); + + return ` +
    +
    +
    + ${this.renderTaskIcons(task)} + ${this.formatCommandForUser(task)} + ${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'} + ${timeAgo} + ${executionTime ? `⏱️ ${executionTime}` : ''} +
    +
    + ${isFailed ? ` + + ` : ''} + + +
    +
    + + +
    + +
    +
    + Task ID: ${task.id} +
    +
    + Type: ${task.type || 'unknown'} +
    +
    + App: ${task.app ? `${task.app}` : 'system'} +
    +
    + Created: ${new Date(task.createdAt).toLocaleString()} +
    + ${task.startedAt ? ` +
    + Started: ${new Date(task.startedAt).toLocaleString()} +
    + ` : ''} + ${task.completedAt ? ` +
    + Completed: ${new Date(task.completedAt).toLocaleString()} +
    + ` : ''} + ${executionTime ? ` +
    + Execution Time: ${executionTime} +
    + ` : ''} +
    + + +
    +
    + ${hasLogs ? + task.log.map(log => `
    ${this.taskManager.parseAnsiColors(log)}
    `).join('') : + '
    Loading logs...
    ' + } +
    +
    +
    + + `; + } + + getStatusIcon(status) { + const icons = { + queued: '⏳', + running: '🔄', + completed: '✅', + failed: '❌' + }; + return icons[status] || '📋'; + } + + formatDuration(seconds) { + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + } + + calculateExecutionTime(startedAt, completedAt) { + if (!startedAt || !completedAt) return null; + + const start = new Date(startedAt); + const end = new Date(completedAt); + const durationMs = end - start; + const durationSeconds = Math.floor(durationMs / 1000); + + return this.formatDuration(durationSeconds); + } + + extractTimestamp(logEntry) { + const match = logEntry.match(/\[([^\]]+)\]/); + return match ? match[1] : ''; + } + + extractLogMessage(logEntry) { + const match = logEntry.match(/\] (.+)$/); + return match ? match[1] : logEntry; + } + + async viewTaskLogs(taskId) { + // Open logs in a modal or new view + const task = this.tasks.find(t => t.id === taskId); + if (!task) return; + + // Load full logs for modal + const fullLogs = await this.taskManager.readFullTaskLog(taskId); + + // Create modal with streaming logs + const modal = document.createElement('div'); + modal.className = 'task-logs-modal'; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + + // Update line count + const lineCount = fullLogs.split('\n').length; + const lineCountElement = modal.querySelector('.log-line-count'); + if (lineCountElement) { + lineCountElement.textContent = `(${lineCount} lines)`; + } + + // Start streaming if task is running + if (task.status === 'running' || task.status === 'queued') { + this.startLogStreaming(taskId, modal); + } + + // Store streaming controller + this.activeLogStreams = this.activeLogStreams || new Map(); + } + + startLogStreaming(taskId, modal) { + const statusIndicator = document.getElementById(`log-status-${taskId}`); + const toggleButton = document.getElementById(`log-toggle-${taskId}`); + const logViewer = document.getElementById(`log-viewer-${taskId}`); + const lineCountElement = modal.querySelector('.log-line-count'); + + if (!logViewer) return; + + let lineCount = logViewer.children.length; + + const stream = this.taskManager.streamTaskLog( + taskId, + (newLines) => { + // Add new lines to existing content + const preElement = logViewer.querySelector('pre'); + let currentContent = preElement ? preElement.textContent : ''; + const separator = currentContent.trim() && !currentContent.includes('Waiting for logs...') ? '\n' : ''; + let cleanedContent = currentContent.replace('Waiting for logs...', '').trim(); + const newContent = cleanedContent + separator + newLines.join('\n'); + preElement.innerHTML = this.taskManager.parseAnsiColors(newContent); + logViewer.appendChild(lineElement); + lineCount++; + if (lineCountElement) { + lineCountElement.textContent = `(${lineCount} lines)`; + } + + // Auto-scroll to bottom + logViewer.scrollTop = logViewer.scrollHeight; + + // Update status + if (statusIndicator) { + statusIndicator.textContent = '🔴 Live'; + statusIndicator.className = 'status-indicator live'; + } + + // Remove new-line highlighting after a moment + setTimeout(() => { + const newLines = logViewer.querySelectorAll('.new-line'); + newLines.forEach(line => line.classList.remove('new-line')); + }, 2000); + }, + (error) => { + console.error('Log streaming error:', error); + if (statusIndicator) { + statusIndicator.textContent = '❌ Error'; + statusIndicator.className = 'status-indicator error'; + } + } + ); + + // Store stream controller + this.activeLogStreams.set(taskId, { stream, modal, isPaused: false }); + + // Update toggle button + if (toggleButton) { + toggleButton.textContent = '⏸️ Pause'; + } + } + + toggleLogStreaming(taskId) { + const streamData = this.activeLogStreams?.get(taskId); + if (!streamData) return; + + const toggleButton = document.getElementById(`log-toggle-${taskId}`); + const statusIndicator = document.getElementById(`log-status-${taskId}`); + + if (streamData.isPaused) { + // Resume streaming + streamData.isPaused = false; + if (toggleButton) toggleButton.textContent = '⏸️ Pause'; + if (statusIndicator) { + statusIndicator.textContent = '🔴 Live'; + statusIndicator.className = 'status-indicator live'; + } + } else { + // Pause streaming + streamData.isPaused = true; + if (toggleButton) toggleButton.textContent = '▶️ Resume'; + if (statusIndicator) { + statusIndicator.textContent = '⏸️ Paused'; + statusIndicator.className = 'status-indicator paused'; + } + } + } + + formatCommandForUser(task) { + if (!task.command) return 'Unknown Task'; + + const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1)); + + if (/^libreportal setup config\b/.test(task.command)) return 'LibrePortal - Apply Configuration'; + if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup'; + if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard'; + + // Backup engine — per-app actions. + const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/); + if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`; + + const backupDeleteMatch = task.command.match(/libreportal backup app delete (\w+) (.+)/); + if (backupDeleteMatch) return `${displayName(backupDeleteMatch[1])} - Delete Backup`; + + const backupCreateMatch = task.command.match(/^libreportal backup app create (\w+)/); + if (backupCreateMatch) return `${displayName(backupCreateMatch[1])} - Create Backup`; + + const backupScheduleMatch = task.command.match(/^libreportal backup app schedule (\w+)/); + if (backupScheduleMatch) return `${displayName(backupScheduleMatch[1])} - Scheduled Backup`; + + const backupListMatch = task.command.match(/^libreportal backup app list (\w+)/); + if (backupListMatch) return `${displayName(backupListMatch[1])} - List Backups`; + + // Backup engine — restore actions. + const restoreStartMatch = task.command.match(/^libreportal restore app start (\w+)/); + if (restoreStartMatch) return `${displayName(restoreStartMatch[1])} - Restore Backup`; + + const restoreListMatch = task.command.match(/^libreportal restore app list (\w+)/); + if (restoreListMatch) return `${displayName(restoreListMatch[1])} - List Backups`; + + const migrateAppMatch = task.command.match(/^libreportal restore migrate app (\w+)/); + if (migrateAppMatch) return `${displayName(migrateAppMatch[1])} - Migrate from Host`; + + // Backup engine — system-level actions (no per-app target). + if (/^libreportal backup all\b/.test(task.command)) return 'LibrePortal - Backup All Apps'; + if (/^libreportal backup verify\b/.test(task.command)) return 'LibrePortal - Verify Backups'; + if (/^libreportal backup location add\b/.test(task.command)) return 'LibrePortal - Add Backup Location'; + if (/^libreportal backup location remove\b/.test(task.command)) return 'LibrePortal - Remove Backup Location'; + if (/^libreportal backup location init\b/.test(task.command)) return 'LibrePortal - Initialise Backup Locations'; + if (/^libreportal backup location check\b/.test(task.command)) return 'LibrePortal - Check Backup Locations'; + if (/^libreportal backup location list\b/.test(task.command)) return 'LibrePortal - List Backup Locations'; + if (/^libreportal backup location stats\b/.test(task.command)) return 'LibrePortal - Backup Location Stats'; + if (/^libreportal restore migrate system\b/.test(task.command)) return 'LibrePortal - Migrate System'; + if (/^libreportal restore migrate discover\b/.test(task.command)) return 'LibrePortal - Discover Backups'; + if (/^libreportal restore first-run\b/.test(task.command)) return 'LibrePortal - First-Run Restore'; + + // `libreportal app tool ['']` — show the + // tool's friendly label instead of "Tool Application". Pull the + // label from window.toolsCatalog if loaded; otherwise titlecase + // the snake_case tool id. + const toolMatch = task.command.match(/libreportal app tool (\S+) (\S+)/); + if (toolMatch) { + const appName = toolMatch[1]; + const toolId = toolMatch[2]; + let label = null; + const cat = window.toolsCatalog; + if (cat && cat.apps && cat.apps[appName] && Array.isArray(cat.apps[appName].tools)) { + const t = cat.apps[appName].tools.find(x => x.id === toolId); + if (t && t.label) label = t.label; + } + if (!label) { + label = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); + } + return `${displayName(appName)} - ${label}`; + } + + // Parse libreportal commands. Capture only the app name token — anything after + // (e.g. config overrides like `CFG_FOO=bar|...`) is for the CLI, not the title. + const libreportalMatch = task.command.match(/libreportal app (\w+) (\S+)/); + if (libreportalMatch) { + const action = libreportalMatch[1]; + const appName = libreportalMatch[2]; + + const actionMap = { + 'install': 'Install Application', + 'uninstall': 'Uninstall Application', + 'restart': 'Restart Application', + 'start': 'Start Application', + 'stop': 'Stop Application', + 'update': 'Update Application', + 'rebuild': 'Rebuild Application', + 'delete': 'Delete Backup', + 'backup': 'Backup Application' + }; + + const formattedAction = actionMap[action] || `${action.charAt(0).toUpperCase() + action.slice(1)} Application`; + return `${displayName(appName)} - ${formattedAction}`; + } + + // Handle other common patterns + if (task.command.includes('docker-compose')) { + return 'Docker Compose Operation'; + } + + if (task.command.includes('docker')) { + return 'Docker Operation'; + } + + // Return first 50 chars of command as fallback + return task.command.length > 50 ? task.command.substring(0, 47) + '...' : task.command; + } + + getTaskTypeIcon(task) { + if (!task.type) return { icon: '⚙️', class: 'custom' }; + + const iconMap = { + 'install': { icon: '✅', class: 'install' }, + 'app-install': { icon: '✅', class: 'install' }, + 'uninstall': { icon: '❌', class: 'uninstall' }, + 'restart': { icon: '🔄', class: 'restart' }, + 'start': { icon: '▶️', class: 'start' }, + 'stop': { icon: '⏹️', class: 'stop' }, + 'update': { icon: '⬆️', class: 'update' }, + 'rebuild': { icon: '🔨', class: 'rebuild' }, + 'backup': { icon: '💾', class: 'backup' }, + 'restore': { icon: '📦', class: 'restore' }, + 'delete': { icon: '🗑️', class: 'delete' }, + 'delete_all': { icon: '🗑️', class: 'delete' }, + 'setup-config': { icon: '🛠️', class: 'setup' }, + 'setup-finalize': { icon: '🎉', class: 'setup' }, + 'custom': { icon: '⚙️', class: 'custom' } + }; + + return iconMap[task.type] || iconMap['custom']; + } + + /* Detect a task that's an LibrePortal system action (no specific app) so + the row can show the LibrePortal logo instead of a blank icon slot. */ + isLibrePortalSystemTask(task) { + if (!task || !task.command || task.app) return false; + return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config)\b/.test(task.command); + } + + /* Render the leading icon(s) on a task row: + - Per-app task → emoji type icon + app icon + - System task → emoji type icon + LibrePortal logo + - Anything else → emoji type icon only + Keeps the layout consistent across every row regardless of source. */ + renderTaskIcons(task) { + const typeIcon = `${this.getTaskTypeIcon(task).icon}`; + if (task.app) { + const appIconPath = this.getAppIconPath(task); + return `${typeIcon}${task.app}`; + } + if (this.isLibrePortalSystemTask(task)) { + return `${typeIcon}LibrePortal`; + } + return typeIcon; + } + + getAppIconPath(task) { + if (!task.app) return null; + + // Try to get icon from commands if available + if (this.commands && this.commands.getAppData) { + const appData = this.commands.getAppData(task.app); + if (appData && appData.icon) { + return appData.icon; + } + } + + // Default icon path + return `icons/apps/${task.app}.svg`; + } + + updateStats() { + const stats = { + queued: this.tasks.filter(t => t.status === 'queued').length, + running: this.tasks.filter(t => t.status === 'running').length, + completed: this.tasks.filter(t => t.status === 'completed').length, + failed: this.tasks.filter(t => t.status === 'failed').length + }; + + Object.keys(stats).forEach(status => { + const element = document.getElementById(`${status}-count`); + if (element) element.textContent = stats[status]; + }); + } + + updateSidebarCounts() { + // Update all sidebar category counts + const categories = ['all', 'queued', 'running', 'completed', 'failed', 'install', 'uninstall', 'management', 'backup', 'config']; + + categories.forEach(category => { + const count = this.filterTasksByCategory(this.tasks, category).length; + const element = document.getElementById(`count-${category}`); + if (element) element.textContent = count; + }); + + // Update app-specific counts + const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + apps.forEach(app => { + const count = this.tasks.filter(task => task.app === app).length; + const element = document.getElementById(`count-app-${app}`); + if (element) element.textContent = count; + }); + } + + async generateAppCategories() { + const container = document.getElementById('app-categories'); + if (!container) return; + + try { + // Show loading state + container.innerHTML = '

    Loading apps...

    '; + + // Try to load apps data if not already available + let appsData = window.apps || []; + + // If window.apps is not available, try to load it + if (!appsData || appsData.length === 0) { + try { + const appsResponse = await fetch('/read-file?path=apps.json'); + if (appsResponse.ok) { + const appsText = await appsResponse.text(); + if (appsText.trim()) { + appsData = JSON.parse(appsText); + window.apps = appsData; // Cache for future use + } + } + } catch (error) { + // console.log('Could not load apps.json, will use fallback'); + } + } + + // Filter for installed apps only and extract slugs + const installedApps = appsData + .filter(app => app.installed === true || app.status === 'installed') + .map(app => { + // Extract slug from command like "libreportal app install adguard" + const command = app.command || ''; + const slugMatch = command.match(/libreportal app install\s+(.+)$/); + const slug = slugMatch ? slugMatch[1].trim() : ''; + + // Extract title from "Title - Description" format + const fullName = app.name || ''; + const title = fullName.split(' - ')[0].trim(); + + return { slug, title }; + }) + .filter(app => app.slug && app.title); + + // If no installed apps found from apps data, fall back to task-based apps + if (installedApps.length === 0) { + // console.log('No installed apps found, using task-based app list'); + const taskApps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + + if (taskApps.length === 0) { + container.innerHTML = '
    No apps found
    '; + return; + } + + const appCategories = taskApps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app).length; + const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1)); + return ` + + + + + + + ${displayName} + ${taskCount} + + `; + }).join(''); + + container.innerHTML = appCategories; + return; + } + + const appCategories = installedApps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app.slug).length; + return ` + +
    + ${app.title} + ${taskCount} +
    + `; + }).join(''); + + container.innerHTML = appCategories; + } catch (error) { + console.error('Error generating app categories:', error); + // Final fallback to task-based app list + const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + + if (apps.length === 0) { + container.innerHTML = '
    No apps found
    '; + return; + } + + const appCategories = apps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app).length; + const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1)); + return ` + + + + + + + ${displayName} + ${taskCount} + + `; + }).join(''); + + container.innerHTML = appCategories; + } + } + + setupAutoRefresh() { + // Refresh every 5 seconds + this.refreshInterval = setInterval(() => { + this.loadTasks(); + }, 5000); + } + + setupMobileMenu() { + // Setup mobile menu toggle (if needed) + const mobileOverlay = document.getElementById('mobile-overlay'); + const sidebar = document.getElementById('sidebar'); + + if (mobileOverlay && sidebar) { + mobileOverlay.addEventListener('click', () => { + sidebar.classList.remove('mobile-open'); + mobileOverlay.classList.remove('active'); + }); + } + } + + filterTasksByCategoryHandler(category) { + this.currentCategory = category; + + // Clear specific task filter when switching categories + this.highlightedTaskId = null; + + // Update URL + this.updateURL(category); + + // Update sidebar active state + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.toggle('active', item.dataset.category === category); + }); + + this.renderTasks(); + } + + setupAutoRefresh() { + // Only refresh when tasks page is visible and every 30 seconds + this.refreshInterval = setInterval(() => { + // Only refresh if we're on the tasks page + if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) { + this.loadTasks(); + } + }, 30000); // 30 seconds instead of 5 + } + + setupGlobalFunctions() { + // Make functions available globally for onclick handlers + window.filterTasksByCategory = (category) => { + event.preventDefault(); + this.filterTasksByCategoryHandler(category); + }; + + // Delegated click handler for the Task ID and App links rendered inside + // each task row. Behaviour: + // * Task ID always lands on the global /tasks page with that task open. + // If we're already there, just push the URL and toggle the row open + // (no content reload); otherwise SPA-navigate to /tasks. + // * App link goes to that app's /app page on whatever its default tab + // is (we don't pin tab=… so the app's own logic picks one). + if (!window.__taskMetaLinksBound) { + window.__taskMetaLinksBound = true; + document.addEventListener('click', (e) => { + const link = e.target.closest('a.task-id-link, a.task-app-link'); + if (!link) return; + const href = link.getAttribute('href'); + if (!href) return; + e.preventDefault(); + e.stopPropagation(); + + const navigate = (url) => { + if (window.spaClean && typeof window.spaClean.navigate === 'function') { + window.spaClean.navigate(url); + } else { + window.location.href = url; + } + }; + + if (link.classList.contains('task-id-link')) { + const taskId = link.dataset.taskId; + const onTasksPage = window.location.pathname.startsWith('/tasks'); + if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') { + // Already on /tasks — soft-update the URL and open the row. + window.history.pushState({}, '', href); + if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId; + window.toggleTaskDetails(taskId); + } else { + // Coming from /app or anywhere else — go to the tasks page; its + // initializeFromURL picks up `task=` and auto-expands. + navigate(href); + } + } else { + // App link — go to the app page on its default tab. + // + // `task-parameter-preserve.js` stashes any `task=…` from the + // initial page URL into sessionStorage.pendingTaskId, and + // app-tabbed-manager's init reads that as a fallback when the + // current URL has no task param. Without clearing it here, an old + // task id from /tasks would hijack the app page, force-switching + // to the Tasks tab and opening that task. Clear it so the app + // page lands on its actual default tab. + try { sessionStorage.removeItem('pendingTaskId'); } catch {} + navigate(href); + } + }); + } + window.refreshTasks = () => this.refreshTasks(); + window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId); + window.retryTask = (taskId) => this.retryTask(taskId); + window.deleteTask = (taskId) => this.deleteTask(taskId); + window.clearAllTasks = () => this.clearAllTasks(); + window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId); + window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId); + window.closeTaskLogsModal = () => { + const modal = document.querySelector('.task-logs-modal'); + if (modal) { + // Stop all active streaming for this modal + this.activeLogStreams?.forEach((streamData, streamTaskId) => { + if (streamData.modal === modal) { + streamData.stream.stop(); + this.activeLogStreams.delete(streamTaskId); + } + }); + modal.remove(); + } + }; + + window.createAndExecuteTask = async (action, appName, config = '') => { + try { + // Create task using NEW signature + const task = await this.taskManager.createTask(`libreportal app ${action} ${appName}`, action, appName, config); + + // Emit task creation event for AppTabbedManager + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // Set up monitoring for this specific task + this.monitorTask(task.id, appName, action); + + // Show success notification with app icon and direct link + if (window.notificationSystem) { + const taskUrl = `/tasks?=all&task=${task.id}`; + const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon({ type: action })?.icon : ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Task created: ${action} ${appName}`, + 'info', + appName, + taskUrl, + `icons/apps/${appName}.svg`, + customIcon + ); + } + + return task; + } catch (error) { + console.error(`❌ Failed to create ${action} task for ${appName}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`); + } + throw error; + } + }; + } + + // Single page-wide subscription to the TaskEventBus. This complements + // monitorTask (which only fires for tasks created in this session) and + // ensures any visible row updates when its status changes — including the + // running -> completed/failed/cancelled transition that previously only + // refreshed when the user switched tabs and came back. + // + // The guard is on `window`, not `this`, because TasksManager is constructed + // in several places (system-loader, app-tabbed-manager, …). Without a + // global guard each instance would attach its own listener and the user + // would see N notifications for one task completion. + setupTaskBusListeners() { + if (window.__tasksManagerBusBound) return; + window.__tasksManagerBusBound = true; + + const upsertLocal = (task) => { + if (!task || !task.id) return; + const idx = this.tasks.findIndex(t => t.id === task.id); + if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task); + }; + + window.addEventListener('taskCreated', (e) => { + const task = e.detail && e.detail.task; + if (!task) return; + upsertLocal(task); + // Don't re-render the entire list here — `renderTasks` would blow away + // any open dropdown's DOM. A targeted update is enough; the next + // category switch / refresh will pull the new row in. + this.updateTaskDisplay(task); + }); + + window.addEventListener('taskUpdated', (e) => { + const task = e.detail && e.detail.task; + if (!task) return; + upsertLocal(task); + if (task.status === 'running') { + this.updateTaskStructure(task.id, task); + this.startLogStreaming(task.id, task); + } + this.updateTaskDisplay(task); + }); + + window.addEventListener('taskCompleted', (e) => { + const task = e.detail && e.detail.task; + const taskId = (task && task.id) || (e.detail && e.detail.taskId); + if (!taskId) return; + if (task) upsertLocal(task); + if (task) this.updateTaskDisplay(task); + + // Final render of any buffered log content, then stop the SSE listener. + const stream = this.activeLogStreams && this.activeLogStreams.get(taskId); + if (stream && typeof stream.render === 'function') stream.render(); + this.stopLogStreaming(taskId); + + // User-visible notification so they know without staring at the page. + // Layout matches the " task started!" format used by task-actions + // and backup-manager so started/completed look like a matched pair. + if (window.notificationSystem && task) { + const appName = task.app || null; + const action = task.type || 'task'; + const friendlyActionMap = { + 'install': 'Install', 'app-install': 'Install', + 'uninstall': 'Uninstall', 'restart': 'Restart', + 'start': 'Start', 'stop': 'Stop', + 'update': 'Update', 'rebuild': 'Rebuild', + 'backup': 'Backup', 'restore': 'Restore', + 'delete': 'Delete Backup', 'delete_all': 'Delete All Backups', + 'setup-config': 'Apply Configuration', + 'setup-finalize': 'Finalize Setup' + }; + let actionTitle = friendlyActionMap[action] || (action.charAt(0).toUpperCase() + action.slice(1)); + // Tool tasks: override the generic "Tool" label with the tool's + // friendly name (e.g. "Manage Shortcuts") so completion toasts + // match what the user clicked. + const toolCmdMatch = (task.command || '').match(/libreportal app tool (\S+) (\S+)/); + if (toolCmdMatch) { + const toolApp = toolCmdMatch[1]; + const toolId = toolCmdMatch[2]; + let toolLabel = null; + const cat = window.toolsCatalog; + if (cat && cat.apps && cat.apps[toolApp] && Array.isArray(cat.apps[toolApp].tools)) { + const t = cat.apps[toolApp].tools.find(x => x.id === toolId); + if (t && t.label) toolLabel = t.label; + } + if (!toolLabel) { + toolLabel = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); + } + actionTitle = toolLabel; + } + const isSystemTask = action.startsWith('setup-'); + const displayName = isSystemTask + ? 'LibrePortal' + : ((appName && window.getAppDisplayName) + ? window.getAppDisplayName(appName) + : (appName || (task.command || `Task ${taskId}`))); + const subjectLabel = isSystemTask ? 'System' : 'App'; + const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps'); + const url = (onAppPage && appName) + ? `/app?=${appName}&tab=tasks&task=${taskId}` + : `/tasks?=all&task=${taskId}`; + const icon = appName ? `icons/apps/${appName}.svg` : null; + + // Match the per-action emoji used in the task list rows (see + // `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the + // notification's leftmost icon slot shows the task *type* (install + // ✅, backup 💾, restore 📦, …) instead of the generic level tick. + const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task) : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + + let body; + let level; + if (task.status === 'completed') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task completed!`; + level = 'success'; + } else if (task.status === 'failed') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task failed.`; + level = 'error'; + } else if (task.status === 'cancelled') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task cancelled.`; + level = 'warning'; + } + if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon); + + // Belt-and-braces: when the completion notification fires we also + // tell the app-tabbed manager to re-enable that app's tabs and + // buttons. The taskCompleted listener inside app-tabbed-manager + // does this too, plus the 5s reconcile sweep — but routing it + // through here as well means any one path being broken or + // de-bound still leaves the user with a usable UI rather than + // permanently-disabled tabs. + if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') { + try { window.appTabbedManager.enableAppButtons(appName); } catch {} + } + } + }); + + window.addEventListener('taskDeleted', (e) => { + const id = e.detail && e.detail.id; + if (!id) return; + this.tasks = this.tasks.filter(t => t.id !== id); + const el = document.querySelector(`.task-item[data-task-id="${id}"]`); + if (!el) return; + const parent = el.parentElement; + el.remove(); + if (!parent || parent.querySelector('.task-item')) return; + if (parent.id === 'tasks-list') { + this.renderTasks(); + } else if (parent.id === 'app-tasks') { + const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || ''; + parent.innerHTML = `

    No tasks found for ${appName}.

    `; + } + }); + } + + // Load task logs on demand + async loadTaskLogs(taskId) { + try { + // console.log(`📋 Loading logs for task ${taskId}...`); + + // Show loading state + const detailsElement = document.getElementById(`details-${taskId}`); + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = '
    📋 Loading logs...
    '; + } + } + + // Load task data directly from task file + const task = this.tasks.find(t => t.id === taskId); + let output = ''; + + if (task) { + output = task.output || ''; + } else { + // Try to fetch task data directly + try { + const response = await fetch(`/api/tasks/${taskId}`); + if (response.ok) { + const taskData = await response.json(); + output = taskData.output || ''; + } + } catch (error) { + console.warn('Failed to fetch task data:', error); + } + } + + if (output && output.trim().length > 0) { + // Display the output + const logsHtml = output.split('\n').map(log => `
    ${this.parseAnsiColors(log)}
    `).join(''); + + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = ` +

    📋 Execution Logs

    +
    ${logsHtml}
    + `; + } + } + + // console.log(`✅ Loaded logs for task ${taskId}`); + } else { + // No logs available + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = '
    📋 No output available for this task.
    '; + } + } + + // console.log(`ℹ️ No logs available for task ${taskId}`); + } + + return output; + } catch (error) { + console.error(`❌ Failed to load logs for task ${taskId}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to load logs: ${error.message}`); + } + return ''; + } + } + + // Monitor a specific task. State changes now arrive via SSE through + // TaskEventBus, so this is mostly a hook for the UI to: + // - auto-expand the task if it's the one we just started + // - start log streaming when the task transitions to running + // - clean up local intervals when it terminates + // No more polling; the only fetches happen on-demand via TaskManager. + monitorTask(taskId, appName, action) { + if (!this.highlightedTaskId || this.highlightedTaskId === taskId) { + setTimeout(() => this.autoExpandTask(taskId), 1500); + } + + let statusUpdateInterval = null; + + const onUpdate = (event) => { + const t = event.detail && event.detail.task; + if (!t || t.id !== taskId) return; + + if (t.status === 'running') { + this.updateTaskStructure(taskId, t); + this.startLogStreaming(taskId, t); + if (this.highlightedTaskId === taskId && !statusUpdateInterval) { + statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000); + } + } else { + this.updateTaskDisplay(t); + } + }; + + const onComplete = (event) => { + if (!event.detail || event.detail.taskId !== taskId) return; + if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; } + this.stopLogStreaming(taskId); + window.removeEventListener('taskUpdated', onUpdate); + window.removeEventListener('taskCompleted', onComplete); + }; + + window.addEventListener('taskUpdated', onUpdate); + window.addEventListener('taskCompleted', onComplete); + } + + // SSE-driven log streaming. Subscribes to `taskLog` events from the bus and + // appends incoming chunks. Initial backlog is fetched once via the API. + // + // Resilient to DOM replacement: the logs container can be wiped out by + // `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle + // re-open). Instead of capturing a `preElement` reference once, `render()` + // re-locates / re-creates it on every call from the cumulative `buffered` + // string. Idempotent: if already streaming, a second call just re-renders. + async startLogStreaming(taskId, task) { + if (!this.activeLogStreams) this.activeLogStreams = new Map(); + + if (this.activeLogStreams.has(taskId)) { + const existing = this.activeLogStreams.get(taskId); + if (existing && typeof existing.render === 'function') existing.render(); + return; + } + + const state = { buffered: '' }; + + const render = () => { + const logsContainer = document.getElementById(`logs-${taskId}`); + if (!logsContainer) return; + const overlay = logsContainer.querySelector('div[style*="position: absolute"]'); + if (overlay) overlay.remove(); + let preElement = logsContainer.querySelector('pre.output-content'); + if (!preElement) { + logsContainer.innerHTML = ''; + preElement = document.createElement('pre'); + preElement.className = 'output-content terminal-style'; + logsContainer.appendChild(preElement); + } + const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; + if (state.buffered) { + preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered); + } else { + preElement.innerHTML = 'Waiting for logs...'; + } + if (atBottom) logsContainer.scrollTop = logsContainer.scrollHeight; + }; + + // Initial backlog via the API. + try { + const initial = await this.taskManager.readFullTaskLog(taskId); + if (initial && initial.length) state.buffered = initial; + } catch { /* fall through to placeholder */ } + render(); + + const onLog = (event) => { + const detail = event.detail || {}; + if (detail.id !== taskId || typeof detail.chunk !== 'string') return; + state.buffered += detail.chunk; + render(); + }; + window.addEventListener('taskLog', onLog); + + // SSE catch-up: when the backend restarts mid-task (e.g., libreportal + // recreates itself during a CrowdSec install), the SSE event source + // drops. EventSource auto-reconnects, but task.log events emitted + // during the gap are lost. taskBusReady fires after every reconnect — + // pull the missed bytes via the existing /:id/log?position=N endpoint + // and splice them in. Skips on the initial connect (no gap). + let initialReadyFired = false; + const onBusReady = async () => { + if (!initialReadyFired) { initialReadyFired = true; return; } + try { + const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/log?position=${state.buffered.length}`); + if (!res.ok) return; + const missed = await res.text(); + if (missed) { + state.buffered += missed; + render(); + } + } catch { /* network blip, next ready will retry */ } + }; + window.addEventListener('taskBusReady', onBusReady); + + this.activeLogStreams.set(taskId, { + stream: { stop: () => { + window.removeEventListener('taskLog', onLog); + window.removeEventListener('taskBusReady', onBusReady); + this.activeLogStreams.delete(taskId); + } }, + render, + isPaused: false + }); + } + + // Stop log streaming for a task + stopLogStreaming(taskId) { + if (this.activeLogStreams && this.activeLogStreams.has(taskId)) { + const streamData = this.activeLogStreams.get(taskId); + streamData.stream.stop(); + this.activeLogStreams.delete(taskId); + // console.log(`⏹️ Stopped log streaming for task ${taskId}`); + } + } + + // Update task structure for live logs + updateTaskStructure(taskId, task) { + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (!taskElement) return; + + const detailsElement = taskElement.querySelector('.task-details'); + if (!detailsElement) return; + + // console.log(`🔄 Updating task structure for ${taskId} to show simplified logs`); + + // Check if logs container already exists + const existingLogs = detailsElement.querySelector('.task-logs .log-container'); + if (existingLogs) { + // console.log(`🔄 Logs container already exists for ${taskId}`); + return; // Already exists, no need to update + } + + // Add simplified logs section for running tasks + if (task.status === 'running') { + const logsHtml = ` +
    +
    +
    Loading logs...
    +
    + + `; + + // Insert logs section at the bottom of details + detailsElement.insertAdjacentHTML('beforeend', logsHtml); + // console.log(`✅ Added simplified logs section for task ${taskId}`); + + // Auto-load logs + this.loadTaskLogs(taskId); + } + } + + // Update task display in real-time + updateTaskDisplay(task) { + const taskElement = document.querySelector(`[data-task-id="${task.id}"]`); + if (!taskElement) { + // The monitored task isn't always rendered (different tab, list filtered out, + // task already removed). Silently skip — this is the normal case. + return; + } + + // console.log(`🔄 Found task element:`, taskElement); + + // Update status and content + const statusElement = taskElement.querySelector('.task-status'); + const contentElement = taskElement.querySelector('.task-content'); + + if (statusElement) { + const statusClass = `status-${task.status || 'unknown'}`; + statusElement.className = `task-status ${statusClass}`; + statusElement.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`; + } else { + console.warn(`⚠️ Status element not found for task ${task.id}`); + } + + // Mirror the status into the details panel's metadata block too — that + // copy of the status was previously left stale until the page reloaded. + const detailsStatus = taskElement.querySelector(`#details-${task.id} .task-meta .status-running, #details-${task.id} .task-meta .status-queued, #details-${task.id} .task-meta .status-pending, #details-${task.id} .task-meta .status-completed, #details-${task.id} .task-meta .status-failed, #details-${task.id} .task-meta .status-cancelled, #details-${task.id} .task-meta [class^="status-"]`); + if (detailsStatus) { + detailsStatus.className = `status-${task.status || 'unknown'}`; + detailsStatus.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`; + } + + if (contentElement) { + contentElement.textContent = task.command; + } + + // console.log(`🔄 Updated task ${task.id} display: ${task.status}`); + } + + // Start global live log updater - simple 2-second updates for all running tasks + startGlobalLiveLogUpdater() { + // console.log(`🔄 Starting global live log updater`); + + // Update every 2 seconds + setInterval(async () => { + // console.log(`🔄 Global updater running - checking tasks...`); + + // Find all running tasks + const runningTasks = this.tasks.filter(task => task.status === 'running'); + // console.log(`🔄 Found ${runningTasks.length} running tasks:`, runningTasks.map(t => t.id)); + + if (runningTasks.length > 0) { + // console.log(`🔄 Updating live logs for ${runningTasks.length} running tasks`); + + // Update each running task's live logs + for (const task of runningTasks) { + // console.log(`🔄 About to update live logs for task ${task.id}`); + await this.updateLiveLogsSimple(task.id); + } + } else { + // console.log(`🔄 No running tasks found, skipping live log updates`); + } + }, 2000); // Every 2 seconds + } + + // Simple live log update - no complex polling logic + async updateLiveLogsSimple(taskId) { + const liveLogsElement = document.getElementById(`live-logs-${taskId}`); + if (!liveLogsElement) { + // console.log(`⚠️ Live logs element not found for task ${taskId}`); + return; // Silently skip if element not found + } + + try { + // console.log(`🔄 Reading log file for task ${taskId}`); + + // Read the log file content + const response = await fetch(`/read-file?path=tasks/${taskId}.log`); + // console.log(`🔄 Log file response status: ${response.status} for task ${taskId}`); + + if (response.ok) { + const logContent = await response.text(); + // console.log(`🔄 Log file content length: ${logContent.length} chars for task ${taskId}`); + // console.log(`🔄 First 100 chars of log content: "${logContent.substring(0, 100)}..."`); + + if (logContent.trim()) { + // Split into lines and display + const lines = logContent.split('\n').filter(line => line.trim()); + // console.log(`🔄 Displaying ${lines.length} log lines for task ${taskId}`); + + liveLogsElement.innerHTML = lines.map(line => + `
    ${this.parseAnsiColors(line)}
    ` + ).join(''); + // Auto-scroll to bottom + liveLogsElement.scrollTop = liveLogsElement.scrollHeight; + } else { + // console.log(`🔄 Log file is empty for task ${taskId}`); + liveLogsElement.innerHTML = '
    🔄 Waiting for logs...
    '; + } + } else { + console.warn(`⚠️ Failed to read log file for task ${taskId}: ${response.status}`); + liveLogsElement.innerHTML = '
    ⚠️ Unable to read logs
    '; + } + } catch (error) { + // Silently handle errors + console.warn(`⚠️ Error reading live logs for task ${taskId}:`, error); + liveLogsElement.innerHTML = '
    ❌ Error loading logs
    '; + } + } + + // Load task logs automatically + async loadTaskLogs(taskId) { + try { + const logsContainer = document.getElementById(`logs-${taskId}`); + if (!logsContainer) { + console.warn(`⚠️ Logs container not found for task ${taskId}`); + return; + } + + const task = this.tasks.find(t => t.id === taskId); + const inMemoryLog = (task && Array.isArray(task.log) && task.log.length > 0) ? task.log : null; + + const renderInMemory = () => { + if (!inMemoryLog) return false; + logsContainer.innerHTML = inMemoryLog + .map(line => `
    ${this.taskManager.parseAnsiColors(line)}
    `) + .join(''); + return true; + }; + + logsContainer.innerHTML = '
    🔄 Loading logs...
    '; + const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; + + const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`); + if (logResponse.ok) { + const logContent = await logResponse.text(); + if (logContent.trim()) { + logsContainer.innerHTML = `
    ${this.taskManager.parseAnsiColors(logContent)}
    `; + if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight; + return; + } + } + + if (renderInMemory()) return; + + logsContainer.innerHTML = '
    ℹ️ No logs available for this task.
    '; + + } catch (error) { + console.error(`❌ Error loading logs for task ${taskId}:`, error); + const logsContainer = document.getElementById(`logs-${taskId}`); + if (logsContainer) { + logsContainer.innerHTML = '
    ❌ Failed to load logs.
    '; + } + } + } + + // Load task output on demand + async loadTaskOutput(taskId) { + try { + // Read the task file to get output + const task = await this.taskManager.getTask(taskId); + if (!task) return; + + const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`); + if (!outputElement) return; + + // Show loading state + outputElement.innerHTML = ` +
    Loading output...
    + `; + + // Check if task has output + if (task.output && task.output.trim()) { + outputElement.innerHTML = ` +

    📤 Output

    +
    ${this.taskManager.parseAnsiColors(task.output)}
    + `; + } else if (task.error && task.error.trim()) { + outputElement.innerHTML = ` +

    ❌ Error

    +
    ${this.escapeHtml(task.error)}
    + `; + } else { + // Try to read from log file + const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`); + if (logResponse.ok) { + const logContent = await logResponse.text(); + if (logContent.trim()) { + outputElement.innerHTML = ` +
    ${this.taskManager.parseAnsiColors(logContent)}
    + `; + } else { + outputElement.innerHTML = ` +

    ℹ️ Information

    +
    No output available for this task.
    + `; + } + } else { + outputElement.innerHTML = ` +

    ℹ️ Information

    +
    No output available for this task.
    + `; + } + } + } catch (error) { + console.error(`❌ Error loading task output for ${taskId}:`, error); + const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`); + if (outputElement) { + outputElement.innerHTML = ` +

    ❌ Error

    +
    Failed to load task output: ${error.message}
    + `; + } + } + } + + // Auto-expand a task when it's created + async autoExpandTask(taskId) { + // console.log(`🔄 Auto-expanding task ${taskId}`); + + // Wait for task to be rendered + let attempts = 0; + const maxAttempts = 10; + + const tryExpand = async () => { + attempts++; + // console.log(`🔄 Auto-expand attempt ${attempts}/${maxAttempts} for task ${taskId}`); + + // Check if task element exists + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (!taskElement) { + // console.log(`⚠️ Task element not found for ${taskId}, attempt ${attempts}`); + if (attempts < maxAttempts) { + setTimeout(tryExpand, 500); // Try again in 500ms + } else { + console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`); + } + return; + } + + // console.log(`✅ Found task element for ${taskId}`); + + // Get the details element + const details = document.getElementById(`details-${taskId}`); + if (!details) { + // console.log(`⚠️ Details element not found for ${taskId}, attempt ${attempts}`); + if (attempts < maxAttempts) { + setTimeout(tryExpand, 500); + } else { + console.warn(`⚠️ Could not find details element for ${taskId}`); + } + return; + } + + // console.log(`✅ Found details element for ${taskId}`); + + // Expand the task details + details.style.display = 'block'; + details.classList.add('task-details-open'); + + // Update toggle button + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + if (toggleBtn) { + toggleBtn.classList.add('expanded'); + } + + // Scroll to the task + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Load the output if task is completed + const task = await this.taskManager.getTask(taskId); + if (task && (task.status === 'completed' || task.status === 'failed')) { + setTimeout(() => { + this.loadTaskOutput(taskId); + }, 1000); + } + + // console.log(`✅ Auto-expanded task ${taskId}`); + }; + + tryExpand(); + } + + // Update highlighted task status and UI + async updateHighlightedTaskStatus(taskId) { + try { + // Use lightweight summary for status updates + const task = await this.taskManager.getTaskSummary(taskId); + if (!task) return; + + // console.log(`🔄 Updating highlighted task ${taskId} status: ${task.status}`); + + // Update task display + this.updateTaskDisplay(task); + + // If task completed or failed, always load output + if ((task.status === 'completed' || task.status === 'failed')) { + // console.log(`🔄 Task ${taskId} is ${task.status}, loading output...`); + + const details = document.getElementById(`details-${taskId}`); + if (details && details.style.display === 'block') { + // Load output regardless of current content + this.loadTaskOutput(taskId); + } else if (details) { + // If details aren't open, mark for loading when opened + details.setAttribute('data-load-output-on-open', 'true'); + } + } + } catch (error) { + console.error(`❌ Error updating highlighted task status for ${taskId}:`, error); + } + } + + toggleTaskDetails(taskId) { + const details = document.getElementById(`details-${taskId}`); + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + + if (details) { + const isOpen = details.style.display === 'block'; + + // Close all other task details and reset their buttons + if (isOpen) { + document.querySelectorAll('.task-details').forEach(otherDetails => { + if (otherDetails.id !== `details-${taskId}`) { + otherDetails.style.display = 'none'; + otherDetails.classList.remove('task-details-open'); + } + }); + + document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => { + if (!otherBtn.getAttribute('onclick').includes(taskId)) { + otherBtn.classList.remove('expanded'); + } + }); + + // Close current + details.style.display = 'none'; + details.classList.remove('task-details-open'); + if (toggleBtn) toggleBtn.classList.remove('expanded'); + } else { + // Open current + details.style.display = 'block'; + details.classList.add('task-details-open'); + if (toggleBtn) toggleBtn.classList.add('expanded'); + + // Auto-load logs when opened. For active tasks, hand off to the live + // streamer so SSE chunks keep updating the panel; for terminal tasks + // a one-shot snapshot is enough. + const t = this.tasks.find(x => x.id === taskId); + if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) { + this.startLogStreaming(taskId, t); + } else { + this.loadTaskLogs(taskId); + } + + // Scroll to task + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (taskElement) { + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + // Update URL to include task parameter + this.updateURL(this.currentCategory, isOpen ? null : taskId); + this.highlightedTaskId = isOpen ? null : taskId; + } else { + // Remove task parameter from URL + this.updateURL(this.currentCategory); + this.highlightedTaskId = null; + } + } + + async retryTask(taskId) { + if (!confirm('Are you sure you want to retry this task?')) return; + + try { + const task = this.tasks.find(t => t.id === taskId); + if (!task) { + throw new Error('Task not found'); + } + + // Create a new task with the same command + const newTask = await this.taskManager.createTask( + task.command, + task.type, + task.app, + task.config + ); + + //// // console.log(`✅ Task retried: ${newTask.id}`); + + // Refresh tasks to show the new one + await this.loadTasks(); + + if (window.notificationSystem) { + // Use the source task's type icon — retrying a backup shows 💾 etc. + const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon); + } + } catch (error) { + console.error('Error retrying task:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to retry task: ${error.message}`); + } + } + } + + async deleteTask(taskId) { + // Get the latest known status before deciding what to do. The server + // refuses to delete tasks that are still running or queued (HTTP 409), + // and we used to just propagate that as a red banner — but the user is + // almost always trying to clean up a row that looked finished, or a + // genuinely active task they want gone. Either way the right answer is + // "cancel first, then delete", which is what we do here. + const cached = (window.taskEventBus && window.taskEventBus.getTask) + ? window.taskEventBus.getTask(taskId) + : null; + let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId)); + if (!task && this.taskManager && this.taskManager.getTask) { + try { task = await this.taskManager.getTask(taskId); } catch {} + } + + const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending'); + + const confirmed = await this._showDeleteTaskModal(task, isActive); + if (!confirmed) return; + + try { + if (isActive) { + // Ask the server to cancel. POST /cancel either flips status straight + // to `cancelled` (queued -> cancelled is synchronous) or drops a + // `.cancel` marker the bash processor picks up within ~1s + // (running -> cancelled). We then wait for the terminal status via + // SSE before issuing the delete. + try { await this.taskManager.cancelTask(taskId); } catch { + // If cancel itself failed (e.g. already terminal), fall through + // and try the delete anyway. + } + await this._waitForTaskTerminal(taskId, 15_000); + } + + // Try the polite delete first. If the task is still flagged as + // running/queued server-side (e.g. the bash processor is dead and + // never picked up the cancel marker), fall back to the force-delete + // override so the user isn't stuck with a permanently-undeletable row. + try { + await this.taskManager.deleteTask(taskId); + } catch (err) { + const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : ''); + if (!looks409) throw err; + await this.taskManager.deleteTask(taskId, { force: true }); + } + + // Remove from local array and re-render + this.tasks = this.tasks.filter(t => t.id !== taskId); + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + + if (window.notificationSystem) { + // Type icon from whatever the task was, with a trash fallback + // because deletion is conceptually a 🗑️ action. + const typeIcon = (task && this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '🗑️'; + const customIcon = `${typeIcon}`; + window.notificationSystem.show('Task deleted successfully', 'info', null, null, null, customIcon); + } + } catch (error) { + console.error('Error deleting task:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to delete task: ${error.message}`); + } + } + } + + // Confirmation modal for deleteTask. Uses the shared openEoModal so it + // visually matches every other destructive confirmation on the app + // (Uninstall, Apply Configuration, etc). Resolves true if the user + // confirms Delete, false on Cancel / backdrop click / close. + _showDeleteTaskModal(task, isActive) { + return new Promise((resolve) => { + const escHtml = (s) => String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + + const taskLabel = (task && (task.command || task.id)) || 'Unknown task'; + const taskStatus = (task && task.status) || 'unknown'; + + const warningTitle = isActive ? 'Active task' : 'This cannot be undone'; + const warningText = isActive + ? 'This task is still running or queued. It will be cancelled first, then deleted.' + : 'The task and its logs will be permanently removed.'; + + const bodyHtml = ` +
    +
    + + + + + +
    +
    +

    ${escHtml(warningTitle)}

    +

    ${escHtml(warningText)}

    +
    +
    + ${window.eoBadgeRow ? window.eoBadgeRow([ + { label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' } + ]) : ''} + `; + + let decided = false; + const finish = (val, modal) => { + if (decided) return; + decided = true; + if (modal) modal.close(); + resolve(val); + }; + + window.openEoModal({ + id: 'delete-task-modal', + size: 'sm', + eyebrow: 'Delete Task', + title: taskLabel, + desc: 'Confirm to delete this task.', + body: bodyHtml, + actions: [ + { label: 'Delete Task', variant: 'danger', onClick: (m) => finish(true, m) }, + { label: 'Cancel', variant: 'secondary', onClick: (m) => finish(false, m) } + ], + onClose: () => finish(false, null) + }); + }); + } + + // Resolves once the task reaches completed/failed/cancelled, or after the + // timeout. Used by deleteTask so a cancel request has time to take effect + // before we issue the DELETE that would otherwise 409. + _waitForTaskTerminal(taskId, timeoutMs = 15_000) { + return new Promise((resolve) => { + // Already terminal in the bus cache? No need to wait. + const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null; + if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) { + return resolve(cached); + } + + let timer; + const cleanup = () => { + window.removeEventListener('taskCompleted', onComplete); + window.removeEventListener('taskUpdated', onUpdate); + if (timer) clearTimeout(timer); + }; + const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled'; + const onComplete = (e) => { + const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null)); + const id = (t && t.id) || (e.detail && e.detail.taskId); + if (id !== taskId) return; + cleanup(); + resolve(t); + }; + const onUpdate = (e) => { + const t = e.detail && e.detail.task; + if (!t || t.id !== taskId) return; + if (isTerminal(t.status)) { cleanup(); resolve(t); } + }; + window.addEventListener('taskCompleted', onComplete); + window.addEventListener('taskUpdated', onUpdate); + timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs); + }); + } + + async clearAllTasks() { + // Use the confirmation dialog system if available, otherwise fallback to confirm + return new Promise((resolve) => { + if (window.showConfirmation) { + window.showConfirmation( + 'Clear All Tasks', + 'Are you sure you want to clear all tasks? This will delete all task history and cannot be undone.', + () => { + this.performClearAll(); + resolve(true); + }, + 'Yes, Clear All', + 'Cancel', + 'clear', + false + ); + } else { + // Fallback to native confirm + const confirmed = confirm('Are you sure you want to clear all tasks? This will delete all task history.'); + if (confirmed) { + this.performClearAll(); + } + resolve(confirmed); + } + }); + } + + async performClearAll() { + // Show progress notification with the trash type icon in the left slot + // (this is conceptually a delete-all action, so 🗑️ matches the row icon). + const customIcon = '🗑️'; + const progressNotification = window.notificationSystem.show( + 'Clearing all tasks...', + 'info', + null, + null, + null, + customIcon + ); + + try { + // Delete all tasks using the task manager + const deletePromises = this.tasks.map(task => + this.taskManager.deleteTask(task.id) + ); + + await Promise.all(deletePromises); + + // Clear local array and re-render + this.tasks = []; + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + + // Remove progress notification and show success + if (progressNotification && progressNotification.remove) { + progressNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.show( + 'All tasks cleared successfully', + 'success', + null, + null, + null, + customIcon + ); + } + } catch (error) { + console.error('Error clearing tasks:', error); + + // Remove progress notification and show error + if (progressNotification && progressNotification.remove) { + progressNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.show( + `Failed to clear tasks: ${error.message}`, + 'error', + null, + null, + null, + customIcon + ); + } + } + } + + getTimeAgo(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + showError(message) { + const container = document.getElementById('tasks-list'); + if (container) { + container.innerHTML = ` +
    +
    $ ${message}
    +
    _
    +
    + `; + } + } + + destroy() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } +} + +// Export for use in other modules +window.TasksManager = TasksManager; diff --git a/containers/libreportal/frontend/js/components/topbar.js b/containers/libreportal/frontend/js/components/topbar.js new file mode 100755 index 0000000..5a0ce05 --- /dev/null +++ b/containers/libreportal/frontend/js/components/topbar.js @@ -0,0 +1,399 @@ +// Topbar component functionality +class TopbarComponent { + constructor() { + this.init(); + } + + getCurrentPage() { + const path = window.location.pathname; + + //// // console.log('🔍 Topbar: Detecting page from path:', path); + + // PRIMARY: Use path-based detection only (most reliable) + // This avoids confusion from query parameters like tab=, app=, etc. + if (path.startsWith('/app') || path === '/app') { + return 'app'; + } + if (path.startsWith('/apps') || path === '/apps') { + return 'apps'; + } + if (path.startsWith('/config') || path === '/config') { + return 'config'; + } + if (path.startsWith('/tasks') || path === '/tasks') { + return 'tasks'; + } + if (path.startsWith('/backup') || path === '/backup') { + return 'backup'; + } + if (path === '/' || path === '/dashboard') { + return 'dashboard'; + } + + // Fallback to filename extraction for backward compatibility + const filename = path.split('/').pop() || 'dashboard.html'; + const pageType = filename.replace('.html', ''); + return pageType; + } + + // Static method to load topbar (only once in SPA) + static async loadTopbar() { + const container = document.getElementById('topbar-container'); + if (!container) { + console.error('Topbar container not found'); + return; + } + + // Load fresh topbar HTML + try { + //// // console.log('Loading topbar HTML (SPA mode)'); + const response = await fetch('html/topbar.html'); + if (!response.ok) { + throw new Error(`Failed to load topbar: ${response.status}`); + } + + const html = await response.text(); + container.innerHTML = html; + + // Initialize component + new TopbarComponent(); + + } catch (error) { + console.error('Error loading topbar:', error); + } + } + + init() { + this.setupNavigation(); + this.setActiveNav(); + this.setupThemeManager(); + this.setupLogout(); + this.setupConfigUpdateLockout(); + this.setupSetupGate(); + } + + // Disable nav items entirely until the Setup Wizard has been completed. + // The wizard itself runs as a full-screen overlay that blocks interaction; + // this is a belt-and-braces guard for the brief window before the wizard + // mounts, and for any nav rendering that happens while it's open. + async setupSetupGate() { + try { + const res = await fetch('/api/setup/status'); + if (!res.ok) return; + const { complete } = await res.json(); + const nav = document.querySelector('.topbar-nav'); + if (!nav) return; + if (!complete) { + nav.classList.add('setup-needed'); + } else { + nav.classList.remove('setup-needed'); + } + } catch { /* leave nav enabled if status check fails */ } + } + + // Disable App Center / Config nav while a config_update task runs. + setupConfigUpdateLockout() { + const setLocked = (locked) => { + ['nav-app-center', 'nav-config'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + if (locked) { + el.classList.add('nav-item-disabled'); + el.setAttribute('aria-disabled', 'true'); + el.title = 'Disabled while configuration is being applied'; + } else { + el.classList.remove('nav-item-disabled'); + el.removeAttribute('aria-disabled'); + el.title = ''; + } + }); + }; + + window.addEventListener('taskCreated', (event) => { + if (event.detail?.action === 'config_update') setLocked(true); + }); + window.addEventListener('taskCompleted', (event) => { + if (event.detail?.action === 'config_update') setLocked(false); + }); + } + + setupLogout() { + const btn = document.getElementById('logout-btn'); + if (btn) { + btn.addEventListener('click', () => window.authManager?.logout()); + } + } + + setupNavigation() { + // Add click handlers to navigation items + const navItems = document.querySelectorAll('.nav-item'); + navItems.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + if (item.classList.contains('nav-item-disabled')) return; + const href = item.getAttribute('href'); + if (href) { + // Special handling for tasks navigation + if (item.id === 'nav-tasks' && window.tasksManager) { + // console.log('🔄 Tasks button clicked - forcing clean state and reloading...'); + + // Force clean state + window.tasksManager.highlightedTaskId = null; + window.tasksManager.currentCategory = 'all'; + window.tasksManager.tasks = []; // Clear the task list + + // Clear URL parameters + window.history.pushState({ category: 'all', taskId: null }, '', '/tasks?=all'); + + // Clear any localStorage filters + localStorage.removeItem('tasksDefaultFilter'); + + // Force reload all tasks + window.tasksManager.loadTasks().catch(error => { + console.warn('⚠️ Error refreshing tasks:', error); + }); + } + + // Use shared navigation utility + navigateToRoute(href); + } + }); + }); + + // Setup mobile menu + this.setupMobileMenu(); + } + + setupMobileMenu() { + const mobileMenuBtn = document.getElementById('mobile-menu-btn'); + const mobileOverlay = document.getElementById('mobile-overlay'); + const sidebar = document.getElementById('sidebar'); + + if (mobileMenuBtn && mobileOverlay) { + mobileMenuBtn.addEventListener('click', () => { + sidebar.classList.toggle('mobile-open'); + mobileOverlay.classList.toggle('active'); + document.body.style.overflow = sidebar.classList.contains('mobile-open') ? 'hidden' : ''; + }); + + mobileOverlay.addEventListener('click', () => { + sidebar.classList.remove('mobile-open'); + mobileOverlay.classList.remove('active'); + document.body.style.overflow = ''; + }); + } + } + + setupThemeManager() { + const themeSelector = document.getElementById('theme-selector'); + if (!themeSelector) return; + + const savedTheme = TopbarComponent.resolveSavedTheme(); + this.setTheme(savedTheme); + + // Populate the dropdown from ThemeRegistry. Called twice — once + // synchronously with the built-in fallback list, then again after + // the API discovery resolves with any custom themes. + const renderOptions = (themes) => { + const current = localStorage.getItem('theme') || savedTheme; + themeSelector.innerHTML = ''; + themes.forEach((t) => { + const opt = document.createElement('option'); + opt.value = t.name; + opt.textContent = t.displayName || t.name; + themeSelector.appendChild(opt); + }); + // If the saved theme is now in the list, select it; otherwise + // leave whatever the browser chose as the default selection. + if (themes.some((t) => t.name === current)) { + themeSelector.value = current; + } + }; + + if (window.ThemeRegistry && typeof window.ThemeRegistry.onChange === 'function') { + window.ThemeRegistry.onChange(renderOptions); + } else { + // ThemeRegistry didn't load — fall back to the built-in three. + renderOptions([ + { name: 'nebula', displayName: 'Nebula' }, + { name: 'dark-blue', displayName: 'Dark Blue' }, + { name: 'light', displayName: 'Light' }, + ]); + } + + themeSelector.addEventListener('change', (e) => { + this.setTheme(e.target.value); + }); + } + + // Reads localStorage, migrates legacy values, and returns the canonical + // theme name. Old "dark" / "blue" both map to the new "dark-blue". + // An earlier dead initializer wrote to "selectedTheme" — fold that in + // and drop it. We don't validate against a fixed list anymore because + // custom themes are discovered at runtime — any string is accepted. + static resolveSavedTheme() { + const legacy = localStorage.getItem('selectedTheme'); + if (legacy && !localStorage.getItem('theme')) { + localStorage.setItem('theme', legacy); + } + if (legacy) localStorage.removeItem('selectedTheme'); + + let theme = localStorage.getItem('theme'); + if (theme === 'dark' || theme === 'blue') { + theme = 'dark-blue'; + localStorage.setItem('theme', theme); + } + if (!theme) { + theme = 'nebula'; + localStorage.setItem('theme', theme); + } + return theme; + } + + setActiveNav() { + // Remove active class from all nav items + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + }); + + // Use path-based detection only for consistency + const path = window.location.pathname; + + let activeNavId; + + // PRIMARY: Use path-based detection only (most reliable) + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeNavId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeNavId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeNavId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeNavId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeNavId = 'nav-dashboard'; + } else { + // Fallback to page detection + if (this.currentPage === 'dashboard') { + activeNavId = 'nav-dashboard'; + } else if (this.currentPage === 'index' || this.currentPage === 'app' || this.currentPage === 'apps') { + activeNavId = 'nav-app-center'; + } else if (this.currentPage === 'config') { + activeNavId = 'nav-config'; + } else if (this.currentPage === 'tasks') { + activeNavId = 'nav-tasks'; + } else if (this.currentPage === 'backup') { + activeNavId = 'nav-backup'; + } else { + activeNavId = 'nav-dashboard'; // default + } + } + + // Add active class to current page nav + if (activeNavId) { + const activeNav = document.getElementById(activeNavId); + if (activeNav) { + activeNav.classList.add('nav-active'); + } else { + console.warn(`❌ Nav element not found: ${activeNavId}`); + } + } else { + console.warn(`❌ Could not determine active nav for path: ${path}`); + } + } + + setTheme(theme) { + localStorage.setItem('theme', theme); + document.documentElement.setAttribute('data-theme', theme); + document.body.setAttribute('data-theme', theme); + } + + static clearAllNavigationHighlighting() { + //// // console.log('🧹 Aggressively clearing all navigation highlighting'); + + // Remove ALL active classes from ALL navigation items + document.querySelectorAll('.nav-item').forEach(item => { + //// // console.log('🧹 Clearing nav item:', item.id, item.textContent.trim()); + item.classList.remove('active'); + item.classList.remove('nav-active'); + // Also remove any other possible active states + item.removeAttribute('aria-current'); + item.blur(); // Remove focus + }); + + // Also clear other active classes that might interfere + document.querySelectorAll('.category.active').forEach(item => { + item.classList.remove('active'); + }); + document.querySelectorAll('.tab-button.active').forEach(item => { + item.classList.remove('active'); + }); + + //// // console.log('🧹 Cleared all navigation highlighting'); + } + + static createNavigationHighlighting() { + // Define the single function that handles navigation highlighting + window.topbarNavigationHighlighting = function() { + // Always clear all existing navigation first to prevent sticky highlighting + TopbarComponent.clearAllNavigationHighlighting(); + + // PRIMARY: Use path-based detection only (most reliable) + // This avoids confusion from query parameters like tab=, app=, etc. + const path = window.location.pathname; + let activeNavId; + + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeNavId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeNavId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeNavId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeNavId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeNavId = 'nav-dashboard'; + } else { + // Fallback: use currentPage detection + const currentPage = new TopbarComponent().getCurrentPage(); + switch (currentPage) { + case 'index.html': + case '': + case 'index': + case 'app': + case 'apps': + activeNavId = 'nav-app-center'; + break; + case 'config': + activeNavId = 'nav-config'; + break; + case 'tasks': + activeNavId = 'nav-tasks'; + break; + case 'backup': + activeNavId = 'nav-backup'; + break; + case 'dashboard': + activeNavId = 'nav-dashboard'; + break; + default: + activeNavId = 'nav-dashboard'; + } + } + + // Add active class to current page nav + if (activeNavId) { + const activeNav = document.getElementById(activeNavId); + if (activeNav) { + activeNav.classList.add('nav-active'); + } else { + console.warn(`❌ Topbar: Nav element not found: ${activeNavId}`); + } + } else { + console.warn(`❌ Topbar: Could not determine active nav for path: ${path}`); + } + }; + + //// // console.log('🌐 Topbar navigation highlighting function created'); + } +} diff --git a/containers/libreportal/frontend/js/spa.js b/containers/libreportal/frontend/js/spa.js new file mode 100755 index 0000000..6d6d4b4 --- /dev/null +++ b/containers/libreportal/frontend/js/spa.js @@ -0,0 +1,550 @@ +// Clean SPA Router - Unified routing system for LibrePortal +class LibrePortalSPAClean { + constructor() { + this.routes = new Map(); + this.currentRoute = null; + this.isLoading = false; + this.dataLoaded = false; + this.apps = []; + this.categories = []; + this.init(); + } + + async init() { + //console.log('🚀 Clean SPA: Initializing...'); + + // Setup routes immediately + this.setupRoutes(); + + // Wait for DOM to be ready + if (document.readyState === 'loading') { + await new Promise(resolve => { + document.addEventListener('DOMContentLoaded', resolve); + }); + } + + // Wait for topbar to load first + await this.waitForTopbar(); + + // Load data first + await this.loadCoreData(); + + // Handle initial route + this.handleInitialRoute(); + + //console.log('✅ Clean SPA: Initialization complete'); + } + + async waitForTopbar() { + //console.log('⏳ Waiting for topbar to load...'); + + // Wait for topbar component to be available and loaded + let attempts = 0; + const maxAttempts = 20; // 2 seconds max (reduced from 5 seconds) + + while (attempts < maxAttempts) { + if (typeof TopbarComponent !== 'undefined' && TopbarComponent.loadTopbar) { + try { + await TopbarComponent.loadTopbar(); + //console.log('✅ Topbar loaded successfully'); + return; + } catch (error) { + console.warn('⚠️ Topbar loading failed, retrying...', error); + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + console.warn('⚠️ Topbar failed to load after 2 seconds, continuing without topbar'); + // Don't block the entire app if topbar fails + } + + setupRoutes() { + // Clean route definitions with explicit handlers + this.routes.set('/', () => this.handleDashboard()); + this.routes.set('/dashboard', () => this.handleDashboard()); + this.routes.set('/apps', () => this.handleApps()); + this.routes.set('/app', () => this.handleAppDetail()); // Handle /app without query + this.routes.set('/app*', () => this.handleAppDetail()); // Handle /app with query + this.routes.set('/config', () => this.handleConfig()); // Handle /config without query + this.routes.set('/config*', () => this.handleConfig()); // Handle /config with query + this.routes.set('/tasks', () => this.handleTasks()); // Handle /tasks without query + this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query + this.routes.set('/backup', () => this.handleBackup()); + this.routes.set('/backup*', () => this.handleBackup()); + + //console.log('📍 Routes registered:', Array.from(this.routes.keys())); + } + + async loadCoreData() { + //console.log('📊 Loading core data...'); + + try { + // Load apps + if (typeof DataLoader !== 'undefined' && DataLoader.loadApps) { + this.apps = await DataLoader.loadApps(); + window.apps = this.apps; + //console.log(`📱 Loaded ${this.apps.length} apps`); + } + + // Load categories + if (typeof DataLoader !== 'undefined' && DataLoader.loadCategories) { + this.categories = await DataLoader.loadCategories(); + window.categories = this.categories; + window.sidebarCategories = this.categories; // Ensure this is always available + //console.log(`📂 Loaded ${Object.keys(this.categories).length} categories`); + } + + this.dataLoaded = true; + //console.log('✅ Core data loaded successfully'); + + } catch (error) { + console.error('❌ Failed to load core data:', error); + this.showError('Failed to load application data'); + } + } + + handleInitialRoute() { + const path = window.location.pathname + window.location.search; + // console.log('🎯 SPA: Handling initial route:', path); + + // Handle root path - redirect to dashboard + if (path === '/' || path === '') { + // console.log('🏠 SPA: Redirecting to dashboard'); + this.navigate('/dashboard', false); + } else { + // console.log('🔀 SPA: Navigating to:', path); + this.navigate(path, false); // Don't add to history for initial load + } + } + + async navigate(path, addToHistory = true) { + // console.log('🚀 SPA: navigate called with:', path, 'addToHistory:', addToHistory); + + if (this.isLoading) { + // console.log('⏳ Navigation already in progress, ignoring:', path); + return; + } + + if (this.currentRoute === path && addToHistory) { + //console.log('🔄 Same route, skipping navigation:', path); + return; + } + + // Unsaved-config guard — an app's config panel registers + // window.__appConfigNavGuard while it has unsaved changes, so it can + // intercept navigation away (Apply / Discard / Stay). + if (typeof window.__appConfigNavGuard === 'function') { + try { + const decision = await window.__appConfigNavGuard(path); + if (decision === 'stay') return; + } catch (e) { + console.error('Nav guard error:', e); + } + } + + // Force data reload when navigating to dashboard, even if same route + if (path === '/dashboard' || path === '/') { + // console.log('🔄 Dashboard navigation detected, forcing data reload'); + // Trigger dashboard data reload after a short delay to ensure DOM is ready + setTimeout(() => { + if (typeof loadDashboardData === 'function') { + loadDashboardData(); + } + }, 100); + } + + this.isLoading = true; + //console.log('🧭 Navigating to:', path); + + try { + // Update browser history + if (addToHistory) { + history.pushState({ route: path }, '', path); + } + + this.currentRoute = path; + + // Find and execute route handler + const handler = this.findRouteHandler(path); + if (handler) { + await handler(); + } else { + console.warn('❌ No handler found for:', path); + this.showError('Page not found'); + } + + // Update navigation highlighting after content loads + if (typeof window.topbarNavigationHighlighting === 'function') { + //console.log('🔗 SPA: Updating navigation highlighting after navigation'); + window.topbarNavigationHighlighting(); + } + + } catch (error) { + console.error('❌ Navigation error:', error); + this.showError('Navigation failed'); + } finally { + this.isLoading = false; + } + } + + findRouteHandler(path) { + //console.log('🔍 Finding handler for path:', path); + + // Exact match first + if (this.routes.has(path)) { + //console.log('✅ Exact match found:', path); + return this.routes.get(path); + } + + // Handle query parameters - strip them for matching + let basePath = path; + if (path.includes('?')) { + basePath = path.split('?')[0]; + } + + //console.log('🔍 Checking base path:', basePath, 'for full path:', path); + + // Check base path against routes + if (this.routes.has(basePath)) { + //console.log('✅ Base path match found:', basePath); + return this.routes.get(basePath); + } + + // Wildcard matching for remaining cases + for (const [route, handler] of this.routes) { + if (route.includes('*')) { + const pattern = route.replace('*', ''); + if (basePath.startsWith(pattern)) { + //console.log('✅ Wildcard match:', pattern, 'for base path:', basePath); + return handler; + } + } + } + + //console.log('❌ No handler found for:', path); + //console.log('❌ Base path:', basePath); + //console.log('❌ Available routes:', Array.from(this.routes.keys())); + return null; + } + + async handleDashboard() { + // console.log('🏠 SPA: Loading dashboard...'); + + try { + // console.log('📄 SPA: Fetching dashboard content...'); + const html = await this.fetchContent('/html/dashboard-content.html'); + // console.log('📄 SPA: Dashboard content fetched, loading...'); + this.loadContent(html, 'Dashboard'); + // console.log('📄 SPA: Dashboard content loaded'); + + // Dashboard should already be initialized by SystemLoader + if (typeof loadInstalledApps === 'function') { + // console.log('📱 SPA: Loading installed apps...'); + loadInstalledApps(); + } + } catch (error) { + console.error('❌ Dashboard load error:', error); + this.showError('Failed to load dashboard'); + } + } + + async handleBackup() { + try { + const html = await this.fetchContent('/html/backup-content.html'); + this.loadContent(html, 'Backups'); + if (typeof BackupPage !== 'undefined') { + window.backupPage = new BackupPage(); + await window.backupPage.init(); + } else { + console.error('BackupPage class not loaded'); + } + } catch (error) { + console.error('❌ Backup page load error:', error); + this.showError('Failed to load backup page'); + } + } + + async handleApps() { + //console.log('📱 Loading apps...'); + + // Handle query parameters for apps + const path = window.location.pathname + window.location.search; + if (path.includes('?=')) { + const [basePath, query] = path.split('?='); + window.appsCategory = query || 'all'; + } else if (path.includes('?')) { + const url = new URL(path, window.location.origin); + const searchParams = url.searchParams; + window.appsCategory = searchParams.get('apps') || 'all'; + } else { + window.appsCategory = 'all'; + } + + try { + // Ensure unified layout is loaded (like the old SPA) + if (!document.querySelector('.apps-layout')) { + //console.log('📄 Loading apps layout HTML...'); + const html = await this.fetchContent('/html/apps-unified-layout.html'); + this.loadContent(html, 'Applications'); + } else { + //console.log('📄 Apps layout already exists, skipping HTML load'); + } + + // Apps manager should already be initialized by SystemLoader + if (window.appsManager) { + //console.log('✅ AppsManager already initialized by SystemLoader'); + await window.appsManager.initialize(); + + //console.log('✅ Apps loaded successfully'); + } else { + console.error('AppsManager not available - SystemLoader should have initialized it'); + throw new Error('AppsManager not initialized by SystemLoader'); + } + } catch (error) { + console.error('❌ Apps load error:', error); + this.showError('Failed to load applications: ' + error.message); + } + } + + async handleAppDetail() { + //console.log('🔍 Loading app detail...'); + + // Extract app name from URL + const url = new URL(window.location); + let appName = url.searchParams.get('app'); + + // Handle old format ?=appname&tab=tabname + if (!appName && url.search.includes('?=')) { + const queryPart = url.search.replace('?', ''); + const parts = queryPart.split('&'); + for (const part of parts) { + if (part.startsWith('=')) { + appName = part.substring(1); // Remove the '=' + break; + } + } + } + + //console.log('🔍 Parsed app name:', appName, 'from URL:', url.search); + + if (!appName) { + this.navigate('/apps', false); + return; + } + + try { + const html = await this.fetchContent('/html/apps-unified-layout.html'); + this.loadContent(html, appName); // Will be updated after app data loads + + // AppTabbedManager should already be initialized by SystemLoader + if (window.appTabbedManager) { + // console.log('✅ AppTabbedManager already initialized by SystemLoader'); + await window.appTabbedManager.initialize(); + } else { + console.error('AppTabbedManager not available - SystemLoader should have initialized it'); + throw new Error('AppTabbedManager not initialized by SystemLoader'); + } + + //console.log('✅ App detail loaded:', appName); + } catch (error) { + console.error('❌ App detail load error:', error); + this.showError('Failed to load application details'); + } + } + + async handleConfig() { + //console.log('⚙️ Loading config...'); + + // Handle query parameters for config + const path = window.location.pathname + window.location.search; + if (path.includes('?=')) { + const [basePath, query] = path.split('?='); + window.configCategory = query || 'general'; + } else if (path.includes('?')) { + const url = new URL(path, window.location.origin); + const searchParams = url.searchParams; + window.configCategory = searchParams.get('config') || 'general'; + } else { + window.configCategory = 'general'; + } + + try { + const html = await this.fetchContent('/html/config-content.html'); + this.loadContent(html, 'Configuration'); + + // Config manager should already be initialized by SystemLoader + if (window.configManager) { + // Render the actual configuration + if (typeof window.configManager.renderConfig === 'function') { + await window.configManager.renderConfig(window.configCategory || 'general'); + } + //console.log('✅ Config loaded'); + } else { + console.error('ConfigManager not available - SystemLoader should have initialized it'); + throw new Error('ConfigManager not initialized by SystemLoader'); + } + } catch (error) { + console.error('❌ Config load error:', error); + this.showError('Failed to load configuration'); + } + } + + async handleTasks() { + //console.log('📋 Loading tasks...'); + + try { + const html = await this.fetchContent('/html/tasks-content.html'); + this.loadContent(html, 'Tasks'); + + // Tasks manager should already be initialized by SystemLoader + if (window.tasksManager) { + //console.log('✅ TasksManager already initialized by SystemLoader'); + await window.tasksManager.init(); + } else { + console.warn('⚠️ TasksManager not available yet, task functionality will be limited'); + // Don't throw error - just show warning and continue + // The task system will be available when the user actually interacts with tasks + } + } catch (error) { + console.error('❌ Tasks load error:', error); + this.showError('Failed to load tasks'); + } + } + + async fetchContent(url) { + //console.log('📥 Fetching:', url); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.text(); + } + + async loadScript(src) { + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + return; // Already loaded + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + loadContent(html, title) { + const container = document.getElementById('main-content') || document.querySelector('.main'); + if (!container) { + throw new Error('Content container not found'); + } + + container.innerHTML = html; + document.title = `${title} - LibrePortal`; + + // Update navigation highlighting + this.updateNavigation(); + } + + updateNavigation() { + //console.log('🔗 SPA: Using fallback navigation logic'); + + // Remove ALL active classes + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + item.classList.remove('nav-active'); + }); + + // Use path-based detection only for consistency + const path = window.location.pathname; + + let activeId = 'nav-dashboard'; // default + + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeId = 'nav-dashboard'; + } + + //console.log('🔗 SPA: Setting active nav:', activeId); + const activeElement = document.getElementById(activeId); + if (activeElement) { + activeElement.classList.add('nav-active'); + } + } + + showError(message) { + const container = document.getElementById('main-content') || document.querySelector('.main'); + if (container) { + container.innerHTML = ` +
    +

    Error

    +

    ${message}

    + +
    + `; + } + } +} + +// Global navigation function for click handlers +window.navigateToRoute = function(href) { + if (window.spaClean) { + //console.log('🔗 Converting href:', href); + + // Convert href to clean path - handle various formats + let route = href.replace('.html', '').replace('./', '').replace(/^\//, ''); + + // Handle special cases + if (route === '' || route === 'index') { + route = '/apps'; // index goes to apps (main app center) + } else if (route === 'dashboard') { + route = '/dashboard'; + } else if (route === 'apps') { + route = '/apps'; + } else if (route === 'config') { + route = '/config?=general'; + } else if (route === 'tasks') { + route = '/tasks'; + } else if (!route.startsWith('/')) { + route = '/' + route; + } + + //console.log('🎯 Final route:', route); + window.spaClean.navigate(route); + } +}; + +// Handle browser back/forward +window.addEventListener('popstate', (e) => { + if (e.state && e.state.route && window.spaClean) { + window.spaClean.navigate(e.state.route, false); + } +}); + +// Handle internal link clicks +document.addEventListener('click', (e) => { + const target = e.target.closest('a'); + if (target && target.href) { + const href = target.getAttribute('href'); + if (href && (href.startsWith('/') || href.startsWith('./'))) { + e.preventDefault(); + window.navigateToRoute(href); + } + } +}); + +// SPA initialization is now handled by SystemLoader +// LibrePortalSPAClean instance will be created centrally diff --git a/containers/libreportal/frontend/js/system/auth-manager.js b/containers/libreportal/frontend/js/system/auth-manager.js new file mode 100755 index 0000000..cbe21dd --- /dev/null +++ b/containers/libreportal/frontend/js/system/auth-manager.js @@ -0,0 +1,177 @@ +class AuthManager { + constructor() { + this.isAuthenticated = false; + this.username = null; + this._resolveLogin = null; + this._overlayEl = null; + } + + async initialize() { + const status = await this._checkStatus(); + if (status.authenticated) { + this.isAuthenticated = true; + this.username = status.username; + return; + } + return this._showLoginOverlay(); + } + + async _checkStatus() { + try { + const res = await fetch('/api/auth/status'); + return await res.json(); + } catch { + return { authenticated: false }; + } + } + + async login(username, password) { + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + if (res.ok && data.success) { + this.isAuthenticated = true; + this.username = data.username; + this._hideLoginOverlay(); + if (this._resolveLogin) this._resolveLogin(); + return { success: true }; + } + return { success: false, error: data.error || 'Invalid credentials' }; + } catch { + return { success: false, error: 'Connection error' }; + } + } + + async logout() { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch { /* ignore */ } + window.location.reload(); + } + + _showLoginOverlay() { + return new Promise(resolve => { + this._resolveLogin = resolve; + + const overlay = document.createElement('div'); + overlay.className = 'login-overlay aurora-bg aurora-static'; + overlay.innerHTML = ` + + + + + + `; + + // Add to page + document.body.appendChild(this.container); + this.isVisible = true; + + // Get references to elements + this.progressBar = document.getElementById('progress-fill'); + this.systemStatusContainer = document.getElementById('system-status-container'); + this.errorMessage = null; + this.retryButton = document.getElementById('retry-button'); + this.continueButton = document.getElementById('continue-button'); + + // Initialize system cards + this.initializeSystemCards(); + } + + // Initialize system status cards + initializeSystemCards() { + const systems = [ + { id: 'core', name: 'Core System', icon: '🪐' }, + { id: 'data', name: 'Data Loading', icon: '📊' }, + { id: 'components', name: 'UI Components', icon: '🎨' }, + { id: 'task', name: 'Task System', icon: '📋' }, + { id: 'managers', name: 'System Managers', icon: '🔧' }, + { id: 'backend', name: 'Backend Services', icon: '🌐' }, + { id: 'config-validation', name: 'Config Files', icon: '🔍' }, + { id: 'update-lock', name: 'Update Lock', icon: '🔄' }, + { id: 'pause-lock', name: 'Pause Lock', icon: '⏸️' } + ]; + + systems.forEach(system => { + const card = document.createElement('div'); + card.className = 'system-card'; + card.id = `system-card-${system.id}`; + card.innerHTML = ` +
    ${system.icon}
    +
    +
    ${system.name}
    +
    Waiting...
    +
    +
    +
    +
    + `; + + this.systemStatusContainer.appendChild(card); + this.systemCards.set(system.id, card); + }); + } + + // Update progress + updateProgress(progress, details = '') { + if (this.progressBar) { + this.progressBar.style.width = `${progress}%`; + } + + const percentageElement = document.getElementById('progress-percentage'); + if (percentageElement) { + percentageElement.textContent = `${Math.round(progress)}%`; + } + + const detailsElement = document.getElementById('progress-details'); + if (detailsElement) { + detailsElement.textContent = details || `${Math.round(progress)}% complete`; + } + + // Update loading tip + this.updateLoadingTip(progress); + } + + // Process message to convert backticks to styled code blocks + processMessage(message) { + if (!message) return message; + + // Convert backtick-wrapped text to styled code blocks + return message.replace(/`([^`]+)`/g, '$1'); + } + + // Update system check status + updateSystemCheck(systemId, checkName, status, error = null, message = null) { + const card = this.systemCards.get(systemId); + if (!card) return; + + const statusElement = card.querySelector('.system-status'); + const indicatorElement = card.querySelector('.status-icon'); + + if (!statusElement || !indicatorElement) return; + + switch (status) { + case 'checking': + statusElement.textContent = `Checking: ${checkName}`; + indicatorElement.textContent = '⏳'; + card.className = 'system-card checking'; + // Auto-scroll to this card when checking starts + this.scrollToCard(card); + break; + + case 'retrying': + statusElement.innerHTML = `Retrying: ${checkName}`; + indicatorElement.textContent = '🔄'; + card.className = 'system-card retrying'; + // Don't auto-scroll during retry to prevent jumping around + if (message) { + statusElement.title = this.processMessage(message); + } + break; + + case 'waiting': + statusElement.textContent = `Waiting: ${checkName}`; + indicatorElement.textContent = '⏰'; + card.className = 'system-card waiting'; + break; + + case 'passed': + statusElement.textContent = 'Operational'; + indicatorElement.textContent = '✅'; + card.className = 'system-card passed'; + break; + + case 'failed': + statusElement.textContent = `Failed: ${checkName}`; + indicatorElement.textContent = '❌'; + card.className = 'system-card failed'; + if (error) { + statusElement.title = error; + } + break; + + case 'skipped': + statusElement.textContent = 'Skipped'; + indicatorElement.textContent = '⏭️'; + card.className = 'system-card skipped'; + break; + } + } + + // Scroll to specific card smoothly + scrollToCard(card) { + if (!card || !this.systemStatusContainer) return; + + const containerHeight = this.systemStatusContainer.clientHeight; + const cardHeight = card.offsetHeight; + const cardOffsetTop = card.offsetTop; + const containerScrollHeight = this.systemStatusContainer.scrollHeight; + + // Calculate target scroll position to center the card + let targetScrollTop = cardOffsetTop - (containerHeight / 2) + (cardHeight / 2); + + // Ensure we don't scroll past the bottom + const maxScrollTop = containerScrollHeight - containerHeight; + if (targetScrollTop > maxScrollTop) { + targetScrollTop = maxScrollTop; + } + + // Ensure we don't scroll before the top + if (targetScrollTop < 0) { + targetScrollTop = 0; + } + + // Smooth scroll to the target position + this.systemStatusContainer.scrollTo({ + top: targetScrollTop, + behavior: 'smooth' + }); + } + + // Update loading tip based on progress + updateLoadingTip(progress) { + const tipElement = document.getElementById('loading-tip'); + if (!tipElement) return; + + const tips = [ + 'Loading essential components...', + 'Preparing your workspace...', + 'Checking system dependencies...', + 'Initializing data connections...', + 'Almost ready...', + 'Finalizing launch...' + ]; + + const tipIndex = Math.floor((progress / 100) * tips.length); + const tip = tips[Math.min(tipIndex, tips.length - 1)]; + + if (tipElement.textContent !== tip) { + tipElement.textContent = tip; + } + } + + // Show error message + showError(errors) { + // console.log('🔍 LoadingUI.showError called with errors:', errors.length); + // console.log('🔍 Existing error details elements:', document.querySelectorAll('.error-details').length); + // console.log('🔍 Call stack:', new Error().stack); + + const actionsContainer = document.getElementById('loading-actions'); + + // Check if this is a missing data files issue + const hasMissingDataFiles = errors.some(error => + error.error && (error.error.includes('Missing required data file') || + error.error.includes('Critical configuration files are missing or empty')) + ); + + // Create error details + const errorDetails = document.createElement('div'); + errorDetails.className = 'error-details'; + + if (hasMissingDataFiles) { + // Special handling for missing data files + errorDetails.innerHTML = ` +

    🔧 LibrePortal Setup Required

    +
    +

    ❌ Required data files are missing!

    +

    LibrePortal needs to generate JSON configuration files before it can run properly.

    +
    + libreportal webui generate all +
    +

    + Run this command in your terminal to fix the issue. +

    +
    +
    +
      + ${errors.map(error => ` +
    • + ${error.system || error.checkName || 'Unknown'}: +
      ${(error.error || 'Check failed').replace(/\n\n/g, '

      ').replace(/\n/g, '
      ')}
      +
    • + `).join('')} +
    +
    +

    After running the setup command, refresh this page to continue.

    + `; + } else { + // Regular error handling + errorDetails.innerHTML = ` +

    ⚠️ Loading Issues Detected

    +
    +
      + ${errors.map(error => ` +
    • + ${error.system || error.checkName || 'Unknown'}: +
      ${(error.error || 'Check failed').replace(/\n\n/g, '

      ').replace(/\n/g, '
      ')}
      + ${error.error && error.error.includes('Timeout') ? '
      Try checking your network connection and refresh.
      ' : ''} +
    • + `).join('')} +
    +
    +

    You can retry loading or continue with limited functionality

    + `; + } + + // Insert before actions + if (actionsContainer && actionsContainer.parentNode) { + actionsContainer.parentNode.insertBefore(errorDetails, actionsContainer); + actionsContainer.style.display = 'flex'; + this.retryButton.style.display = 'inline-flex'; + this.continueButton.style.display = 'inline-flex'; + } + } + + // Hide loading screen + hide() { + if (this.container && this.isVisible) { + // Show success message and trigger animations + const successMessage = document.getElementById('success-message'); + if (successMessage) { + successMessage.style.display = 'block'; + } + + // Add success class to trigger animations + this.container.classList.add('success'); + + // Wait for success animations to play, then hide + setTimeout(() => { + this.container.classList.add('hiding'); + setTimeout(() => { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.isVisible = false; + }, 500); + }, 200); + } + } + + // Show loading screen + show() { + if (!this.isVisible && this.container) { + if (!this.container.parentNode) { + document.body.appendChild(this.container); + } + this.container.classList.remove('hiding'); + this.isVisible = true; + } + } + + // Update status text + updateStatus(text) { + const statusElement = document.getElementById('loading-status-text'); + if (statusElement) { + statusElement.textContent = text; + } + } + + // Attach event listeners + attachEventListeners() { + if (this.retryButton) { + this.retryButton.addEventListener('click', () => { + // console.log('🔄 Retry button clicked - refreshing page'); + window.location.reload(); + }); + } + + if (this.continueButton) { + this.continueButton.addEventListener('click', () => { + this.hide(); + // Trigger continue event + window.dispatchEvent(new CustomEvent('loadingContinue')); + }); + } + } + + // Cleanup + destroy() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.isVisible = false; + this.systemCards.clear(); + } +} diff --git a/containers/libreportal/frontend/js/system/setup-completion-watcher.js b/containers/libreportal/frontend/js/system/setup-completion-watcher.js new file mode 100644 index 0000000..d1375b0 --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-completion-watcher.js @@ -0,0 +1,234 @@ +// Setup Completion Watcher +// +// Listens for the `taskCompleted` event the TaskEventBus dispatches when any +// task hits a terminal state. If the completed task is the finalize task +// recorded by the Setup Wizard handoff (stashed in sessionStorage), trigger +// the celebratory exit: toast notification, re-enable the topbar nav, then +// navigate to the dashboard. + +(function setupCompletionWatcher() { + function readHandoff() { + try { + const raw = sessionStorage.getItem('libreportal_setup_handoff'); + return raw ? JSON.parse(raw) : null; + } catch { return null; } + } + + function clearHandoff() { + try { sessionStorage.removeItem('libreportal_setup_handoff'); } catch { /* noop */ } + } + + // Floating banner shown across the top of any page while setup tasks are + // running. Surfaces the X-of-Y completion count from the live TaskEventBus + // snapshot so the user always knows how far through they are. Vanishes + // automatically when the finalize event fires (the same handler that runs + // the toast + dashboard redirect down below). + let bannerEl = null; + + function isOnTasksPage() { + const p = window.location.pathname; + return p === '/tasks' || p.startsWith('/tasks') || p === '/tasks.html'; + } + + function ensureBanner() { + if (bannerEl && document.body.contains(bannerEl)) return bannerEl; + bannerEl = document.createElement('div'); + bannerEl.className = 'setup-progress-banner'; + bannerEl.innerHTML = ` +
    + +
    + Setting up your install + — starting… +
    +
    +
    + `; + document.body.appendChild(bannerEl); + return bannerEl; + } + + function removeBanner() { + if (bannerEl) { + bannerEl.classList.add('leaving'); + setTimeout(() => { bannerEl?.remove(); bannerEl = null; }, 350); + } + } + + // Local cache keyed by task id. SSE bus only registers tasks once they fire + // an event AFTER it connects, so tasks that already finished before the page + // loaded are invisible to it. We seed this from /api/tasks on start and + // refresh from each SSE event so the banner reflects the true count. + const groupTasks = new Map(); + + async function seedGroupTasks() { + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) return; + try { + const res = await fetch('/api/tasks', { cache: 'no-store' }); + if (!res.ok) return; + const all = await res.json(); + for (const t of all) { + if (t && t.setupGroup === handoff.setupGroup) groupTasks.set(t.id, t); + } + refreshBanner(); + // Reload-mid-setup case: if the finalize task already terminated + // before the page came back up, no SSE event will fire for it. The + // /api/tasks fetch above is the only signal — kick off the completion + // handler from here too so the toast/redirect still runs. + checkAlreadyCompleted(); + } catch { /* network blip — SSE events will fill in the gaps */ } + } + + function ingestEventTask(detail) { + const t = detail && detail.task; + if (!t || !t.id) return; + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) return; + if (t.setupGroup !== handoff.setupGroup) return; + groupTasks.set(t.id, t); + } + + function refreshBanner() { + if (!isOnTasksPage()) { removeBanner(); return; } + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) { removeBanner(); return; } + + const tasksInGroup = Array.from(groupTasks.values()); + + // Trust the handoff for the denominator — see seedGroupTasks for why + // the SSE bus alone undercounts. + const total = handoff.totalTaskCount || tasksInGroup.length; + if (total === 0) return; + + const done = tasksInGroup.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').length; + const failed = tasksInGroup.some(t => t.status === 'failed'); + + const banner = ensureBanner(); + banner.classList.toggle('failed', failed); + banner.querySelector('.setup-progress-banner-count').textContent = `— ${done} of ${total} tasks complete`; + const pct = total ? Math.round((done / total) * 100) : 0; + banner.querySelector('.setup-progress-banner-fill').style.width = `${pct}%`; + } + + function showWelcomeToast(installName, success) { + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() + : window.notificationSystem; + if (!sys || typeof sys.show !== 'function') return; + + if (success) { + sys.show( + `Setup complete — welcome aboard, ${installName || 'Quantum Traveler'}. Your install is ready.`, + 'success' + ); + } else { + sys.show( + `Setup ran into an issue. Check the failed task above for details.`, + 'error' + ); + } + } + + function reEnableNav() { + document.querySelectorAll('.topbar-nav.setup-needed').forEach((nav) => { + nav.classList.remove('setup-needed'); + }); + } + + function navigateToRecommendedApps() { + const route = '/apps?=recommended'; + if (window.spaClean && typeof window.spaClean.navigate === 'function') { + window.spaClean.navigate(route); + } else if (typeof window.navigateToRoute === 'function') { + window.navigateToRoute(route); + } else { + window.location.href = 'apps.html?=recommended'; + } + } + + // Refresh the banner on every task event so the count stays live. + const onTaskEvent = (e) => { ingestEventTask(e.detail || {}); refreshBanner(); }; + window.addEventListener('taskCreated', onTaskEvent); + window.addEventListener('taskUpdated', onTaskEvent); + window.addEventListener('taskCompleted', onTaskEvent); + + // Re-evaluate on navigation: covers back/forward (popstate) and SPA + // pushState/replaceState (monkey-patched once). Without this the banner + // would linger after leaving /tasks (or never appear when arriving there) + // because nothing else triggers refreshBanner. + window.addEventListener('popstate', refreshBanner); + ['pushState', 'replaceState'].forEach((m) => { + const orig = history[m]; + history[m] = function (...args) { + const r = orig.apply(this, args); + refreshBanner(); + return r; + }; + }); + + // Seed once now and again every 3s as a safety net — covers the window + // between the page loading and the SSE bus connecting, plus any FS-watch + // hiccups that swallow a task.upsert event. + seedGroupTasks(); + setInterval(() => { + if (readHandoff()) seedGroupTasks(); + }, 3000); + + window.addEventListener('taskCompleted', (event) => { + const detail = event.detail || {}; + const handoff = readHandoff(); + if (!handoff || !handoff.finalizeTaskId) return; + if (detail.taskId !== handoff.finalizeTaskId) return; + + const success = detail.status === 'completed'; + clearHandoff(); + + // Defer to the next frame so the tasks-manager listener (registered on the + // same `taskCompleted` event) gets to flip the row to "completed" first — + // otherwise the welcome toast appears while the finalize row still shows + // "running", which reads as out-of-order. + requestAnimationFrame(() => { + removeBanner(); + reEnableNav(); + showWelcomeToast(handoff.installName, success); + if (success) setTimeout(navigateToRecommendedApps, 1800); + }); + }); + + // Also handle the case where the user reloads the tasks page after the + // finalize task has already completed — we won't get a fresh `taskCompleted` + // event, so check the bus snapshot once it's ready. + function checkAlreadyCompleted() { + const handoff = readHandoff(); + if (!handoff || !handoff.finalizeTaskId) return; + // Prefer the /api/tasks-seeded cache (groupTasks) — the SSE bus only + // knows tasks that emitted events after it connected, so reload-mid- + // setup leaves it blind to anything that already finished. + const task = groupTasks.get(handoff.finalizeTaskId) + || (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId)); + if (!task) return; + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + const success = task.status === 'completed'; + clearHandoff(); + removeBanner(); + reEnableNav(); + showWelcomeToast(handoff.installName, success); + if (success) setTimeout(navigateToRecommendedApps, 1800); + } + } + + window.addEventListener('taskBusReady', () => { + checkAlreadyCompleted(); + refreshBanner(); + }); + if (window.taskEventBus && window.taskEventBus.connected) { + checkAlreadyCompleted(); + refreshBanner(); + } +})(); diff --git a/containers/libreportal/frontend/js/system/setup-detector.js b/containers/libreportal/frontend/js/system/setup-detector.js new file mode 100755 index 0000000..73274c5 --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-detector.js @@ -0,0 +1,440 @@ +// Setup Detection - First-time user setup and installer detection +class SetupDetector { + constructor() { + this.setupSteps = new Map(); + this.currentStep = 0; + this.isFirstTime = false; + this.setupComplete = false; + } + + // Initialize setup detection + async initialize() { + this.setupSteps = this.defineSetupSteps(); + this.isFirstTime = await this.detectFirstTime(); + this.setupComplete = !this.isFirstTime; + + return { + isFirstTime: this.isFirstTime, + setupComplete: this.setupComplete + }; + } + + // Define setup steps + defineSetupSteps() { + return new Map([ + ['welcome', { + title: 'Welcome to LibrePortal', + description: 'Let\'s get your Docker management system set up', + component: 'welcome-step' + }], + ['requirements', { + title: 'System Requirements', + description: 'Checking your system compatibility', + component: 'requirements-step', + check: () => this.checkSystemRequirements() + }], + ['directories', { + title: 'Directory Setup', + description: 'Creating necessary directories', + component: 'directories-step', + check: () => this.checkDirectories() + }], + ['permissions', { + title: 'Permissions Check', + description: 'Verifying file permissions', + component: 'permissions-step', + check: () => this.checkPermissions() + }], + ['configuration', { + title: 'Basic Configuration', + description: 'Setting up initial configuration', + component: 'config-step', + check: () => this.checkBasicConfig() + }], + ['complete', { + title: 'Setup Complete', + description: 'Your LibrePortal system is ready', + component: 'complete-step' + }] + ]); + } + + // Detect if this is first-time setup. Source of truth is the server-side + // lock file at /docker/containers/libreportal/frontend/data/.setup_complete + // — that's what the bash setupApply function creates after a successful + // wizard run. localStorage is no longer used because per-browser state + // gives wrong answers (different browsers would each see "first install"). + async detectFirstTime() { + try { + const res = await fetch('/api/setup/status'); + if (!res.ok) return true; + const data = await res.json(); + return !data.complete; + + } catch (error) { + console.error('Error detecting first-time setup:', error); + return true; // Assume first time on error + } + } + + // Check system requirements + async checkSystemRequirements() { + const checks = []; + + // Check browser compatibility + const browserCheck = this.checkBrowserCompatibility(); + checks.push({ + name: 'Browser Compatibility', + status: browserCheck.passed, + details: browserCheck.details + }); + + // Check JavaScript features + const jsCheck = this.checkJavaScriptFeatures(); + checks.push({ + name: 'JavaScript Features', + status: jsCheck.passed, + details: jsCheck.details + }); + + // Check storage availability + const storageCheck = this.checkStorageAvailability(); + checks.push({ + name: 'Local Storage', + status: storageCheck.passed, + details: storageCheck.details + }); + + // Check network connectivity + const networkCheck = await this.checkNetworkConnectivity(); + checks.push({ + name: 'Network Connectivity', + status: networkCheck.passed, + details: networkCheck.details + }); + + return { + passed: checks.every(check => check.status), + checks + }; + } + + // Check browser compatibility + checkBrowserCompatibility() { + const userAgent = navigator.userAgent; + const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor); + const isFirefox = /Firefox/.test(userAgent); + const isSafari = /Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor); + const isEdge = /Edg/.test(userAgent); + + const supported = isChrome || isFirefox || isSafari || isEdge; + + return { + passed: supported, + details: { + browser: this.getBrowserName(), + supported, + version: navigator.userAgent.match(/(?:Chrome|Firefox|Safari|Edge)\/(\d+)/)?.[1] || 'Unknown' + } + }; + } + + // Get browser name + getBrowserName() { + const userAgent = navigator.userAgent; + if (/Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)) return 'Chrome'; + if (/Firefox/.test(userAgent)) return 'Firefox'; + if (/Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor)) return 'Safari'; + if (/Edg/.test(userAgent)) return 'Edge'; + return 'Unknown'; + } + + // Check JavaScript features + checkJavaScriptFeatures() { + const features = [ + { name: 'Fetch API', check: () => typeof fetch !== 'undefined' }, + { name: 'Promises', check: () => typeof Promise !== 'undefined' }, + { name: 'Arrow Functions', check: () => { try { eval('() => {}'); return true; } catch { return false; } } }, + { name: 'Async/Await', check: () => { try { eval('async () => {}'); return true; } catch { return false; } } }, + { name: 'Map/Set', check: () => typeof Map !== 'undefined' && typeof Set !== 'undefined' }, + { name: 'LocalStorage', check: () => typeof Storage !== 'undefined' } + ]; + + const results = features.map(feature => ({ + name: feature.name, + supported: feature.check() + })); + + const allSupported = results.every(result => result.supported); + + return { + passed: allSupported, + details: { + features: results, + totalSupported: results.filter(r => r.supported).length, + totalFeatures: results.length + } + }; + } + + // Check storage availability + checkStorageAvailability() { + try { + const testKey = 'libreportal_test'; + localStorage.setItem(testKey, 'test'); + localStorage.removeItem(testKey); + + return { + passed: true, + details: { + available: true, + quota: 'Available' + } + }; + } catch (error) { + return { + passed: false, + details: { + available: false, + error: error.message + } + }; + } + } + + // Check network connectivity + async checkNetworkConnectivity() { + try { + const response = await fetch('/', { method: 'HEAD', cache: 'no-cache' }); + + return { + passed: response.ok, + details: { + status: response.status, + statusText: response.statusText, + online: navigator.onLine + } + }; + } catch (error) { + return { + passed: false, + details: { + error: error.message, + online: navigator.onLine + } + }; + } + } + + // Check required directories + async checkDirectories() { + const requiredPaths = [ + '/data/apps/', + '/data/config/', + '/data/backup/', + '/containers/libreportal/' + ]; + + const checks = []; + + for (const path of requiredPaths) { + try { + // Try to access a file in the directory to check if it exists + const testFile = path.endsWith('/') ? path + '.gitkeep' : path; + const response = await fetch(testFile, { method: 'HEAD' }); + + checks.push({ + path, + exists: response.ok || response.status === 404 // 404 means directory exists but file doesn't + }); + } catch (error) { + checks.push({ + path, + exists: false, + error: error.message + }); + } + } + + const allExist = checks.every(check => check.exists); + + return { + passed: allExist, + details: { + directories: checks, + totalExists: checks.filter(c => c.exists).length, + totalDirectories: checks.length + } + }; + } + + // Check file permissions + async checkPermissions() { + const testFiles = [ + '/data/apps/generated/apps.json', + '/data/apps/apps-categories.json', + '/data/config/generated/configs.json' + ]; + + const checks = []; + + for (const file of testFiles) { + try { + const response = await fetch(file, { method: 'HEAD' }); + + checks.push({ + file, + readable: response.ok, + status: response.status + }); + } catch (error) { + checks.push({ + file, + readable: false, + error: error.message + }); + } + } + + const allReadable = checks.every(check => check.readable); + + return { + passed: allReadable, + details: { + files: checks, + totalReadable: checks.filter(c => c.readable).length, + totalFiles: checks.length + } + }; + } + + // Check basic configuration + async checkBasicConfig() { + try { + // Check for basic config files + const configFiles = [ + '/data/config/generated/configs.json' + ]; + + for (const file of configFiles) { + const response = await fetch(file, { method: 'HEAD' }); + if (!response.ok) { + return { + passed: false, + details: { + missing: file, + error: 'Configuration file not found' + } + }; + } + } + + // Try to load and validate config structure + const configResponse = await fetch('/data/config/generated/configs.json'); + if (configResponse.ok) { + const config = await configResponse.json(); + + return { + passed: true, + details: { + configLoaded: true, + configType: typeof config, + hasData: Object.keys(config).length > 0 + } + }; + } + + return { + passed: false, + details: { + error: 'Failed to load configuration' + } + }; + + } catch (error) { + return { + passed: false, + details: { + error: error.message + } + }; + } + } + + // Get setup step by ID + getStep(stepId) { + return this.setupSteps.get(stepId); + } + + // Get all setup steps + getAllSteps() { + return Array.from(this.setupSteps.entries()).map(([id, step]) => ({ + id, + ...step + })); + } + + // Get next step + getNextStep() { + const steps = Array.from(this.setupSteps.keys()); + const nextIndex = this.currentStep + 1; + + if (nextIndex < steps.length) { + return steps[nextIndex]; + } + + return null; + } + + // Get previous step + getPreviousStep() { + const steps = Array.from(this.setupSteps.keys()); + const prevIndex = this.currentStep - 1; + + if (prevIndex >= 0) { + return steps[prevIndex]; + } + + return null; + } + + // Move to specific step + goToStep(stepId) { + if (this.setupSteps.has(stepId)) { + this.currentStep = Array.from(this.setupSteps.keys()).indexOf(stepId); + return true; + } + return false; + } + + // Mark setup as complete + markSetupComplete() { + this.setupComplete = true; + this.isFirstTime = false; + localStorage.setItem('libreportal_setup_complete', 'true'); + localStorage.setItem('libreportal_setup_date', new Date().toISOString()); + } + + // Reset setup (for testing/re-setup) + resetSetup() { + this.setupComplete = false; + this.isFirstTime = true; + this.currentStep = 0; + localStorage.removeItem('libreportal_setup_complete'); + localStorage.removeItem('libreportal_setup_date'); + } + + // Get setup status + getSetupStatus() { + return { + isFirstTime: this.isFirstTime, + setupComplete: this.setupComplete, + currentStep: this.currentStep, + totalSteps: this.setupSteps.size, + setupDate: localStorage.getItem('libreportal_setup_date') + }; + } +} + +// Global instance +window.SetupDetector = SetupDetector; diff --git a/containers/libreportal/frontend/js/system/setup-wizard.js b/containers/libreportal/frontend/js/system/setup-wizard.js new file mode 100755 index 0000000..43150ff --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-wizard.js @@ -0,0 +1,758 @@ +// Setup Wizard - First-time install configuration UI. +// +// Multi-step slide-right form. Each step is a sibling div inside a track +// that translates horizontally as the user advances. Submission POSTs to +// /api/setup/save which fans out into separate tasks per app, then this UI +// hands off to the tasks page focused on the first task. + +class SetupWizard { + constructor() { + this.container = null; + this.suggestedName = ''; + this.dnsCheckTimer = null; + this.dnsCheckController = null; + this.onComplete = null; + this.currentStep = 0; + this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics']; + this.stepIcons = ['🪐', '🛰️', '🛡️', '📊']; + this.totalSteps = this.stepNames.length; + this.domainCount = 0; // tracked dynamically as the user adds rows + } + + initialize(setupDetector, onComplete = null) { + this.onComplete = onComplete; + this.create(); + this.attach(); + this.suggestName(); + this.renderAppTiles(); + this.preselectTimezone(); + this.showStep(0); + } + + getWizardApps() { + return [ + { slug: 'traefik', recommended: true, defaultChecked: true, + fallback: { name: 'Traefik', description: 'Reverse proxy + automatic SSL via LetsEncrypt' } }, + { slug: 'crowdsec', recommended: true, defaultChecked: true, + fallback: { name: 'CrowdSec', description: 'Host-installed intrusion prevention' } } + ]; + } + + getMetricsApps() { + return [ + { slug: 'prometheus', defaultChecked: false, + fallback: { name: 'Prometheus', description: 'Scrapes and stores time-series metrics from your apps' } }, + { slug: 'grafana', defaultChecked: false, + fallback: { name: 'Grafana', description: 'Dashboards and visualisations on top of Prometheus metrics' } } + ]; + } + + create() { + this.container = document.createElement('div'); + this.container.className = 'setup-wizard aurora-bg aurora-static'; + this.container.innerHTML = ` + + +
    +
    + +

    Tuning your private universe before takeoff...

    +
    + +
    +
    +
    +
    +
    +
    + Step 1 of ${this.totalSteps} + 0% +
    +
    + +
    + +
    +
    + + +
    +
    + +
    + + + +
    +
    + +
    + +
    + + +
    +
    +
    + + +
    +
    + +
    + +

    No domain? Skip this step — apps will be reachable by IP and Port on your LAN.

    +
    +
    + + +
    +
    +
    Recommended Apps
    +

    Pre-selected to give you a working install out of the box.

    + + + + +
    +
    + + +
    +
    +
    Metrics Apps
    +

    Optional. Install these to enable per-app "Export metrics to Grafana" later.

    +
    +
    +
    + +
    +
    + + + +
    + + + +
    + +
    +
    +
    + `; + document.body.appendChild(this.container); + document.body.classList.add('setup-wizard-open'); + } + + attach() { + const $ = (id) => this.container.querySelector(id); + + $('#sw-reroll').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const btn = $('#sw-reroll'); + btn.classList.add('manifesting'); + setTimeout(() => btn.classList.remove('manifesting'), 700); + this.suggestName(true); + }); + + $('#sw-domain-add').addEventListener('click', () => this.addDomainRow()); + // Seed with one empty row so the user has somewhere to type. They can + // remove it if they truly want a local-only install. + this.addDomainRow(); + + this.attachLiveValidation(); + + $('#sw-back').addEventListener('click', () => this.prev()); + $('#sw-next').addEventListener('click', () => this.next()); + + $('#setup-form').addEventListener('submit', (e) => { + e.preventDefault(); + console.log('[setup] form submit fired'); + this.submit(); + }); + + $('#sw-submit').addEventListener('click', (e) => { + e.preventDefault(); + console.log('[setup] launch button clicked'); + this.submit(); + }); + + $('#setup-form').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && this.currentStep < this.totalSteps - 1) { + e.preventDefault(); + this.next(); + } + }); + } + + showStep(n) { + this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n)); + + this.container.querySelectorAll('.setup-step').forEach((el, idx) => { + el.classList.toggle('active', idx === this.currentStep); + }); + + const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100); + const name = this.stepNames[this.currentStep]; + const icon = this.stepIcons[this.currentStep]; + this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`; + this.container.querySelector('#sw-progress-step').innerHTML = + `Step ${this.currentStep + 1} of ${this.totalSteps} ${icon} ${name}`; + this.container.querySelector('#sw-progress-pct').textContent = `${pct}%`; + + this.container.querySelector('#sw-back').disabled = this.currentStep === 0; + const isLast = this.currentStep === this.totalSteps - 1; + this.container.querySelector('#sw-next').style.display = isLast ? 'none' : ''; + this.container.querySelector('#sw-submit').style.display = isLast ? '' : 'none'; + + setTimeout(() => { + const focusable = this.container.querySelector(`.setup-step.active input, .setup-step.active select`); + if (focusable) focusable.focus(); + }, 350); + } + + validateStep(idx) { + const $ = (id) => this.container.querySelector(id); + if (idx === 0) { + const name = $('#sw-name').value.trim(); + if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.'; + if (!$('#sw-timezone').value) return 'Please select a timezone.'; + } + if (idx === 1) { + // Domains are optional, but any non-empty input must be valid. + const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input')); + for (const input of inputs) { + const v = input.value.trim(); + if (v && !/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(v)) { + return 'One of your domains doesn\'t look valid.'; + } + } + } + if (idx === 2) { + const traefikBox = this.container.querySelector('input[data-app="traefik"]'); + if (traefikBox && traefikBox.checked) { + const tEmail = $('#sw-traefik-email').value.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tEmail)) { + return 'Traefik needs a valid LetsEncrypt email.'; + } + } + } + return null; + } + + next() { + const err = this.validateStep(this.currentStep); + if (err) { this.showError(err); return; } + this.clearError(); + this.showStep(this.currentStep + 1); + } + + prev() { + this.clearError(); + this.showStep(this.currentStep - 1); + } + + async loadAppsManifest() { + if (this._appsManifest) return this._appsManifest; + try { + const res = await fetch('/data/apps/generated/apps.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const list = Array.isArray(data) ? data : (data.apps || []); + this._appsManifest = new Map(); + for (const app of list) { + const key = (app.slug || app.name || app.id || '').toLowerCase(); + if (key) this._appsManifest.set(key, app); + } + } catch (err) { + this._appsManifest = new Map(); + } + return this._appsManifest; + } + + async renderAppTiles() { + const manifest = await this.loadAppsManifest(); + const recBox = this.container.querySelector('#sw-apps-recommended'); + if (!recBox) return; + + const buildTile = ({ slug, defaultChecked, fallback, subOption }) => { + const app = manifest.get(slug) || {}; + const name = app.title || app.name || fallback.name; + const desc = app.description || fallback.description; + const icon = app.icon || `icons/apps/${slug}.svg`; + // Sub-option lives OUTSIDE the parent label (nested