[
  {
    "path": ".github/FUNDING.yml",
    "content": "custom: ['https://www.spug.dev/sponsorship/']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.md",
    "content": "---\nname: \"🐛 Bug Report\"\nabout: Report a reproducible bug or regression.\ntitle: 'Bug: '\n---\n\n<!--\n  Spug 版本信息可以在 系统管理/系统设置/关于 中查看，请填写 Spug 版本信息。\n-->\n\nSpug 版本:\n\n## 问题重现步骤\n\n1.\n2.\n\n## 报错/问题截图\n\n\n## 期望的结果\n\n"
  },
  {
    "path": ".github/workflows/github_to_gitee.yml",
    "content": "name: github repos to gitee job\non:\n# 如果需要PR触发把push前的#去掉\n# push:\n  schedule:\n    # 每天北京时间1点跑\n    - cron:  '0 1 * * *'\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Mirror the Github organization repos to Gitee.\n      uses: Yikun/gitee-mirror-action@v0.01\n      with:\n        src: github/openspug\n        dst: gitee/openspug\n        # Gitee对应的秘钥\n        private_key: ${{ secrets.mac_pro_videojj }}\n        # 需要同步的Github组织名（源）\n        github_org: openspug\n        # 需要同步到的Gitee的组织名（目的）\n        gitee_org: openspug\n"
  },
  {
    "path": ".gitignore",
    "content": "/.idea/\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">Spug</h1>\n\n<div align=\"center\">\n\nSpug是面向中小型企业设计的轻量级无Agent的自动化运维平台，整合了主机管理、主机批量执行、主机在线终端、应用发布部署、在线任务计划、配置中心、监控、报警等一系列功能。\n\n</div>\n\n- 公司官网：https://www.spug.cc\n- 项目官网：https://ops.spug.cc\n- 使用文档：https://ops.spug.cc/docs/about-spug/\n\n## 演示环境\n\n演示地址：https://demo.spug.cc\n\n## 🔐免费通配符SSL证书\n免费通配符，付费证书价格亲民，性价比超高，低于市场其他平台价格，免费专家一对一配置服务，购买流程简单快速，且支持7天无理由退款和开具发票。提供一键下载和SSL过期通知配置，免费申请：[https://ssl.spug.cc](https://ssl.spug.cc)\n\n\n## 🔥推送助手\n\n推送助手是一个集成了电话、短信、邮件、飞书、钉钉、微信、企业微信等多通道的消息推送平台，可以3分钟实现个人电话短信推送，点击体验：[https://push.spug.cc](https://push.spug.cc)\n\n\n## 特性\n\n- **批量执行**: 主机命令在线批量执行\n- **在线终端**: 主机支持浏览器在线终端登录\n- **文件管理**: 主机文件在线上传下载\n- **任务计划**: 灵活的在线任务计划\n- **发布部署**: 支持自定义发布部署流程\n- **配置中心**: 支持KV、文本、json等格式的配置\n- **监控中心**: 支持站点、端口、进程、自定义等监控\n- **报警中心**: 支持短信、邮件、钉钉、微信等报警方式\n- **优雅美观**: 基于 Ant Design 的UI界面\n- **开源免费**: 前后端代码完全开源\n\n\n## 环境\n\n* Python 3.6+\n* Django 2.2\n* Node 12.14\n* React 16.11\n\n## 安装\n\n[官方文档](https://ops.spug.cc/docs/install-docker)\n\n更多使用帮助请参考： [使用文档](https://ops.spug.cc/docs/host-manage/)\n\n\n## 推荐项目\n[Yearning — MYSQL 开源SQL语句审核平台](https://github.com/cookieY/Yearning)\n\n\n## 预览\n\n### 主机管理\n![image](https://cdn.spug.cc/img/3.0/host.jpg)\n\n#### 主机在线终端\n![image](https://cdn.spug.cc/img/3.0/web-terminal.jpg)\n\n#### 文件在线上传下载\n![image](https://cdn.spug.cc/img/3.0/file-manager.jpg)\n\n#### 主机批量执行\n![image](https://cdn.spug.cc/img/3.0/host-exec.jpg)\n![image](https://cdn.spug.cc/img/3.0/host-exec2.jpg)\n\n#### 应用发布\n![image](https://cdn.spug.cc/img/3.0/deploy.jpg)\n\n#### 监控报警\n![image](https://cdn.spug.cc/img/3.0/monitor.jpg)\n\n#### 角色权限\n![image](https://cdn.spug.cc/img/3.0/user-role.jpg)\n\n\n## 赞助\n<table>\n  <thead>\n    <tr>\n      <th align=\"center\" style=\"width: 115px;\">\n        <a href=\"https://www.ucloud.cn/site/active/kuaijie.html?invitation_code=C1xD0E5678FBA77\">\n          <img src=\"https://cdn.spug.cc/img/ucloud.png\" width=\"115px\"><br>\n          <sub>UCloud</sub><br>\n          <sub>5 元/月云主机</sub>\n        </a>\n      </th>\n        <th align=\"center\" style=\"width: 115px;\">\n        <a href=\"https://www.aliyun.com/minisite/goods?userCode=bkj6b9tn\">\n          <img src=\"https://cdn.spug.cc/img/aliyun-logo.png\" width=\"115px\"><br>\n          <sub>阿里云</sub><br>\n          <sub>2核心2G低至99元/年</sub>\n        </a>\n      </th>\n      <th align=\"center\" style=\"width: 125px;\">\n        <a href=\"http://www.magedu.com\">\n          <img src=\"https://cdn.spug.cc/img/magedu-logo.jpeg\" width=\"115px\"><br>\n          <sub>马哥教育</sub><br>\n          <sub>IT人高薪职业学院</sub>\n        </a>\n      </th>\n    </tr>\n  </thead>\n</table>\n\n## 开发者群\n#### 关注Spug运维公众号加微信群、QQ群、获取最新产品动态\n<div >\n   <img src=\"https://cdn.spug.cc/img/spug-club.jpg\" width = \"300\" height = \"300\" alt=\"spug-qq\" align=center />\n<div>\n  \n## License & Copyright\n[AGPL-3.0](https://opensource.org/licenses/AGPL-3.0)\n"
  },
  {
    "path": "docs/FQA.md",
    "content": "### install mysqlclient\n```shell\n# for centos 7\nyum install mariadb-devel python3-devel gcc\npip install mysqlclient\n```\n"
  },
  {
    "path": "docs/docker/Dockerfile",
    "content": "FROM centos:7.9.2009\n\nENV TZ=Asia/Shanghai\nRUN yum install -y epel-release https://packages.endpointdev.com/rhel/7/os/x86_64/endpoint-repo.x86_64.rpm && yum install -y --setopt=tsflags=nodocs nginx redis mariadb-devel python36 python36-devel openldap-devel supervisor git gcc wget unzip net-tools sshpass rsync sshfs && yum -y clean all --enablerepo='*'\n\nRUN pip3 install --no-cache-dir --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/\nRUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ \\\n    gunicorn \\\n    mysqlclient \\\n    cryptography==36.0.2 \\\n    apscheduler==3.7.0 \\\n    asgiref==3.2.10 \\\n    Django==2.2.28 \\\n    channels==2.3.1 \\\n    channels_redis==2.4.1 \\\n    paramiko==2.11.0 \\\n    django-redis==4.10.0 \\\n    requests==2.22.0 \\\n    GitPython==3.0.8 \\\n    python-ldap==3.4.0 \\\n    openpyxl==3.0.3 \\\n    user_agents==2.2.0\n\nRUN localedef -c -i en_US -f UTF-8 en_US.UTF-8\nENV LANG=en_US.UTF-8\nENV LC_ALL=en_US.UTF-8\nRUN echo -e '\\n# Source definitions\\n. /etc/profile\\n' >> /root/.bashrc\nRUN mkdir -p /data/repos\nCOPY init_spug /usr/bin/\nCOPY nginx.conf /etc/nginx/\nCOPY ssh_config /etc/ssh/\nCOPY spug.ini /etc/supervisord.d/\nCOPY redis.conf /etc/\nCOPY entrypoint.sh /\n\nVOLUME /data\nEXPOSE 80\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "docs/docker/docker-compose.yml",
    "content": "version: \"3.3\"\nservices:\n  db:\n    image: mariadb:10.8\n    container_name: spug-db\n    restart: always\n    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci\n    volumes:\n      - /data/spug/mysql:/var/lib/mysql\n    environment:\n      - MYSQL_DATABASE=spug\n      - MYSQL_USER=spug\n      - MYSQL_PASSWORD=spug.cc\n      - MYSQL_ROOT_PASSWORD=spug.cc\n  spug:\n    image: openspug/spug-service\n    container_name: spug\n    privileged: true\n    restart: always\n    volumes:\n      - /data/spug/service:/data/spug\n      - /data/spug/repos:/data/repos\n    ports:\n      # 如果80端口被占用可替换为其他端口，例如: - \"8000:80\"\n      - \"80:80\"\n    environment:\n      - SPUG_DOCKER_VERSION=v3.2.4\n      - MYSQL_DATABASE=spug\n      - MYSQL_USER=spug\n      - MYSQL_PASSWORD=spug.cc\n      - MYSQL_HOST=db\n      - MYSQL_PORT=3306\n    depends_on:\n      - db\n"
  },
  {
    "path": "docs/docker/entrypoint.sh",
    "content": "#!/bin/bash\n#\nset -e\n\nif [ -e /root/.bashrc ]; then\n    source /root/.bashrc\nfi\n\nif [ ! -d /data/spug/spug_api ]; then\n    git clone -b $SPUG_DOCKER_VERSION https://gitee.com/openspug/spug.git /data/spug\n    curl -o web.tar.gz https://cdn.spug.cc/spug/web_${SPUG_DOCKER_VERSION}.tar.gz\n    tar xf web.tar.gz -C /data/spug/spug_web/\n    rm -f web.tar.gz\n    SECRET_KEY=$(< /dev/urandom tr -dc '!@#%^.a-zA-Z0-9' | head -c50)\n    cat > /data/spug/spug_api/spug/overrides.py << EOF\nimport os\n\n\nDEBUG = False\nALLOWED_HOSTS = ['127.0.0.1']\nSECRET_KEY = '${SECRET_KEY}'\n\nDATABASES = {\n    'default': {\n        'ATOMIC_REQUESTS': True,\n        'ENGINE': 'django.db.backends.mysql',\n        'NAME': os.environ.get('MYSQL_DATABASE'),\n        'USER': os.environ.get('MYSQL_USER'),\n        'PASSWORD': os.environ.get('MYSQL_PASSWORD'),\n        'HOST': os.environ.get('MYSQL_HOST'),\n        'PORT': os.environ.get('MYSQL_PORT'),\n        'OPTIONS': {\n            'charset': 'utf8mb4',\n            'sql_mode': 'STRICT_TRANS_TABLES',\n        }\n    }\n}\nEOF\nfi\n\nexec supervisord -c /etc/supervisord.conf\n"
  },
  {
    "path": "docs/docker/init_spug",
    "content": "#!/bin/bash\n#\nset -e\nset -u\n\npython3 /data/spug/spug_api/manage.py updatedb\npython3 /data/spug/spug_api/manage.py user add -u $1 -p $2 -n 管理员 -s\n"
  },
  {
    "path": "docs/docker/nginx.conf",
    "content": "# For more information on configuration, see:\n#   * Official English Documentation: http://nginx.org/en/docs/\n#   * Official Russian Documentation: http://nginx.org/ru/docs/\n\nuser nginx;\nworker_processes auto;\nerror_log /var/log/nginx/error.log;\npid /run/nginx.pid;\n\n# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.\ninclude /usr/share/nginx/modules/*.conf;\n\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    log_format  main  '$remote_addr - $remote_user [$time_local] \"$request\" '\n                      '$status $body_bytes_sent \"$http_referer\" '\n                      '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n    access_log  /var/log/nginx/access.log  main;\n\n    sendfile            on;\n    tcp_nopush          on;\n    tcp_nodelay         on;\n    keepalive_timeout   65;\n    types_hash_max_size 2048;\n    server_tokens       off;\n\n    include             /etc/nginx/mime.types;\n    default_type        application/octet-stream;\n\n    # Load modular configuration files from the /etc/nginx/conf.d directory.\n    # See http://nginx.org/en/docs/ngx_core_module.html#include\n    # for more information.\n    include /etc/nginx/conf.d/*.conf;\n\n    server {\n        listen       80 default_server;\n        listen       [::]:80 default_server;\n        server_name  _;\n        root         /data/spug/spug_web/build;\n\tclient_max_body_size\t0;\n        add_header   X-Frame-Options SAMEORIGIN always;\n\n\tgzip  on;\n\tgzip_min_length  1k;\n\tgzip_buffers     4 16k;\n\tgzip_http_version 1.1;\n\tgzip_comp_level 7;\n\tgzip_types       text/plain text/css text/javascript application/javascript application/json;\n\tgzip_disable \"MSIE [1-6]\\.\";\n\tgzip_vary on;\n\n        location ^~ /api/ {\n                rewrite ^/api(.*) $1 break;\n                proxy_pass http://127.0.0.1:9001;\n\t\tproxy_read_timeout 180s;\n                proxy_redirect off;\n                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        }\n\n        location ^~ /api/ws/ {\n                rewrite ^/api(.*) $1 break;\n                proxy_pass http://127.0.0.1:9002;\n                proxy_http_version 1.1;\n                proxy_set_header Upgrade $http_upgrade;\n                proxy_set_header Connection \"Upgrade\";\n                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        }\n       \n        location / {\n                try_files $uri /index.html;\n        }\n    }\n}\n"
  },
  {
    "path": "docs/docker/redis.conf",
    "content": "# Redis configuration file example.\n#\n# Note that in order to read the configuration file, Redis must be\n# started with the file path as first argument:\n#\n# ./redis-server /path/to/redis.conf\n\n# Note on units: when memory size is needed, it is possible to specify\n# it in the usual form of 1k 5GB 4M and so forth:\n#\n# 1k => 1000 bytes\n# 1kb => 1024 bytes\n# 1m => 1000000 bytes\n# 1mb => 1024*1024 bytes\n# 1g => 1000000000 bytes\n# 1gb => 1024*1024*1024 bytes\n#\n# units are case insensitive so 1GB 1Gb 1gB are all the same.\n\n################################## INCLUDES ###################################\n\n# Include one or more other config files here.  This is useful if you\n# have a standard template that goes to all Redis servers but also need\n# to customize a few per-server settings.  Include files can include\n# other files, so use this wisely.\n#\n# Notice option \"include\" won't be rewritten by command \"CONFIG REWRITE\"\n# from admin or Redis Sentinel. Since Redis always uses the last processed\n# line as value of a configuration directive, you'd better put includes\n# at the beginning of this file to avoid overwriting config change at runtime.\n#\n# If instead you are interested in using includes to override configuration\n# options, it is better to use include as the last line.\n#\n# include /path/to/local.conf\n# include /path/to/other.conf\n\n################################## NETWORK #####################################\n\n# By default, if no \"bind\" configuration directive is specified, Redis listens\n# for connections from all the network interfaces available on the server.\n# It is possible to listen to just one or multiple selected interfaces using\n# the \"bind\" configuration directive, followed by one or more IP addresses.\n#\n# Examples:\n#\n# bind 192.168.1.100 10.0.0.1\n# bind 127.0.0.1 ::1\n#\n# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the\n# internet, binding to all the interfaces is dangerous and will expose the\n# instance to everybody on the internet. So by default we uncomment the\n# following bind directive, that will force Redis to listen only into\n# the IPv4 lookback interface address (this means Redis will be able to\n# accept connections only from clients running into the same computer it\n# is running).\n#\n# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES\n# JUST COMMENT THE FOLLOWING LINE.\n# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nbind 127.0.0.1\n\n# Protected mode is a layer of security protection, in order to avoid that\n# Redis instances left open on the internet are accessed and exploited.\n#\n# When protected mode is on and if:\n#\n# 1) The server is not binding explicitly to a set of addresses using the\n#    \"bind\" directive.\n# 2) No password is configured.\n#\n# The server only accepts connections from clients connecting from the\n# IPv4 and IPv6 loopback addresses 127.0.0.1 and ::1, and from Unix domain\n# sockets.\n#\n# By default protected mode is enabled. You should disable it only if\n# you are sure you want clients from other hosts to connect to Redis\n# even if no authentication is configured, nor a specific set of interfaces\n# are explicitly listed using the \"bind\" directive.\nprotected-mode yes\n\n# Accept connections on the specified port, default is 6379 (IANA #815344).\n# If port 0 is specified Redis will not listen on a TCP socket.\nport 6379\n\n# TCP listen() backlog.\n#\n# In high requests-per-second environments you need an high backlog in order\n# to avoid slow clients connections issues. Note that the Linux kernel\n# will silently truncate it to the value of /proc/sys/net/core/somaxconn so\n# make sure to raise both the value of somaxconn and tcp_max_syn_backlog\n# in order to get the desired effect.\ntcp-backlog 511\n\n# Unix socket.\n#\n# Specify the path for the Unix socket that will be used to listen for\n# incoming connections. There is no default, so Redis will not listen\n# on a unix socket when not specified.\n#\n# unixsocket /tmp/redis.sock\n# unixsocketperm 700\n\n# Close the connection after a client is idle for N seconds (0 to disable)\ntimeout 0\n\n# TCP keepalive.\n#\n# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence\n# of communication. This is useful for two reasons:\n#\n# 1) Detect dead peers.\n# 2) Take the connection alive from the point of view of network\n#    equipment in the middle.\n#\n# On Linux, the specified value (in seconds) is the period used to send ACKs.\n# Note that to close the connection the double of the time is needed.\n# On other kernels the period depends on the kernel configuration.\n#\n# A reasonable value for this option is 300 seconds, which is the new\n# Redis default starting with Redis 3.2.1.\ntcp-keepalive 300\n\n################################# GENERAL #####################################\n\n# By default Redis does not run as a daemon. Use 'yes' if you need it.\n# Note that Redis will write a pid file in /var/run/redis.pid when daemonized.\ndaemonize no\n\n# If you run Redis from upstart or systemd, Redis can interact with your\n# supervision tree. Options:\n#   supervised no      - no supervision interaction\n#   supervised upstart - signal upstart by putting Redis into SIGSTOP mode\n#   supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET\n#   supervised auto    - detect upstart or systemd method based on\n#                        UPSTART_JOB or NOTIFY_SOCKET environment variables\n# Note: these supervision methods only signal \"process is ready.\"\n#       They do not enable continuous liveness pings back to your supervisor.\nsupervised no\n\n# If a pid file is specified, Redis writes it where specified at startup\n# and removes it at exit.\n#\n# When the server runs non daemonized, no pid file is created if none is\n# specified in the configuration. When the server is daemonized, the pid file\n# is used even if not specified, defaulting to \"/var/run/redis.pid\".\n#\n# Creating a pid file is best effort: if Redis is not able to create it\n# nothing bad happens, the server will start and run normally.\npidfile /var/run/redis_6379.pid\n\n# Specify the server verbosity level.\n# This can be one of:\n# debug (a lot of information, useful for development/testing)\n# verbose (many rarely useful info, but not a mess like the debug level)\n# notice (moderately verbose, what you want in production probably)\n# warning (only very important / critical messages are logged)\nloglevel notice\n\n# Specify the log file name. Also the empty string can be used to force\n# Redis to log on the standard output. Note that if you use standard\n# output for logging but daemonize, logs will be sent to /dev/null\nlogfile /var/log/redis/redis.log\n\n# To enable logging to the system logger, just set 'syslog-enabled' to yes,\n# and optionally update the other syslog parameters to suit your needs.\n# syslog-enabled no\n\n# Specify the syslog identity.\n# syslog-ident redis\n\n# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7.\n# syslog-facility local0\n\n# Set the number of databases. The default database is DB 0, you can select\n# a different one on a per-connection basis using SELECT <dbid> where\n# dbid is a number between 0 and 'databases'-1\ndatabases 16\n\n################################ SNAPSHOTTING  ################################\n#\n# Save the DB on disk:\n#\n#   save <seconds> <changes>\n#\n#   Will save the DB if both the given number of seconds and the given\n#   number of write operations against the DB occurred.\n#\n#   In the example below the behaviour will be to save:\n#   after 900 sec (15 min) if at least 1 key changed\n#   after 300 sec (5 min) if at least 10 keys changed\n#   after 60 sec if at least 10000 keys changed\n#\n#   Note: you can disable saving completely by commenting out all \"save\" lines.\n#\n#   It is also possible to remove all the previously configured save\n#   points by adding a save directive with a single empty string argument\n#   like in the following example:\n#\n#   save \"\"\n\nsave 900 1\nsave 300 10\nsave 60 10000\n\n# By default Redis will stop accepting writes if RDB snapshots are enabled\n# (at least one save point) and the latest background save failed.\n# This will make the user aware (in a hard way) that data is not persisting\n# on disk properly, otherwise chances are that no one will notice and some\n# disaster will happen.\n#\n# If the background saving process will start working again Redis will\n# automatically allow writes again.\n#\n# However if you have setup your proper monitoring of the Redis server\n# and persistence, you may want to disable this feature so that Redis will\n# continue to work as usual even if there are problems with disk,\n# permissions, and so forth.\nstop-writes-on-bgsave-error yes\n\n# Compress string objects using LZF when dump .rdb databases?\n# For default that's set to 'yes' as it's almost always a win.\n# If you want to save some CPU in the saving child set it to 'no' but\n# the dataset will likely be bigger if you have compressible values or keys.\nrdbcompression yes\n\n# Since version 5 of RDB a CRC64 checksum is placed at the end of the file.\n# This makes the format more resistant to corruption but there is a performance\n# hit to pay (around 10%) when saving and loading RDB files, so you can disable it\n# for maximum performances.\n#\n# RDB files created with checksum disabled have a checksum of zero that will\n# tell the loading code to skip the check.\nrdbchecksum yes\n\n# The filename where to dump the DB\ndbfilename dump.rdb\n\n# The working directory.\n#\n# The DB will be written inside this directory, with the filename specified\n# above using the 'dbfilename' configuration directive.\n#\n# The Append Only File will also be created inside this directory.\n#\n# Note that you must specify a directory here, not a file name.\ndir /var/lib/redis\n\n################################# REPLICATION #################################\n\n# Master-Slave replication. Use slaveof to make a Redis instance a copy of\n# another Redis server. A few things to understand ASAP about Redis replication.\n#\n# 1) Redis replication is asynchronous, but you can configure a master to\n#    stop accepting writes if it appears to be not connected with at least\n#    a given number of slaves.\n# 2) Redis slaves are able to perform a partial resynchronization with the\n#    master if the replication link is lost for a relatively small amount of\n#    time. You may want to configure the replication backlog size (see the next\n#    sections of this file) with a sensible value depending on your needs.\n# 3) Replication is automatic and does not need user intervention. After a\n#    network partition slaves automatically try to reconnect to masters\n#    and resynchronize with them.\n#\n# slaveof <masterip> <masterport>\n\n# If the master is password protected (using the \"requirepass\" configuration\n# directive below) it is possible to tell the slave to authenticate before\n# starting the replication synchronization process, otherwise the master will\n# refuse the slave request.\n#\n# masterauth <master-password>\n\n# When a slave loses its connection with the master, or when the replication\n# is still in progress, the slave can act in two different ways:\n#\n# 1) if slave-serve-stale-data is set to 'yes' (the default) the slave will\n#    still reply to client requests, possibly with out of date data, or the\n#    data set may just be empty if this is the first synchronization.\n#\n# 2) if slave-serve-stale-data is set to 'no' the slave will reply with\n#    an error \"SYNC with master in progress\" to all the kind of commands\n#    but to INFO and SLAVEOF.\n#\nslave-serve-stale-data yes\n\n# You can configure a slave instance to accept writes or not. Writing against\n# a slave instance may be useful to store some ephemeral data (because data\n# written on a slave will be easily deleted after resync with the master) but\n# may also cause problems if clients are writing to it because of a\n# misconfiguration.\n#\n# Since Redis 2.6 by default slaves are read-only.\n#\n# Note: read only slaves are not designed to be exposed to untrusted clients\n# on the internet. It's just a protection layer against misuse of the instance.\n# Still a read only slave exports by default all the administrative commands\n# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve\n# security of read only slaves using 'rename-command' to shadow all the\n# administrative / dangerous commands.\nslave-read-only yes\n\n# Replication SYNC strategy: disk or socket.\n#\n# -------------------------------------------------------\n# WARNING: DISKLESS REPLICATION IS EXPERIMENTAL CURRENTLY\n# -------------------------------------------------------\n#\n# New slaves and reconnecting slaves that are not able to continue the replication\n# process just receiving differences, need to do what is called a \"full\n# synchronization\". An RDB file is transmitted from the master to the slaves.\n# The transmission can happen in two different ways:\n#\n# 1) Disk-backed: The Redis master creates a new process that writes the RDB\n#                 file on disk. Later the file is transferred by the parent\n#                 process to the slaves incrementally.\n# 2) Diskless: The Redis master creates a new process that directly writes the\n#              RDB file to slave sockets, without touching the disk at all.\n#\n# With disk-backed replication, while the RDB file is generated, more slaves\n# can be queued and served with the RDB file as soon as the current child producing\n# the RDB file finishes its work. With diskless replication instead once\n# the transfer starts, new slaves arriving will be queued and a new transfer\n# will start when the current one terminates.\n#\n# When diskless replication is used, the master waits a configurable amount of\n# time (in seconds) before starting the transfer in the hope that multiple slaves\n# will arrive and the transfer can be parallelized.\n#\n# With slow disks and fast (large bandwidth) networks, diskless replication\n# works better.\nrepl-diskless-sync no\n\n# When diskless replication is enabled, it is possible to configure the delay\n# the server waits in order to spawn the child that transfers the RDB via socket\n# to the slaves.\n#\n# This is important since once the transfer starts, it is not possible to serve\n# new slaves arriving, that will be queued for the next RDB transfer, so the server\n# waits a delay in order to let more slaves arrive.\n#\n# The delay is specified in seconds, and by default is 5 seconds. To disable\n# it entirely just set it to 0 seconds and the transfer will start ASAP.\nrepl-diskless-sync-delay 5\n\n# Slaves send PINGs to server in a predefined interval. It's possible to change\n# this interval with the repl_ping_slave_period option. The default value is 10\n# seconds.\n#\n# repl-ping-slave-period 10\n\n# The following option sets the replication timeout for:\n#\n# 1) Bulk transfer I/O during SYNC, from the point of view of slave.\n# 2) Master timeout from the point of view of slaves (data, pings).\n# 3) Slave timeout from the point of view of masters (REPLCONF ACK pings).\n#\n# It is important to make sure that this value is greater than the value\n# specified for repl-ping-slave-period otherwise a timeout will be detected\n# every time there is low traffic between the master and the slave.\n#\n# repl-timeout 60\n\n# Disable TCP_NODELAY on the slave socket after SYNC?\n#\n# If you select \"yes\" Redis will use a smaller number of TCP packets and\n# less bandwidth to send data to slaves. But this can add a delay for\n# the data to appear on the slave side, up to 40 milliseconds with\n# Linux kernels using a default configuration.\n#\n# If you select \"no\" the delay for data to appear on the slave side will\n# be reduced but more bandwidth will be used for replication.\n#\n# By default we optimize for low latency, but in very high traffic conditions\n# or when the master and slaves are many hops away, turning this to \"yes\" may\n# be a good idea.\nrepl-disable-tcp-nodelay no\n\n# Set the replication backlog size. The backlog is a buffer that accumulates\n# slave data when slaves are disconnected for some time, so that when a slave\n# wants to reconnect again, often a full resync is not needed, but a partial\n# resync is enough, just passing the portion of data the slave missed while\n# disconnected.\n#\n# The bigger the replication backlog, the longer the time the slave can be\n# disconnected and later be able to perform a partial resynchronization.\n#\n# The backlog is only allocated once there is at least a slave connected.\n#\n# repl-backlog-size 1mb\n\n# After a master has no longer connected slaves for some time, the backlog\n# will be freed. The following option configures the amount of seconds that\n# need to elapse, starting from the time the last slave disconnected, for\n# the backlog buffer to be freed.\n#\n# A value of 0 means to never release the backlog.\n#\n# repl-backlog-ttl 3600\n\n# The slave priority is an integer number published by Redis in the INFO output.\n# It is used by Redis Sentinel in order to select a slave to promote into a\n# master if the master is no longer working correctly.\n#\n# A slave with a low priority number is considered better for promotion, so\n# for instance if there are three slaves with priority 10, 100, 25 Sentinel will\n# pick the one with priority 10, that is the lowest.\n#\n# However a special priority of 0 marks the slave as not able to perform the\n# role of master, so a slave with priority of 0 will never be selected by\n# Redis Sentinel for promotion.\n#\n# By default the priority is 100.\nslave-priority 100\n\n# It is possible for a master to stop accepting writes if there are less than\n# N slaves connected, having a lag less or equal than M seconds.\n#\n# The N slaves need to be in \"online\" state.\n#\n# The lag in seconds, that must be <= the specified value, is calculated from\n# the last ping received from the slave, that is usually sent every second.\n#\n# This option does not GUARANTEE that N replicas will accept the write, but\n# will limit the window of exposure for lost writes in case not enough slaves\n# are available, to the specified number of seconds.\n#\n# For example to require at least 3 slaves with a lag <= 10 seconds use:\n#\n# min-slaves-to-write 3\n# min-slaves-max-lag 10\n#\n# Setting one or the other to 0 disables the feature.\n#\n# By default min-slaves-to-write is set to 0 (feature disabled) and\n# min-slaves-max-lag is set to 10.\n\n# A Redis master is able to list the address and port of the attached\n# slaves in different ways. For example the \"INFO replication\" section\n# offers this information, which is used, among other tools, by\n# Redis Sentinel in order to discover slave instances.\n# Another place where this info is available is in the output of the\n# \"ROLE\" command of a masteer.\n#\n# The listed IP and address normally reported by a slave is obtained\n# in the following way:\n#\n#   IP: The address is auto detected by checking the peer address\n#   of the socket used by the slave to connect with the master.\n#\n#   Port: The port is communicated by the slave during the replication\n#   handshake, and is normally the port that the slave is using to\n#   list for connections.\n#\n# However when port forwarding or Network Address Translation (NAT) is\n# used, the slave may be actually reachable via different IP and port\n# pairs. The following two options can be used by a slave in order to\n# report to its master a specific set of IP and port, so that both INFO\n# and ROLE will report those values.\n#\n# There is no need to use both the options if you need to override just\n# the port or the IP address.\n#\n# slave-announce-ip 5.5.5.5\n# slave-announce-port 1234\n\n################################## SECURITY ###################################\n\n# Require clients to issue AUTH <PASSWORD> before processing any other\n# commands.  This might be useful in environments in which you do not trust\n# others with access to the host running redis-server.\n#\n# This should stay commented out for backward compatibility and because most\n# people do not need auth (e.g. they run their own servers).\n#\n# Warning: since Redis is pretty fast an outside user can try up to\n# 150k passwords per second against a good box. This means that you should\n# use a very strong password otherwise it will be very easy to break.\n#\n# requirepass foobared\n\n# Command renaming.\n#\n# It is possible to change the name of dangerous commands in a shared\n# environment. For instance the CONFIG command may be renamed into something\n# hard to guess so that it will still be available for internal-use tools\n# but not available for general clients.\n#\n# Example:\n#\n# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52\n#\n# It is also possible to completely kill a command by renaming it into\n# an empty string:\n#\n# rename-command CONFIG \"\"\n#\n# Please note that changing the name of commands that are logged into the\n# AOF file or transmitted to slaves may cause problems.\n\n################################### LIMITS ####################################\n\n# Set the max number of connected clients at the same time. By default\n# this limit is set to 10000 clients, however if the Redis server is not\n# able to configure the process file limit to allow for the specified limit\n# the max number of allowed clients is set to the current file limit\n# minus 32 (as Redis reserves a few file descriptors for internal uses).\n#\n# Once the limit is reached Redis will close all the new connections sending\n# an error 'max number of clients reached'.\n#\n# maxclients 10000\n\n# Don't use more memory than the specified amount of bytes.\n# When the memory limit is reached Redis will try to remove keys\n# according to the eviction policy selected (see maxmemory-policy).\n#\n# If Redis can't remove keys according to the policy, or if the policy is\n# set to 'noeviction', Redis will start to reply with errors to commands\n# that would use more memory, like SET, LPUSH, and so on, and will continue\n# to reply to read-only commands like GET.\n#\n# This option is usually useful when using Redis as an LRU cache, or to set\n# a hard memory limit for an instance (using the 'noeviction' policy).\n#\n# WARNING: If you have slaves attached to an instance with maxmemory on,\n# the size of the output buffers needed to feed the slaves are subtracted\n# from the used memory count, so that network problems / resyncs will\n# not trigger a loop where keys are evicted, and in turn the output\n# buffer of slaves is full with DELs of keys evicted triggering the deletion\n# of more keys, and so forth until the database is completely emptied.\n#\n# In short... if you have slaves attached it is suggested that you set a lower\n# limit for maxmemory so that there is some free RAM on the system for slave\n# output buffers (but this is not needed if the policy is 'noeviction').\n#\nmaxmemory 2GB\n\n# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory\n# is reached. You can select among five behaviors:\n#\n# volatile-lru -> remove the key with an expire set using an LRU algorithm\n# allkeys-lru -> remove any key according to the LRU algorithm\n# volatile-random -> remove a random key with an expire set\n# allkeys-random -> remove a random key, any key\n# volatile-ttl -> remove the key with the nearest expire time (minor TTL)\n# noeviction -> don't expire at all, just return an error on write operations\n#\n# Note: with any of the above policies, Redis will return an error on write\n#       operations, when there are no suitable keys for eviction.\n#\n#       At the date of writing these commands are: set setnx setex append\n#       incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd\n#       sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby\n#       zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby\n#       getset mset msetnx exec sort\n#\n# The default is:\n#\nmaxmemory-policy allkeys-lru\n\n# LRU and minimal TTL algorithms are not precise algorithms but approximated\n# algorithms (in order to save memory), so you can tune it for speed or\n# accuracy. For default Redis will check five keys and pick the one that was\n# used less recently, you can change the sample size using the following\n# configuration directive.\n#\n# The default of 5 produces good enough results. 10 Approximates very closely\n# true LRU but costs a bit more CPU. 3 is very fast but not very accurate.\n#\n# maxmemory-samples 5\n\n############################## APPEND ONLY MODE ###############################\n\n# By default Redis asynchronously dumps the dataset on disk. This mode is\n# good enough in many applications, but an issue with the Redis process or\n# a power outage may result into a few minutes of writes lost (depending on\n# the configured save points).\n#\n# The Append Only File is an alternative persistence mode that provides\n# much better durability. For instance using the default data fsync policy\n# (see later in the config file) Redis can lose just one second of writes in a\n# dramatic event like a server power outage, or a single write if something\n# wrong with the Redis process itself happens, but the operating system is\n# still running correctly.\n#\n# AOF and RDB persistence can be enabled at the same time without problems.\n# If the AOF is enabled on startup Redis will load the AOF, that is the file\n# with the better durability guarantees.\n#\n# Please check http://redis.io/topics/persistence for more information.\n\nappendonly no\n\n# The name of the append only file (default: \"appendonly.aof\")\n\nappendfilename \"appendonly.aof\"\n\n# The fsync() call tells the Operating System to actually write data on disk\n# instead of waiting for more data in the output buffer. Some OS will really flush\n# data on disk, some other OS will just try to do it ASAP.\n#\n# Redis supports three different modes:\n#\n# no: don't fsync, just let the OS flush the data when it wants. Faster.\n# always: fsync after every write to the append only log. Slow, Safest.\n# everysec: fsync only one time every second. Compromise.\n#\n# The default is \"everysec\", as that's usually the right compromise between\n# speed and data safety. It's up to you to understand if you can relax this to\n# \"no\" that will let the operating system flush the output buffer when\n# it wants, for better performances (but if you can live with the idea of\n# some data loss consider the default persistence mode that's snapshotting),\n# or on the contrary, use \"always\" that's very slow but a bit safer than\n# everysec.\n#\n# More details please check the following article:\n# http://antirez.com/post/redis-persistence-demystified.html\n#\n# If unsure, use \"everysec\".\n\n# appendfsync always\nappendfsync everysec\n# appendfsync no\n\n# When the AOF fsync policy is set to always or everysec, and a background\n# saving process (a background save or AOF log background rewriting) is\n# performing a lot of I/O against the disk, in some Linux configurations\n# Redis may block too long on the fsync() call. Note that there is no fix for\n# this currently, as even performing fsync in a different thread will block\n# our synchronous write(2) call.\n#\n# In order to mitigate this problem it's possible to use the following option\n# that will prevent fsync() from being called in the main process while a\n# BGSAVE or BGREWRITEAOF is in progress.\n#\n# This means that while another child is saving, the durability of Redis is\n# the same as \"appendfsync none\". In practical terms, this means that it is\n# possible to lose up to 30 seconds of log in the worst scenario (with the\n# default Linux settings).\n#\n# If you have latency problems turn this to \"yes\". Otherwise leave it as\n# \"no\" that is the safest pick from the point of view of durability.\n\nno-appendfsync-on-rewrite no\n\n# Automatic rewrite of the append only file.\n# Redis is able to automatically rewrite the log file implicitly calling\n# BGREWRITEAOF when the AOF log size grows by the specified percentage.\n#\n# This is how it works: Redis remembers the size of the AOF file after the\n# latest rewrite (if no rewrite has happened since the restart, the size of\n# the AOF at startup is used).\n#\n# This base size is compared to the current size. If the current size is\n# bigger than the specified percentage, the rewrite is triggered. Also\n# you need to specify a minimal size for the AOF file to be rewritten, this\n# is useful to avoid rewriting the AOF file even if the percentage increase\n# is reached but it is still pretty small.\n#\n# Specify a percentage of zero in order to disable the automatic AOF\n# rewrite feature.\n\nauto-aof-rewrite-percentage 100\nauto-aof-rewrite-min-size 64mb\n\n# An AOF file may be found to be truncated at the end during the Redis\n# startup process, when the AOF data gets loaded back into memory.\n# This may happen when the system where Redis is running\n# crashes, especially when an ext4 filesystem is mounted without the\n# data=ordered option (however this can't happen when Redis itself\n# crashes or aborts but the operating system still works correctly).\n#\n# Redis can either exit with an error when this happens, or load as much\n# data as possible (the default now) and start if the AOF file is found\n# to be truncated at the end. The following option controls this behavior.\n#\n# If aof-load-truncated is set to yes, a truncated AOF file is loaded and\n# the Redis server starts emitting a log to inform the user of the event.\n# Otherwise if the option is set to no, the server aborts with an error\n# and refuses to start. When the option is set to no, the user requires\n# to fix the AOF file using the \"redis-check-aof\" utility before to restart\n# the server.\n#\n# Note that if the AOF file will be found to be corrupted in the middle\n# the server will still exit with an error. This option only applies when\n# Redis will try to read more data from the AOF file but not enough bytes\n# will be found.\naof-load-truncated yes\n\n################################ LUA SCRIPTING  ###############################\n\n# Max execution time of a Lua script in milliseconds.\n#\n# If the maximum execution time is reached Redis will log that a script is\n# still in execution after the maximum allowed time and will start to\n# reply to queries with an error.\n#\n# When a long running script exceeds the maximum execution time only the\n# SCRIPT KILL and SHUTDOWN NOSAVE commands are available. The first can be\n# used to stop a script that did not yet called write commands. The second\n# is the only way to shut down the server in the case a write command was\n# already issued by the script but the user doesn't want to wait for the natural\n# termination of the script.\n#\n# Set it to 0 or a negative value for unlimited execution without warnings.\nlua-time-limit 5000\n\n################################ REDIS CLUSTER  ###############################\n#\n# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n# WARNING EXPERIMENTAL: Redis Cluster is considered to be stable code, however\n# in order to mark it as \"mature\" we need to wait for a non trivial percentage\n# of users to deploy it in production.\n# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n#\n# Normal Redis instances can't be part of a Redis Cluster; only nodes that are\n# started as cluster nodes can. In order to start a Redis instance as a\n# cluster node enable the cluster support uncommenting the following:\n#\n# cluster-enabled yes\n\n# Every cluster node has a cluster configuration file. This file is not\n# intended to be edited by hand. It is created and updated by Redis nodes.\n# Every Redis Cluster node requires a different cluster configuration file.\n# Make sure that instances running in the same system do not have\n# overlapping cluster configuration file names.\n#\n# cluster-config-file nodes-6379.conf\n\n# Cluster node timeout is the amount of milliseconds a node must be unreachable\n# for it to be considered in failure state.\n# Most other internal time limits are multiple of the node timeout.\n#\n# cluster-node-timeout 15000\n\n# A slave of a failing master will avoid to start a failover if its data\n# looks too old.\n#\n# There is no simple way for a slave to actually have a exact measure of\n# its \"data age\", so the following two checks are performed:\n#\n# 1) If there are multiple slaves able to failover, they exchange messages\n#    in order to try to give an advantage to the slave with the best\n#    replication offset (more data from the master processed).\n#    Slaves will try to get their rank by offset, and apply to the start\n#    of the failover a delay proportional to their rank.\n#\n# 2) Every single slave computes the time of the last interaction with\n#    its master. This can be the last ping or command received (if the master\n#    is still in the \"connected\" state), or the time that elapsed since the\n#    disconnection with the master (if the replication link is currently down).\n#    If the last interaction is too old, the slave will not try to failover\n#    at all.\n#\n# The point \"2\" can be tuned by user. Specifically a slave will not perform\n# the failover if, since the last interaction with the master, the time\n# elapsed is greater than:\n#\n#   (node-timeout * slave-validity-factor) + repl-ping-slave-period\n#\n# So for example if node-timeout is 30 seconds, and the slave-validity-factor\n# is 10, and assuming a default repl-ping-slave-period of 10 seconds, the\n# slave will not try to failover if it was not able to talk with the master\n# for longer than 310 seconds.\n#\n# A large slave-validity-factor may allow slaves with too old data to failover\n# a master, while a too small value may prevent the cluster from being able to\n# elect a slave at all.\n#\n# For maximum availability, it is possible to set the slave-validity-factor\n# to a value of 0, which means, that slaves will always try to failover the\n# master regardless of the last time they interacted with the master.\n# (However they'll always try to apply a delay proportional to their\n# offset rank).\n#\n# Zero is the only value able to guarantee that when all the partitions heal\n# the cluster will always be able to continue.\n#\n# cluster-slave-validity-factor 10\n\n# Cluster slaves are able to migrate to orphaned masters, that are masters\n# that are left without working slaves. This improves the cluster ability\n# to resist to failures as otherwise an orphaned master can't be failed over\n# in case of failure if it has no working slaves.\n#\n# Slaves migrate to orphaned masters only if there are still at least a\n# given number of other working slaves for their old master. This number\n# is the \"migration barrier\". A migration barrier of 1 means that a slave\n# will migrate only if there is at least 1 other working slave for its master\n# and so forth. It usually reflects the number of slaves you want for every\n# master in your cluster.\n#\n# Default is 1 (slaves migrate only if their masters remain with at least\n# one slave). To disable migration just set it to a very large value.\n# A value of 0 can be set but is useful only for debugging and dangerous\n# in production.\n#\n# cluster-migration-barrier 1\n\n# By default Redis Cluster nodes stop accepting queries if they detect there\n# is at least an hash slot uncovered (no available node is serving it).\n# This way if the cluster is partially down (for example a range of hash slots\n# are no longer covered) all the cluster becomes, eventually, unavailable.\n# It automatically returns available as soon as all the slots are covered again.\n#\n# However sometimes you want the subset of the cluster which is working,\n# to continue to accept queries for the part of the key space that is still\n# covered. In order to do so, just set the cluster-require-full-coverage\n# option to no.\n#\n# cluster-require-full-coverage yes\n\n# In order to setup your cluster make sure to read the documentation\n# available at http://redis.io web site.\n\n################################## SLOW LOG ###################################\n\n# The Redis Slow Log is a system to log queries that exceeded a specified\n# execution time. The execution time does not include the I/O operations\n# like talking with the client, sending the reply and so forth,\n# but just the time needed to actually execute the command (this is the only\n# stage of command execution where the thread is blocked and can not serve\n# other requests in the meantime).\n#\n# You can configure the slow log with two parameters: one tells Redis\n# what is the execution time, in microseconds, to exceed in order for the\n# command to get logged, and the other parameter is the length of the\n# slow log. When a new command is logged the oldest one is removed from the\n# queue of logged commands.\n\n# The following time is expressed in microseconds, so 1000000 is equivalent\n# to one second. Note that a negative number disables the slow log, while\n# a value of zero forces the logging of every command.\nslowlog-log-slower-than 10000\n\n# There is no limit to this length. Just be aware that it will consume memory.\n# You can reclaim memory used by the slow log with SLOWLOG RESET.\nslowlog-max-len 128\n\n################################ LATENCY MONITOR ##############################\n\n# The Redis latency monitoring subsystem samples different operations\n# at runtime in order to collect data related to possible sources of\n# latency of a Redis instance.\n#\n# Via the LATENCY command this information is available to the user that can\n# print graphs and obtain reports.\n#\n# The system only logs operations that were performed in a time equal or\n# greater than the amount of milliseconds specified via the\n# latency-monitor-threshold configuration directive. When its value is set\n# to zero, the latency monitor is turned off.\n#\n# By default latency monitoring is disabled since it is mostly not needed\n# if you don't have latency issues, and collecting data has a performance\n# impact, that while very small, can be measured under big load. Latency\n# monitoring can easily be enabled at runtime using the command\n# \"CONFIG SET latency-monitor-threshold <milliseconds>\" if needed.\nlatency-monitor-threshold 0\n\n############################# EVENT NOTIFICATION ##############################\n\n# Redis can notify Pub/Sub clients about events happening in the key space.\n# This feature is documented at http://redis.io/topics/notifications\n#\n# For instance if keyspace events notification is enabled, and a client\n# performs a DEL operation on key \"foo\" stored in the Database 0, two\n# messages will be published via Pub/Sub:\n#\n# PUBLISH __keyspace@0__:foo del\n# PUBLISH __keyevent@0__:del foo\n#\n# It is possible to select the events that Redis will notify among a set\n# of classes. Every class is identified by a single character:\n#\n#  K     Keyspace events, published with __keyspace@<db>__ prefix.\n#  E     Keyevent events, published with __keyevent@<db>__ prefix.\n#  g     Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ...\n#  $     String commands\n#  l     List commands\n#  s     Set commands\n#  h     Hash commands\n#  z     Sorted set commands\n#  x     Expired events (events generated every time a key expires)\n#  e     Evicted events (events generated when a key is evicted for maxmemory)\n#  A     Alias for g$lshzxe, so that the \"AKE\" string means all the events.\n#\n#  The \"notify-keyspace-events\" takes as argument a string that is composed\n#  of zero or multiple characters. The empty string means that notifications\n#  are disabled.\n#\n#  Example: to enable list and generic events, from the point of view of the\n#           event name, use:\n#\n#  notify-keyspace-events Elg\n#\n#  Example 2: to get the stream of the expired keys subscribing to channel\n#             name __keyevent@0__:expired use:\n#\n#  notify-keyspace-events Ex\n#\n#  By default all notifications are disabled because most users don't need\n#  this feature and the feature has some overhead. Note that if you don't\n#  specify at least one of K or E, no events will be delivered.\nnotify-keyspace-events \"\"\n\n############################### ADVANCED CONFIG ###############################\n\n# Hashes are encoded using a memory efficient data structure when they have a\n# small number of entries, and the biggest entry does not exceed a given\n# threshold. These thresholds can be configured using the following directives.\nhash-max-ziplist-entries 512\nhash-max-ziplist-value 64\n\n# Lists are also encoded in a special way to save a lot of space.\n# The number of entries allowed per internal list node can be specified\n# as a fixed maximum size or a maximum number of elements.\n# For a fixed maximum size, use -5 through -1, meaning:\n# -5: max size: 64 Kb  <-- not recommended for normal workloads\n# -4: max size: 32 Kb  <-- not recommended\n# -3: max size: 16 Kb  <-- probably not recommended\n# -2: max size: 8 Kb   <-- good\n# -1: max size: 4 Kb   <-- good\n# Positive numbers mean store up to _exactly_ that number of elements\n# per list node.\n# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),\n# but if your use case is unique, adjust the settings as necessary.\nlist-max-ziplist-size -2\n\n# Lists may also be compressed.\n# Compress depth is the number of quicklist ziplist nodes from *each* side of\n# the list to *exclude* from compression.  The head and tail of the list\n# are always uncompressed for fast push/pop operations.  Settings are:\n# 0: disable all list compression\n# 1: depth 1 means \"don't start compressing until after 1 node into the list,\n#    going from either the head or tail\"\n#    So: [head]->node->node->...->node->[tail]\n#    [head], [tail] will always be uncompressed; inner nodes will compress.\n# 2: [head]->[next]->node->node->...->node->[prev]->[tail]\n#    2 here means: don't compress head or head->next or tail->prev or tail,\n#    but compress all nodes between them.\n# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]\n# etc.\nlist-compress-depth 0\n\n# Sets have a special encoding in just one case: when a set is composed\n# of just strings that happen to be integers in radix 10 in the range\n# of 64 bit signed integers.\n# The following configuration setting sets the limit in the size of the\n# set in order to use this special memory saving encoding.\nset-max-intset-entries 512\n\n# Similarly to hashes and lists, sorted sets are also specially encoded in\n# order to save a lot of space. This encoding is only used when the length and\n# elements of a sorted set are below the following limits:\nzset-max-ziplist-entries 128\nzset-max-ziplist-value 64\n\n# HyperLogLog sparse representation bytes limit. The limit includes the\n# 16 bytes header. When an HyperLogLog using the sparse representation crosses\n# this limit, it is converted into the dense representation.\n#\n# A value greater than 16000 is totally useless, since at that point the\n# dense representation is more memory efficient.\n#\n# The suggested value is ~ 3000 in order to have the benefits of\n# the space efficient encoding without slowing down too much PFADD,\n# which is O(N) with the sparse encoding. The value can be raised to\n# ~ 10000 when CPU is not a concern, but space is, and the data set is\n# composed of many HyperLogLogs with cardinality in the 0 - 15000 range.\nhll-sparse-max-bytes 3000\n\n# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in\n# order to help rehashing the main Redis hash table (the one mapping top-level\n# keys to values). The hash table implementation Redis uses (see dict.c)\n# performs a lazy rehashing: the more operation you run into a hash table\n# that is rehashing, the more rehashing \"steps\" are performed, so if the\n# server is idle the rehashing is never complete and some more memory is used\n# by the hash table.\n#\n# The default is to use this millisecond 10 times every second in order to\n# actively rehash the main dictionaries, freeing memory when possible.\n#\n# If unsure:\n# use \"activerehashing no\" if you have hard latency requirements and it is\n# not a good thing in your environment that Redis can reply from time to time\n# to queries with 2 milliseconds delay.\n#\n# use \"activerehashing yes\" if you don't have such hard requirements but\n# want to free memory asap when possible.\nactiverehashing yes\n\n# The client output buffer limits can be used to force disconnection of clients\n# that are not reading data from the server fast enough for some reason (a\n# common reason is that a Pub/Sub client can't consume messages as fast as the\n# publisher can produce them).\n#\n# The limit can be set differently for the three different classes of clients:\n#\n# normal -> normal clients including MONITOR clients\n# slave  -> slave clients\n# pubsub -> clients subscribed to at least one pubsub channel or pattern\n#\n# The syntax of every client-output-buffer-limit directive is the following:\n#\n# client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>\n#\n# A client is immediately disconnected once the hard limit is reached, or if\n# the soft limit is reached and remains reached for the specified number of\n# seconds (continuously).\n# So for instance if the hard limit is 32 megabytes and the soft limit is\n# 16 megabytes / 10 seconds, the client will get disconnected immediately\n# if the size of the output buffers reach 32 megabytes, but will also get\n# disconnected if the client reaches 16 megabytes and continuously overcomes\n# the limit for 10 seconds.\n#\n# By default normal clients are not limited because they don't receive data\n# without asking (in a push way), but just after a request, so only\n# asynchronous clients may create a scenario where data is requested faster\n# than it can read.\n#\n# Instead there is a default limit for pubsub and slave clients, since\n# subscribers and slaves receive data in a push fashion.\n#\n# Both the hard or the soft limit can be disabled by setting them to zero.\nclient-output-buffer-limit normal 0 0 0\nclient-output-buffer-limit slave 256mb 64mb 60\nclient-output-buffer-limit pubsub 32mb 8mb 60\n\n# Redis calls an internal function to perform many background tasks, like\n# closing connections of clients in timeout, purging expired keys that are\n# never requested, and so forth.\n#\n# Not all tasks are performed with the same frequency, but Redis checks for\n# tasks to perform according to the specified \"hz\" value.\n#\n# By default \"hz\" is set to 10. Raising the value will use more CPU when\n# Redis is idle, but at the same time will make Redis more responsive when\n# there are many keys expiring at the same time, and timeouts may be\n# handled with more precision.\n#\n# The range is between 1 and 500, however a value over 100 is usually not\n# a good idea. Most users should use the default of 10 and raise this up to\n# 100 only in environments where very low latency is required.\nhz 10\n\n# When a child rewrites the AOF file, if the following option is enabled\n# the file will be fsync-ed every 32 MB of data generated. This is useful\n# in order to commit the file to the disk more incrementally and avoid\n# big latency spikes.\naof-rewrite-incremental-fsync yes\n"
  },
  {
    "path": "docs/docker/spug.ini",
    "content": "[supervisord]\nnodaemon=true\n\n[program:nginx]\ncommand = nginx -g \"daemon off;\"\nautostart = true\n\n[program:redis]\ncommand = redis-server /etc/redis.conf\nautostart = true\n\n[program:spug-api]\ncommand = sh /data/spug/spug_api/tools/start-api.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/api.log\nredirect_stderr = true\n\n[program:spug-ws]\ncommand = sh /data/spug/spug_api/tools/start-ws.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/ws.log\nredirect_stderr = true\n\n[program:spug-worker]\ncommand = sh /data/spug/spug_api/tools/start-worker.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/worker.log\nredirect_stderr = true\n\n[program:spug-monitor]\ncommand = sh /data/spug/spug_api/tools/start-monitor.sh\nautostart = true\nstartsecs = 3\nstdout_logfile = /data/spug/spug_api/logs/monitor.log\nredirect_stderr = true\n\n[program:spug-scheduler]\ncommand = sh /data/spug/spug_api/tools/start-scheduler.sh\nautostart = true\nstartsecs = 3\nstdout_logfile = /data/spug/spug_api/logs/scheduler.log\nredirect_stderr = true\n"
  },
  {
    "path": "docs/docker/ssh_config",
    "content": "Host *\n  StrictHostKeyChecking no\n"
  },
  {
    "path": "docs/install.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n\nset -e\n\n\nfunction spug_banner() {\n\necho \"                           \";\necho \" ####  #####  #    #  #### \";\necho \"#      #    # #    # #    #\";\necho \" ####  #    # #    # #     \";\necho \"     # #####  #    # #  ###\";\necho \"#    # #      #    # #    #\";\necho \" ####  #       ####   #### \";\necho \"                           \";\n\n}\n\n\nfunction init_system_lib() {\n    source /etc/os-release\n    case $ID in\n        centos|fedora|rhel)\n            echo \"开始安装/更新可能缺少的依赖: git mariadb-server mariadb-devel python3-devel gcc openldap-devel redis nginx supervisor python36\"\n            yum install -y epel-release\n            yum install -y git mariadb-server mariadb-devel python3-devel gcc openldap-devel redis nginx supervisor python36\n            sed -i 's/ default_server//g' /etc/nginx/nginx.conf\n            MYSQL_CONF=/etc/my.cnf.d/spug.cnf\n            SUPERVISOR_CONF=/etc/supervisord.d/spug.ini\n            REDIS_SRV=redis\n            SUPERVISOR_SRV=supervisord\n            ;;\n\n        debian|ubuntu|devuan)\n            echo \"开始安装/更新可能缺少的依赖: git mariadb-server libmariadbd-dev python3-venv libsasl2-dev libldap2-dev redis-server nginx supervisor\"\n            apt update\n            apt install -y git mariadb-server libmariadbd-dev python3-dev python3-venv libsasl2-dev libldap2-dev redis-server nginx supervisor\n            rm -f /etc/nginx/sites-enabled/default\n            MYSQL_CONF=/etc/mysql/conf.d/spug.cnf\n            SUPERVISOR_CONF=/etc/supervisor/conf.d/spug.conf\n            REDIS_SRV=redis-server\n            SUPERVISOR_SRV=supervisor\n            ;;\n        *)\n            exit 1\n            ;;\n    esac\n}\n\n\nfunction install_spug() {\n  echo \"开始安装Spug...\"\n  mkdir -p /data\n  cd /data\n  git clone --depth=1 https://gitee.com/openspug/spug.git\n  curl -o /tmp/web_latest.tar.gz https://spug.dev/installer/web_latest.tar.gz\n  tar xf /tmp/web_latest.tar.gz -C spug/spug_web/\n  cd spug/spug_api\n  python3 -m venv venv\n  source venv/bin/activate\n\n  pip install wheel -i https://pypi.doubanio.com/simple/\n  pip install gunicorn mysqlclient -i https://pypi.doubanio.com/simple/\n  pip install -r requirements.txt -i https://pypi.doubanio.com/simple/\n}\n\n\nfunction setup_conf() {\n\n  echo \"开始配置Spug配置...\"\n# mysql conf\ncat << EOF > $MYSQL_CONF\n[mysqld]\nbind-address=127.0.0.1\nEOF\n\n# spug conf\ncat << EOF > spug/overrides.py\nDEBUG = False\nALLOWED_HOSTS = ['127.0.0.1']\n\nDATABASES = {\n    'default': {\n        'ATOMIC_REQUESTS': True,\n        'ENGINE': 'django.db.backends.mysql',\n        'NAME': 'spug',\n        'USER': 'spug',\n        'PASSWORD': 'spug.dev',\n        'HOST': '127.0.0.1',\n        'OPTIONS': {\n            'charset': 'utf8mb4',\n            'sql_mode': 'STRICT_TRANS_TABLES',\n        }\n    }\n}\nEOF\n\ncat << EOF > $SUPERVISOR_CONF\n[program:spug-api]\ncommand = bash /data/spug/spug_api/tools/start-api.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/api.log\nredirect_stderr = true\n\n[program:spug-ws]\ncommand = bash /data/spug/spug_api/tools/start-ws.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/ws.log\nredirect_stderr = true\n\n[program:spug-worker]\ncommand = bash /data/spug/spug_api/tools/start-worker.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/worker.log\nredirect_stderr = true\n\n[program:spug-monitor]\ncommand = bash /data/spug/spug_api/tools/start-monitor.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/monitor.log\nredirect_stderr = true\n\n[program:spug-scheduler]\ncommand = bash /data/spug/spug_api/tools/start-scheduler.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/scheduler.log\nredirect_stderr = true\nEOF\n\ncat << EOF > /etc/nginx/conf.d/spug.conf\nserver {\n        listen 80 default_server;\n        root /data/spug/spug_web/build/;\n\n        location ^~ /api/ {\n                rewrite ^/api(.*) \\$1 break;\n                proxy_pass http://127.0.0.1:9001;\n                proxy_redirect off;\n                proxy_set_header X-Real-IP \\$remote_addr;\n        }\n\n        location ^~ /api/ws/ {\n                rewrite ^/api(.*) \\$1 break;\n                proxy_pass http://127.0.0.1:9002;\n                proxy_http_version 1.1;\n                proxy_set_header Upgrade \\$http_upgrade;\n                proxy_set_header Connection \"Upgrade\";\n                proxy_set_header X-Real-IP \\$remote_addr;\n        }\n\n        error_page 404 /index.html;\n}\nEOF\n\n\nsystemctl start mariadb\nsystemctl enable mariadb\n\nmysql -e \"create database spug default character set utf8mb4 collate utf8mb4_unicode_ci;\"\nmysql -e \"grant all on spug.* to spug@127.0.0.1 identified by 'spug.dev'\"\nmysql -e \"flush privileges\"\n\npython manage.py initdb\npython manage.py useradd -u admin -p spug.dev -s -n 管理员\n\n\nsystemctl enable nginx\nsystemctl enable $REDIS_SRV\nsystemctl enable $SUPERVISOR_SRV\n\nsystemctl restart nginx\nsystemctl start $REDIS_SRV\nsystemctl restart $SUPERVISOR_SRV\n\n}\n\n\nspug_banner\ninit_system_lib\ninstall_spug\nsetup_conf\n\necho -e \"\\n\\n\\033[33m安全警告：默认的数据库和Redis服务并不安全，请确保其仅监听在127.0.0.1，推荐参考官网文档自行加固安全配置！\\033[0m\"\necho -e \"\\033[32m安装成功！\\033[0m\"\necho \"默认管理员账户：admin  密码：spug.dev\"\necho \"默认数据库用户：spug   密码：spug.dev\"\n"
  },
  {
    "path": "spug_api/.gitignore",
    "content": "*.pyc\n/venv/\n__pycache__/\n/.idea/\n/db.sqlite3\nmigrations/\n/access.log\n/repos/*\n/logs/*\n"
  },
  {
    "path": "spug_api/apps/account/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/account/history.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom libs.mixins import AdminView\nfrom libs import json_response\nfrom apps.account.models import History\n\n\nclass HistoryView(AdminView):\n    def get(self, request):\n        histories = History.objects.all()\n        return json_response(histories)\n"
  },
  {
    "path": "spug_api/apps/account/management/commands/set.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom apps.setting.utils import AppSetting\n\n\nclass Command(BaseCommand):\n    help = '系统设置'\n\n    def add_arguments(self, parser):\n        parser.add_argument('target', type=str, help='设置对象')\n        parser.add_argument('value', type=str, help='设置值')\n\n    def echo_success(self, msg):\n        self.stdout.write(self.style.SUCCESS(msg))\n\n    def echo_error(self, msg):\n        self.stderr.write(self.style.ERROR(msg))\n\n    def print_help(self, *args):\n        message = '''\n        系统设置命令用法：\n            set mfa disable     禁用登录MFA\n        '''\n        self.stdout.write(message)\n\n    def handle(self, *args, **options):\n        target = options['target']\n        if target == 'mfa':\n            if options['value'] != 'disable':\n                return self.echo_error(f'mfa设置，不支持的值【{options[\"value\"]}】')\n            AppSetting.set('MFA', {'enable': False})\n            self.echo_success('MFA已禁用')\n        else:\n            self.echo_error('未识别的操作')\n            self.print_help()\n"
  },
  {
    "path": "spug_api/apps/account/management/commands/update.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom django.conf import settings\nimport subprocess\nimport requests\nimport os\n\n\nclass Command(BaseCommand):\n    help = '升级Spug版本'\n\n    def handle(self, *args, **options):\n        version, is_repair = settings.SPUG_VERSION, False\n        res = requests.get(f'https://api.spug.cc/apis/release/latest/?version={version}').json()\n        if res['error']:\n            return self.stderr.write(self.style.ERROR(f'获取新版本失败：{res[\"error\"]}'))\n        if not res['data']['has_new']:\n            self.stdout.write(res['data']['extra'])\n            is_repair = True\n            answer = input(f'\\r\\n当前已是最新版本，是否要进行修复性更新[y|n]？')\n        else:\n            version = res['data']['version']\n            self.stdout.write(res['data']['content'])\n            self.stdout.write('\\r\\n')\n            self.stdout.write(res['data']['extra'])\n            answer = input(f'\\r\\n发现新版本 {version} 是否更新[y|n]？')\n        if answer.lower() != 'y':\n            return\n\n        # update web\n        web_dir = os.path.join(settings.BASE_DIR, '../spug_web')\n        commands = [\n            f'curl -o /tmp/spug_web.tar.gz https://cdn.spug.cc/spug/web_{version}.tar.gz',\n            f'rm -rf {web_dir}/build',\n            f'tar xf /tmp/spug_web.tar.gz -C {web_dir}'\n        ]\n        task = subprocess.Popen(' && '.join(commands), shell=True)\n        if task.wait() != 0:\n            return self.stderr.write(self.style.ERROR('获取更新失败，排除网络问题后请附带输出内容至官方论坛反馈。'))\n\n        # update api\n        commands = [\n            f'cd {settings.BASE_DIR}',\n            f'git fetch origin refs/tags/{version}:refs/tags/{version} --no-tags',\n            f'git checkout {version}'\n        ]\n        if is_repair:\n            commands.insert(1, f'git tag -d {version}')\n        task = subprocess.Popen(' && '.join(commands), shell=True)\n        if task.wait() != 0:\n            return self.stderr.write(self.style.ERROR('获取更新失败，排除网络问题后请附带输出内容至官方论坛反馈。'))\n\n        # update dep\n        commands = [\n            f'cd {settings.BASE_DIR}',\n            'pip3 install -r requirements.txt -i https://pypi.doubanio.com/simple/'\n        ]\n        task = subprocess.Popen(' && '.join(commands), shell=True)\n        if task.wait() != 0:\n            return self.stderr.write(self.style.ERROR('更新依赖包失败，排除网络问题后请附带输出内容至官方论坛反馈。'))\n\n        # update db\n        apps = [x.split('.')[-1] for x in settings.INSTALLED_APPS if x.startswith('apps.')]\n        commands = [\n            f'cd {settings.BASE_DIR}',\n            f'python3 ./manage.py makemigrations ' + ' '.join(apps),\n            f'python3 ./manage.py migrate',\n            f'python3 ./tools/migrate.py {settings.SPUG_VERSION}'\n        ]\n        task = subprocess.Popen(' && '.join(commands), shell=True)\n        if task.wait() != 0:\n            return self.stderr.write(self.style.ERROR('更新表结构失败，请附带输出内容至官方论坛反馈。'))\n\n        self.stdout.write(self.style.SUCCESS('''升级成功，请自行重启服务，如果通过官方文档安装一般重启命令为\n        Docker: docker restart spug\n        Centos: systemctl restart supervisord \n        Ubuntu: systemctl restart supervisor\n        '''))\n        self.stderr.write(self.style.WARNING(f'最后别忘了刷新浏览器，确保系统设置/关于里的api与web版本一致哦～'))\n"
  },
  {
    "path": "spug_api/apps/account/management/commands/updatedb.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom django.core.management import execute_from_command_line\nfrom django.conf import settings\n\n\nclass Command(BaseCommand):\n    help = '初始化/更新数据库'\n\n    def handle(self, *args, **options):\n        args = ['manage.py', 'makemigrations']\n        apps = [x.split('.')[-1] for x in settings.INSTALLED_APPS if x.startswith('apps.')]\n        execute_from_command_line(args + apps)\n        execute_from_command_line(['manage.py', 'migrate'])\n        self.stdout.write(self.style.SUCCESS('初始化/更新成功'))\n"
  },
  {
    "path": "spug_api/apps/account/management/commands/user.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom django.core.cache import cache\nfrom apps.account.models import User\n\n\nclass Command(BaseCommand):\n    help = '账户管理'\n\n    def add_arguments(self, parser):\n        parser.add_argument('action', type=str, help='执行动作')\n        parser.add_argument('-u', required=False, help='账户名称')\n        parser.add_argument('-p', required=False, help='账户密码')\n        parser.add_argument('-n', required=False, help='账户昵称')\n        parser.add_argument('-s', default=False, action='store_true', help='是否是超级用户（默认否）')\n\n    def echo_success(self, msg):\n        self.stdout.write(self.style.SUCCESS(msg))\n\n    def echo_error(self, msg):\n        self.stderr.write(self.style.ERROR(msg))\n\n    def print_help(self, *args):\n        message = '''\n        账户管理命令用法：\n            user add    创建账户，例如：user add -u admin -p 123 -n 管理员 -s\n            user reset  重置账户密码，例如：user reset -u admin -p 123\n            user enable 启用被禁用的账户，例如：user enable -u admin\n        '''\n        self.stdout.write(message)\n\n    def handle(self, *args, **options):\n        action = options['action']\n        if action == 'add':\n            if not all((options['u'], options['p'], options['n'])):\n                self.echo_error('缺少参数')\n                self.print_help()\n            elif User.objects.filter(username=options['u'], deleted_by_id__isnull=True).exists():\n                self.echo_error(f'已存在登录名为【{options[\"u\"]}】的用户')\n            else:\n                User.objects.create(\n                    username=options['u'],\n                    nickname=options['n'],\n                    password_hash=User.make_password(options['p']),\n                    is_supper=options['s'],\n                )\n                self.echo_success('创建用户成功')\n        elif action == 'enable':\n            if not options['u']:\n                self.echo_error('缺少参数')\n                self.print_help()\n            user = User.objects.filter(username=options['u'], deleted_by_id__isnull=True).first()\n            if user:\n                user.is_active = True\n                user.save()\n            cache.delete(user.username)\n            self.echo_success('账户已启用')\n        elif action == 'reset':\n            if not all((options['u'], options['p'])):\n                self.echo_error('缺少参数')\n                self.print_help()\n            user = User.objects.filter(username=options['u'], deleted_by_id__isnull=True).first()\n            if not user:\n                return self.echo_error(f'未找到登录名为【{options[\"u\"]}】的账户')\n            user.password_hash = User.make_password(options['p'])\n            user.save()\n            cache.delete(user.username)\n            self.echo_success('账户密码已重置')\n        else:\n            self.echo_error('未识别的操作')\n            self.print_help()\n"
  },
  {
    "path": "spug_api/apps/account/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom django.core.cache import cache\nfrom libs import ModelMixin, human_datetime\nfrom django.contrib.auth.hashers import make_password, check_password\nimport json\n\n\nclass User(models.Model, ModelMixin):\n    username = models.CharField(max_length=100)\n    nickname = models.CharField(max_length=100)\n    password_hash = models.CharField(max_length=100)  # hashed password\n    type = models.CharField(max_length=20, default='default')\n    is_supper = models.BooleanField(default=False)\n    is_active = models.BooleanField(default=True)\n    access_token = models.CharField(max_length=32)\n    token_expired = models.IntegerField(null=True)\n    last_login = models.CharField(max_length=20)\n    last_ip = models.CharField(max_length=50)\n    wx_token = models.CharField(max_length=50, null=True)\n    roles = models.ManyToManyField('Role', db_table='user_role_rel')\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True)\n    deleted_at = models.CharField(max_length=20, null=True)\n    deleted_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True)\n\n    @staticmethod\n    def make_password(plain_password: str) -> str:\n        return make_password(plain_password, hasher='pbkdf2_sha256')\n\n    def verify_password(self, plain_password: str) -> bool:\n        return check_password(plain_password, self.password_hash)\n\n    def get_perms_cache(self):\n        return cache.get(f'perms_{self.id}', set())\n\n    def set_perms_cache(self, value=None):\n        cache.set(f'perms_{self.id}', value or set())\n\n    @property\n    def page_perms(self):\n        data = self.get_perms_cache()\n        if data:\n            return data\n        for item in self.roles.all():\n            if item.page_perms:\n                perms = json.loads(item.page_perms)\n                for m, v in perms.items():\n                    for p, d in v.items():\n                        data.update(f'{m}.{p}.{x}' for x in d)\n        self.set_perms_cache(data)\n        return data\n\n    @property\n    def deploy_perms(self):\n        data = {'apps': set(), 'envs': set()}\n        for item in self.roles.all():\n            if item.deploy_perms:\n                perms = json.loads(item.deploy_perms)\n                data['apps'].update(perms.get('apps', []))\n                data['envs'].update(perms.get('envs', []))\n        data['apps'].update(x.id for x in self.app_set.all())\n        return data\n\n    @property\n    def group_perms(self):\n        data = set()\n        for item in self.roles.all():\n            if item.group_perms:\n                data.update(json.loads(item.group_perms))\n        return list(data)\n\n    def has_perms(self, codes):\n        if self.is_supper:\n            return True\n        return self.page_perms.intersection(codes)\n\n    def __repr__(self):\n        return '<User %r>' % self.username\n\n    class Meta:\n        db_table = 'users'\n        ordering = ('-id',)\n\n\nclass Role(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    desc = models.CharField(max_length=255, null=True)\n    page_perms = models.TextField(null=True)\n    deploy_perms = models.TextField(null=True)\n    group_perms = models.TextField(null=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['page_perms'] = json.loads(self.page_perms) if self.page_perms else {}\n        tmp['deploy_perms'] = json.loads(self.deploy_perms) if self.deploy_perms else {}\n        tmp['group_perms'] = json.loads(self.group_perms) if self.group_perms else []\n        tmp['used'] = self.user_set.filter(deleted_by_id__isnull=True).count()\n        return tmp\n\n    def add_deploy_perm(self, target, value):\n        perms = {'apps': [], 'envs': []}\n        if self.deploy_perms:\n            perms.update(json.loads(self.deploy_perms))\n        perms[target].append(value)\n        self.deploy_perms = json.dumps(perms)\n        self.save()\n\n    def clear_perms_cache(self):\n        for item in self.user_set.all():\n            item.set_perms_cache()\n\n    def __repr__(self):\n        return '<Role name=%r>' % self.name\n\n    class Meta:\n        db_table = 'roles'\n        ordering = ('-id',)\n\n\nclass History(models.Model, ModelMixin):\n    username = models.CharField(max_length=100, null=True)\n    type = models.CharField(max_length=20, default='default')\n    ip = models.CharField(max_length=50)\n    agent = models.CharField(max_length=255, null=True)\n    message = models.CharField(max_length=255, null=True)\n    is_success = models.BooleanField(default=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n\n    class Meta:\n        db_table = 'login_histories'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/account/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.conf.urls import url\n\nfrom apps.account.views import *\nfrom apps.account.history import *\n\nurlpatterns = [\n    url(r'^login/$', login),\n    url(r'^logout/$', logout),\n    url(r'^user/$', UserView.as_view()),\n    url(r'^role/$', RoleView.as_view()),\n    url(r'^self/$', SelfView.as_view()),\n    url(r'^login/history/$', HistoryView.as_view())\n]\n"
  },
  {
    "path": "spug_api/apps/account/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apps.host.models import Group\nimport re\n\n\ndef get_host_perms(user):\n    ids = sub_ids = set(user.group_perms)\n    while sub_ids:\n        sub_ids = [x.id for x in Group.objects.filter(parent_id__in=sub_ids)]\n        ids.update(sub_ids)\n    return set(x.host_id for x in Group.hosts.through.objects.filter(group_id__in=ids))\n\n\ndef has_host_perm(user, target):\n    if user.is_supper:\n        return True\n    host_ids = get_host_perms(user)\n    if isinstance(target, (list, set, tuple)):\n        return set(target).issubset(host_ids)\n    return int(target) in host_ids\n\n\ndef verify_password(password):\n    if len(password) < 8:\n        return False\n    if not all(map(lambda x: re.findall(x, password), ['[0-9]', '[a-z]', '[A-Z]'])):\n        return False\n    return True\n"
  },
  {
    "path": "spug_api/apps/account/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.cache import cache\nfrom django.conf import settings\nfrom libs.mixins import AdminView, View\nfrom libs import JsonParser, Argument, human_datetime, json_response\nfrom libs.utils import get_request_real_ip, generate_random_str\nfrom libs.push import send_login_code\nfrom apps.account.models import User, Role, History\nfrom apps.setting.utils import AppSetting\nfrom apps.account.utils import verify_password\nfrom libs.ldap import LDAP\nfrom functools import partial\nimport user_agents\nimport ipaddress\nimport time\nimport uuid\nimport json\n\n\nclass UserView(AdminView):\n    def get(self, request):\n        users = []\n        for u in User.objects.filter(deleted_by_id__isnull=True):\n            tmp = u.to_dict(excludes=('access_token', 'password_hash'))\n            tmp['role_ids'] = [x.id for x in u.roles.all()]\n            tmp['password'] = '******'\n            users.append(tmp)\n        return json_response(users)\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('username', help='请输入登录名'),\n            Argument('password', help='请输入密码'),\n            Argument('nickname', help='请输入姓名'),\n            Argument('role_ids', type=list, default=[]),\n            Argument('wx_token', required=False),\n        ).parse(request.body)\n        if error is None:\n            user = User.objects.filter(username=form.username, deleted_by_id__isnull=True).first()\n            if user and (not form.id or form.id != user.id):\n                return json_response(error=f'已存在登录名为【{form.username}】的用户')\n\n            role_ids, password = form.pop('role_ids'), form.pop('password')\n            if form.id:\n                user = User.objects.get(pk=form.id)\n                user.update_by_dict(form)\n            else:\n                if not verify_password(password):\n                    return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码')\n                user = User.objects.create(\n                    password_hash=User.make_password(password),\n                    created_by=request.user,\n                    **form\n                )\n            user.roles.set(role_ids)\n            user.set_perms_cache()\n        return json_response(error=error)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('password', required=False),\n            Argument('is_active', type=bool, required=False),\n        ).parse(request.body)\n        if error is None:\n            user = User.objects.get(pk=form.id)\n            if form.password:\n                if not verify_password(form.password):\n                    return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码')\n                user.token_expired = 0\n                user.password_hash = User.make_password(form.pop('password'))\n            if form.is_active is not None:\n                user.is_active = form.is_active\n                cache.delete(user.username)\n            user.save()\n        return json_response(error=error)\n\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            user = User.objects.filter(pk=form.id).first()\n            if user:\n                if user.type == 'ldap':\n                    return json_response(error='ldap账户无法删除，请使用禁用功能来禁止该账户访问系统')\n                if user.id == request.user.id:\n                    return json_response(error='无法删除当前登录账户')\n                user.is_active = True\n                user.deleted_at = human_datetime()\n                user.deleted_by = request.user\n                user.roles.clear()\n                user.save()\n        return json_response(error=error)\n\n\nclass RoleView(AdminView):\n    def get(self, request):\n        roles = Role.objects.all()\n        return json_response(roles)\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入角色名称'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            if form.id:\n                Role.objects.filter(pk=form.id).update(**form)\n            else:\n                Role.objects.create(created_by=request.user, **form)\n        return json_response(error=error)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('page_perms', type=dict, required=False),\n            Argument('deploy_perms', type=dict, required=False),\n            Argument('group_perms', type=list, required=False)\n        ).parse(request.body)\n        if error is None:\n            role = Role.objects.filter(pk=form.pop('id')).first()\n            if not role:\n                return json_response(error='未找到指定角色')\n            if form.page_perms is not None:\n                role.page_perms = json.dumps(form.page_perms)\n                role.clear_perms_cache()\n            if form.deploy_perms is not None:\n                role.deploy_perms = json.dumps(form.deploy_perms)\n            if form.group_perms is not None:\n                role.group_perms = json.dumps(form.group_perms)\n            role.user_set.update(token_expired=0)\n            role.save()\n        return json_response(error=error)\n\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            role = Role.objects.get(pk=form.id)\n            if role.user_set.exists():\n                return json_response(error='已有用户使用了该角色，请解除关联后再尝试删除')\n            role.delete()\n        return json_response(error=error)\n\n\nclass SelfView(View):\n    def get(self, request):\n        data = request.user.to_dict(selects=('nickname', 'wx_token'))\n        return json_response(data)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('old_password', required=False),\n            Argument('new_password', required=False),\n            Argument('nickname', required=False, help='请输入昵称'),\n            Argument('wx_token', required=False),\n        ).parse(request.body)\n        if error is None:\n            if form.old_password and form.new_password:\n                if request.user.type == 'ldap':\n                    return json_response(error='LDAP账户无法修改密码')\n\n                if not verify_password(form.new_password):\n                    return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码')\n\n                if request.user.verify_password(form.old_password):\n                    request.user.password_hash = User.make_password(form.new_password)\n                    request.user.token_expired = 0\n                    request.user.save()\n                    return json_response()\n                else:\n                    return json_response(error='原密码错误，请重新输入')\n            if form.nickname is not None:\n                request.user.nickname = form.nickname\n            if form.wx_token is not None:\n                request.user.wx_token = form.wx_token\n            request.user.save()\n        return json_response(error=error)\n\n\ndef login(request):\n    form, error = JsonParser(\n        Argument('username', help='请输入用户名'),\n        Argument('password', help='请输入密码'),\n        Argument('captcha', required=False),\n        Argument('type', required=False)\n    ).parse(request.body)\n    if error is None:\n        handle_response = partial(handle_login_record, request, form.username, form.type)\n        user = User.objects.filter(username=form.username, type=form.type).first()\n        if user and not user.is_active:\n            return handle_response(error=\"账户已被系统禁用\")\n        if form.type == 'ldap':\n            config = AppSetting.get_default('ldap_service')\n            if not config:\n                return handle_response(error='请在系统设置中配置LDAP后再尝试通过该方式登录')\n            ldap = LDAP(**config)\n            is_success, message = ldap.valid_user(form.username, form.password)\n            if is_success:\n                if not user:\n                    user = User.objects.create(username=form.username, nickname=form.username, type=form.type)\n                return handle_user_info(handle_response, request, user, form.captcha)\n            elif message:\n                return handle_response(error=message)\n        else:\n            if user and user.deleted_by is None:\n                if user.verify_password(form.password):\n                    return handle_user_info(handle_response, request, user, form.captcha)\n\n        value = cache.get_or_set(form.username, 0, 86400)\n        if value >= 3:\n            if user and user.is_active:\n                user.is_active = False\n                user.save()\n            return handle_response(error='账户已被系统禁用')\n        cache.set(form.username, value + 1, 86400)\n        return handle_response(error=\"用户名或密码错误，连续多次错误账户将会被禁用\")\n    return json_response(error=error)\n\n\ndef handle_login_record(request, username, login_type, error=None):\n    x_real_ip = get_request_real_ip(request.headers)\n    user_agent = user_agents.parse(request.headers.get('User-Agent'))\n    History.objects.create(\n        username=username,\n        type=login_type,\n        ip=x_real_ip,\n        agent=user_agent,\n        is_success=False if error else True,\n        message=error\n    )\n    if error:\n        return json_response(error=error)\n\n\ndef handle_user_info(handle_response, request, user, captcha):\n    cache.delete(user.username)\n    key = f'{user.username}:code'\n    if captcha:\n        code = cache.get(key)\n        if not code:\n            return handle_response(error='验证码已失效，请重新获取')\n        if code != captcha:\n            ttl = cache.ttl(key)\n            cache.expire(key, ttl - 100)\n            return handle_response(error='验证码错误')\n        cache.delete(key)\n    else:\n        mfa = AppSetting.get_default('MFA', {'enable': False})\n        if mfa['enable']:\n            if not user.wx_token:\n                return handle_response(error='已启用登录双重认证，但您的账户未配置推送标识，请联系管理员')\n            spug_push_key = AppSetting.get_default('spug_push_key')\n            if not spug_push_key:\n                return handle_response(error='已启用登录双重认证，但系统未配置推送服务，请联系管理员')\n            code = generate_random_str(6)\n            send_login_code(spug_push_key, user.wx_token, code)\n            cache.set(key, code, 300)\n            return json_response({'required_mfa': True})\n\n    handle_response()\n    x_real_ip = get_request_real_ip(request.headers)\n    token_isvalid = user.access_token and len(user.access_token) == 32 and user.token_expired >= time.time()\n    user.access_token = user.access_token if token_isvalid else uuid.uuid4().hex\n    user.token_expired = time.time() + settings.TOKEN_TTL\n    user.last_login = human_datetime()\n    user.last_ip = x_real_ip\n    user.save()\n    verify_ip = AppSetting.get_default('verify_ip', True)\n    return json_response({\n        'id': user.id,\n        'access_token': user.access_token,\n        'nickname': user.nickname,\n        'is_supper': user.is_supper,\n        'has_real_ip': x_real_ip and ipaddress.ip_address(x_real_ip).is_global if verify_ip else True,\n        'permissions': [] if user.is_supper else list(user.page_perms)\n    })\n\n\ndef logout(request):\n    request.user.token_expired = 0\n    request.user.save()\n    return json_response()\n"
  },
  {
    "path": "spug_api/apps/alarm/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/alarm/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nimport json\n\n\nclass Alarm(models.Model, ModelMixin):\n    MODES = (\n        ('1', '微信'),\n        ('2', '短信'),\n        ('3', '钉钉'),\n        ('4', '邮件'),\n        ('5', '企业微信'),\n        ('6', '电话'),\n        ('7', '飞书'),\n    )\n    STATUS = (\n        ('1', '报警发生'),\n        ('2', '故障恢复'),\n    )\n    name = models.CharField(max_length=50)\n    type = models.CharField(max_length=50)\n    target = models.CharField(max_length=100)\n    notify_mode = models.CharField(max_length=255)\n    notify_grp = models.CharField(max_length=255)\n    status = models.CharField(max_length=2, choices=STATUS)\n    duration = models.CharField(max_length=50)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['notify_mode'] = ','.join(dict(self.MODES)[x] for x in json.loads(self.notify_mode))\n        tmp['notify_grp'] = json.loads(self.notify_grp)\n        tmp['status_alias'] = self.get_status_display()\n        return tmp\n\n    def __repr__(self):\n        return '<Alarm %r>' % self.name\n\n    class Meta:\n        db_table = 'alarms'\n        ordering = ('-id',)\n\n\nclass Group(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    desc = models.CharField(max_length=255, null=True)\n    contacts = models.TextField(null=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['contacts'] = json.loads(self.contacts)\n        return tmp\n\n    def __repr__(self):\n        return '<AlarmGroup %r>' % self.name\n\n    class Meta:\n        db_table = 'alarm_groups'\n        ordering = ('-id',)\n\n\nclass Contact(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    phone = models.CharField(max_length=20, null=True)\n    email = models.CharField(max_length=255, null=True)\n    ding = models.CharField(max_length=255, null=True)\n    wx_token = models.CharField(max_length=255, null=True)\n    qy_wx = models.CharField(max_length=255, null=True)\n    feishu = models.CharField(max_length=255, null=True)\n    secret = models.TextField(null=True)\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n\n    def __repr__(self):\n        return '<AlarmContact %r>' % self.name\n\n    class Meta:\n        db_table = 'alarm_contacts'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/alarm/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('alarm/', AlarmView.as_view()),\n    path('group/', GroupView.as_view()),\n    path('contact/', ContactView.as_view()),\n    path('test/', handle_test),\n]\n"
  },
  {
    "path": "spug_api/apps/alarm/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom libs import json_response, JsonParser, Argument, auth\nfrom libs.spug import Notification\nfrom libs.push import get_contacts\nfrom apps.alarm.models import Alarm, Group, Contact\nfrom apps.monitor.models import Detection\nfrom apps.setting.utils import AppSetting\nimport json\n\n\nclass AlarmView(View):\n    @auth('alarm.alarm.view')\n    def get(self, request):\n        alarms = Alarm.objects.all()\n        return json_response(alarms)\n\n\nclass GroupView(View):\n    @auth('alarm.group.view|monitor.monitor.add|monitor.monitor.edit|alarm.alarm.view')\n    def get(self, request):\n        groups = Group.objects.all()\n        return json_response(groups)\n\n    @auth('alarm.group.add|alarm.group.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入组名'),\n            Argument('contacts', type=list, help='请选择联系人'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            form.contacts = json.dumps(form.contacts)\n            if form.id:\n                Group.objects.filter(pk=form.id).update(**form)\n            else:\n                form.created_by = request.user\n                Group.objects.create(**form)\n        return json_response(error=error)\n\n    @auth('alarm.group.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            detection = Detection.objects.filter(notify_grp__regex=fr'[^0-9]{form.id}[^0-9]').first()\n            if detection:\n                return json_response(error=f'监控任务【{detection.name}】正在使用该报警组，请解除关联后再尝试删除该联系组')\n            Group.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\nclass ContactView(View):\n    @auth('alarm.contact.view|alarm.group.view|schedule.schedule.add|schedule.schedule.edit')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('with_push', required=False),\n            Argument('only_push', required=False),\n        ).parse(request.GET)\n        if error is None:\n            response = []\n            if form.with_push or form.only_push:\n                push_key = AppSetting.get_default('spug_push_key')\n                if push_key:\n                    response = get_contacts(push_key)\n                if form.only_push:\n                    return json_response(response)\n\n            for item in Contact.objects.all():\n                response.append(item.to_dict())\n            return json_response(response)\n        return json_response(error=error)\n\n    @auth('alarm.contact.add|alarm.contact.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入联系人姓名'),\n            Argument('phone', required=False),\n            Argument('email', required=False),\n            Argument('ding', required=False),\n            Argument('wx_token', required=False),\n            Argument('qy_wx', required=False),\n            Argument('feishu', required=False),\n            Argument('secret', required=False),\n        ).parse(request.body)\n        if error is None:\n            if form.id:\n                Contact.objects.filter(pk=form.id).update(**form)\n            else:\n                form.created_by = request.user\n                Contact.objects.create(**form)\n        return json_response(error=error)\n\n    @auth('alarm.contact.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            group = Group.objects.filter(contacts__contains=f'\\\"{form.id}\\\"').first()\n            if group:\n                return json_response(error=f'报警联系组【{group.name}】包含此联系人，请解除关联后再尝试删除该联系人')\n            Contact.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\n@auth('alarm.contact.add|alarm.contact.edit')\ndef handle_test(request):\n    form, error = JsonParser(\n        Argument('mode', help='参数错误'),\n        Argument('value', help='参数错误')\n    ).parse(request.body)\n    if error is None:\n        notify = Notification(None, '1', 'https://spug.cc', 'Spug官网（测试）', '这是一条测试告警信息', None)\n        if form.mode == '3':\n            notify.monitor_by_dd([(form.value, None)])\n        elif form.mode == '4':\n            notify.monitor_by_email([form.value])\n        elif form.mode == '5':\n            notify.monitor_by_qy_wx([form.value])\n        elif form.mode == '7':\n            notify.monitor_by_fs([(form.value, None)])\n    return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/apis/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/apis/config.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.http.response import HttpResponse\nfrom django_redis import get_redis_connection\nfrom apps.config.models import Environment\nfrom apps.app.models import App\nfrom apps.setting.utils import AppSetting\nfrom apps.config.utils import compose_configs\nimport json\n\n\ndef get_configs(request):\n    app, env_id, no_prefix = _parse_params(request)\n    if not app or not env_id:\n        return HttpResponse('Invalid params', status=400)\n\n    configs = compose_configs(app, env_id, no_prefix)\n    fmt = request.GET.get('format', 'kv')\n    if fmt == 'kv':\n        return _kv_response(configs)\n    elif fmt == 'env':\n        return _env_response(configs)\n    elif fmt == 'json':\n        return _json_response(configs)\n    else:\n        return HttpResponse('Unsupported output format', status=400)\n\n\ndef _kv_response(data):\n    output = ''\n    for k, v in sorted(data.items()):\n        output += f'{k} = {v}\\r\\n'\n    return HttpResponse(output, content_type='text/plain; charset=utf-8')\n\n\ndef _env_response(data):\n    output = ''\n    for k, v in sorted(data.items()):\n        output += f'{k}={v}\\n'\n    return HttpResponse(output, content_type='text/plain; charset=utf-8')\n\n\ndef _json_response(data):\n    data = dict(sorted(data.items()))\n    return HttpResponse(json.dumps(data), content_type='application/json')\n\n\ndef _parse_params(request):\n    app, env_id = None, None\n    api_token = request.GET.get('apiToken')\n    if api_token:\n        rds = get_redis_connection()\n        content = rds.get(api_token)\n        if content:\n            app_id, env_id = content.decode().split(',')\n            app = App.objects.filter(pk=app_id).first()\n    else:\n        api_key = AppSetting.get_default('api_key')\n        if api_key and request.GET.get('apiKey') == api_key:\n            app_key = request.GET.get('app')\n            env_key = request.GET.get('env')\n            if app_key and env_key:\n                app = App.objects.filter(key=app_key).first()\n                env = Environment.objects.filter(key=env_key).first()\n                if env:\n                    env_id = env.id\n    return app, env_id, request.GET.get('noPrefix')\n"
  },
  {
    "path": "spug_api/apps/apis/deploy.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.http.response import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse\nfrom apps.setting.utils import AppSetting\nfrom apps.deploy.models import Deploy, DeployRequest\nfrom apps.repository.models import Repository\nfrom apps.deploy.utils import dispatch as deploy_dispatch\nfrom libs.utils import human_datetime\nfrom threading import Thread\nimport hashlib\nimport hmac\nimport json\n\n\ndef auto_deploy(request, deploy_id, kind):\n    repo, body = _parse_request(request)\n    if not repo:\n        return HttpResponseForbidden()\n\n    try:\n        _, _kind, ref = body['ref'].split('/', 2)\n        if kind == 'branch' and _kind == 'heads':\n            commit_id = body['after']\n            if commit_id != '0000000000000000000000000000000000000000' and ref == request.GET.get('name'):\n                message = _parse_message(body, repo)\n                Thread(target=_dispatch, args=(deploy_id, ref, commit_id, message)).start()\n                return HttpResponse(status=202)\n        elif kind == 'tag' and _kind == 'tags':\n            Thread(target=_dispatch, args=(deploy_id, ref)).start()\n            return HttpResponse(status=202)\n        return HttpResponse(status=204)\n    except Exception as e:\n        return HttpResponseBadRequest(e)\n\n\ndef _parse_request(request):\n    api_key = AppSetting.get_default('api_key')\n    token, repo, body = None, None, None\n    token = request.headers.get('X-Gitlab-Token')\n    if 'X-Gitlab-Token' in request.headers:\n        token = request.headers['X-Gitlab-Token']\n        repo = 'Gitlab'\n    elif 'X-Gitee-Token' in request.headers:\n        token = request.headers['X-Gitee-Token']\n        repo = 'Gitee'\n    elif 'X-Codeup-Token' in request.headers:\n        token = request.headers['X-Codeup-Token']\n        repo = 'Codeup'\n    elif 'X-Gogs-Signature' in request.headers:\n        token = request.headers['X-Gogs-Signature']\n        repo = 'Gogs'\n    elif 'X-Hub-Signature-256' in request.headers:\n        token = request.headers['X-Hub-Signature-256'].replace('sha256=', '')\n        repo = 'Github'\n    elif 'X-Coding-Signature' in request.headers:\n        token = request.headers['X-Coding-Signature'].replace('sha1=', '')\n        repo = 'Coding'\n    elif 'token' in request.GET:  # Compatible the old version of gitlab\n        token = request.GET.get('token')\n        repo = 'Gitlab'\n\n    if repo in ['Gitlab', 'Gitee', 'Codeup']:\n        if token != api_key:\n            return None, None\n    elif repo in ['Github', 'Gogs']:\n        en_api_key = hmac.new(api_key.encode(), request.body, hashlib.sha256).hexdigest()\n        if token != en_api_key:\n            return None, None\n    elif repo in ['Coding']:\n        en_api_key = hmac.new(api_key.encode(), request.body, hashlib.sha1).hexdigest()\n        if token != en_api_key:\n            return None, None\n    else:\n        return None, None\n\n    body = json.loads(request.body)\n    if repo == 'Gogs' and not body['ref'].startswith('refs/'):\n        body['ref'] = 'refs/tags/' + body['ref']\n\n    return repo, body\n\n\ndef _parse_message(body, repo):\n    message = None\n    if repo in ['Gitee', 'Github', 'Coding']:\n        message = body.get('head_commit', {}).get('message', '')\n    elif repo in ['Gitlab', 'Codeup', 'Gogs']:\n        if body.get('commits'):\n            message = body['commits'][0].get('message', '')\n    else:\n        raise ValueError(f'repo {repo} is not supported')\n    return message[:20].strip()\n\n\ndef _dispatch(deploy_id, ref, commit_id=None, message=None):\n    deploy = Deploy.objects.filter(pk=deploy_id).first()\n    if not deploy:\n        raise Exception(f'no such deploy id for {deploy_id}')\n\n    req = DeployRequest(\n        type='3',\n        status='0' if deploy.is_audit else '2',\n        deploy=deploy,\n        spug_version=Repository.make_spug_version(deploy.id),\n        host_ids=deploy.host_ids,\n        created_by=deploy.created_by\n    )\n\n    if commit_id:  # branch\n        req.version = f'{ref}#{commit_id[:6]}'\n        req.name = message or req.version\n        if deploy.extend == '1':\n            req.extra = json.dumps(['branch', ref, commit_id])\n    else:  # tag\n        req.version = ref\n        req.name = ref\n        if deploy.extend == '1':\n            req.extra = json.dumps(['tag', ref, None])\n\n    req.save()\n    if req.status == '2':\n        req.do_at = human_datetime()\n        req.do_by = deploy.created_by\n        req.save()\n        deploy_dispatch(req)\n"
  },
  {
    "path": "spug_api/apps/apis/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom apps.apis import config\nfrom apps.apis import deploy\n\nurlpatterns = [\n    path('config/', config.get_configs),\n    path('deploy/<int:deploy_id>/<str:kind>/', deploy.auto_deploy)\n]\n"
  },
  {
    "path": "spug_api/apps/app/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/app/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom django.conf import settings\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nfrom apps.config.models import Environment\nimport subprocess\nimport json\nimport os\n\n\nclass App(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    key = models.CharField(max_length=50, unique=True)\n    desc = models.CharField(max_length=255, null=True)\n    rel_apps = models.TextField(null=True)\n    rel_services = models.TextField(null=True)\n    sort_id = models.IntegerField(default=0, db_index=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['rel_apps'] = json.loads(self.rel_apps) if self.rel_apps else []\n        tmp['rel_services'] = json.loads(self.rel_services) if self.rel_services else []\n        return tmp\n\n    def __repr__(self):\n        return f'<App {self.name!r}>'\n\n    class Meta:\n        db_table = 'apps'\n        ordering = ('-sort_id',)\n\n\nclass Deploy(models.Model, ModelMixin):\n    EXTENDS = (\n        ('1', '常规发布'),\n        ('2', '自定义发布'),\n    )\n    app = models.ForeignKey(App, on_delete=models.PROTECT)\n    env = models.ForeignKey(Environment, on_delete=models.PROTECT)\n    host_ids = models.TextField()\n    extend = models.CharField(max_length=2, choices=EXTENDS)\n    is_audit = models.BooleanField()\n    is_parallel = models.BooleanField(default=True)\n    rst_notify = models.CharField(max_length=255, null=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n    updated_at = models.CharField(max_length=20, null=True)\n    updated_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n\n    @property\n    def extend_obj(self):\n        cls = DeployExtend1 if self.extend == '1' else DeployExtend2\n        return cls.objects.filter(deploy=self).first()\n\n    def to_dict(self, *args, **kwargs):\n        deploy = super().to_dict(*args, **kwargs)\n        deploy['app_key'] = self.app_key if hasattr(self, 'app_key') else None\n        deploy['app_name'] = self.app_name if hasattr(self, 'app_name') else None\n        deploy['host_ids'] = json.loads(self.host_ids)\n        deploy['rst_notify'] = json.loads(self.rst_notify)\n        deploy.update(self.extend_obj.to_dict())\n        return deploy\n\n    def delete(self, using=None, keep_parents=False):\n        deploy_id = self.id\n        super().delete(using, keep_parents)\n        repo_dir = os.path.join(settings.REPOS_DIR, str(deploy_id))\n        build_dir = os.path.join(settings.BUILD_DIR, f'{deploy_id}_*')\n        subprocess.Popen(f'rm -rf {repo_dir} {repo_dir + \"_*\"} {build_dir}', shell=True)\n\n    def __repr__(self):\n        return '<Deploy app_id=%r env_id=%r>' % (self.app_id, self.env_id)\n\n    class Meta:\n        db_table = 'deploys'\n        ordering = ('-id',)\n\n\nclass DeployExtend1(models.Model, ModelMixin):\n    deploy = models.OneToOneField(Deploy, primary_key=True, on_delete=models.CASCADE)\n    git_repo = models.CharField(max_length=255)\n    dst_dir = models.CharField(max_length=255)\n    dst_repo = models.CharField(max_length=255)\n    versions = models.IntegerField()\n    filter_rule = models.TextField()\n    hook_pre_server = models.TextField(null=True)\n    hook_post_server = models.TextField(null=True)\n    hook_pre_host = models.TextField(null=True)\n    hook_post_host = models.TextField(null=True)\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['filter_rule'] = json.loads(self.filter_rule)\n        return tmp\n\n    def __repr__(self):\n        return '<DeployExtend1 deploy_id=%r>' % self.deploy_id\n\n    class Meta:\n        db_table = 'deploy_extend1'\n\n\nclass DeployExtend2(models.Model, ModelMixin):\n    deploy = models.OneToOneField(Deploy, primary_key=True, on_delete=models.CASCADE)\n    server_actions = models.TextField()\n    host_actions = models.TextField()\n    require_upload = models.BooleanField(default=False)\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['server_actions'] = json.loads(self.server_actions)\n        tmp['host_actions'] = json.loads(self.host_actions)\n        return tmp\n\n    def __repr__(self):\n        return '<DeployExtend2 deploy_id=%r>' % self.deploy_id\n\n    class Meta:\n        db_table = 'deploy_extend2'\n"
  },
  {
    "path": "spug_api/apps/app/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', AppView.as_view()),\n    path('kit/key/', kit_key),\n    path('deploy/', DeployView.as_view()),\n    path('deploy/<int:d_id>/versions/', get_versions),\n]\n"
  },
  {
    "path": "spug_api/apps/app/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.conf import settings\nfrom apps.app.models import Deploy\nfrom apps.setting.utils import AppSetting\nfrom libs.gitlib import Git\nimport shutil\nimport os\n\n\ndef parse_envs(text):\n    data = {}\n    if text:\n        for line in text.split('\\n'):\n            fields = line.split('=', 1)\n            if len(fields) != 2 or fields[0].strip() == '':\n                raise Exception(f'解析自定义全局变量{line!r}失败，确认其遵循 key = value 格式')\n            data[fields[0].strip()] = fields[1].strip()\n    return data\n\n\ndef fetch_versions(deploy: Deploy):\n    git_repo = deploy.extend_obj.git_repo\n    repo_dir = os.path.join(settings.REPOS_DIR, str(deploy.id))\n    pkey = AppSetting.get_default('private_key')\n    with Git(git_repo, repo_dir, pkey) as git:\n        return git.fetch_branches_tags()\n\n\ndef fetch_repo(deploy_id, git_repo):\n    repo_dir = os.path.join(settings.REPOS_DIR, str(deploy_id))\n    pkey = AppSetting.get_default('private_key')\n    with Git(git_repo, repo_dir, pkey) as git:\n        return git.fetch_branches_tags()\n\n\ndef remove_repo(deploy_id):\n    shutil.rmtree(os.path.join(settings.REPOS_DIR, str(deploy_id)), True)\n"
  },
  {
    "path": "spug_api/apps/app/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom libs import JsonParser, Argument, json_response, auth\nfrom apps.app.models import App, Deploy, DeployExtend1, DeployExtend2\nfrom apps.config.models import Config, ConfigHistory, Service\nfrom apps.app.utils import fetch_versions, remove_repo\nfrom apps.setting.utils import AppSetting\nimport json\nimport re\n\n\nclass AppView(View):\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False)\n        ).parse(request.GET)\n        if error is None:\n            if request.user.is_supper:\n                apps = App.objects.all()\n            else:\n                ids = request.user.deploy_perms['apps']\n                apps = App.objects.filter(id__in=ids)\n\n            if form.id:\n                app = apps.filter(pk=form.id).first()\n                return json_response(app)\n            return json_response(apps)\n        return json_response(error=error)\n\n    @auth('deploy.app.add|deploy.app.edit|config.app.add|config.app.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入服务名称'),\n            Argument('key', help='请输入唯一标识符'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            if not re.fullmatch(r'\\w+', form.key, re.ASCII):\n                return json_response(error='标识符必须为字母、数字和下划线的组合')\n\n            app = App.objects.filter(key=form.key).first()\n            if app and app.id != form.id:\n                return json_response(error='该识符已存在，请更改后重试')\n            service = Service.objects.filter(key=form.key).first()\n            if service:\n                return json_response(error=f'该标识符已被服务 {service.name} 使用，请更改后重试')\n            if form.id:\n                App.objects.filter(pk=form.id).update(**form)\n            else:\n                app = App.objects.create(created_by=request.user, **form)\n                app.sort_id = app.id\n                app.save()\n        return json_response(error=error)\n\n    @auth('deploy.app.edit|config.app.edit_config')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('rel_apps', type=list, required=False),\n            Argument('rel_services', type=list, required=False),\n            Argument('sort', filter=lambda x: x in ('up', 'down'), required=False)\n        ).parse(request.body)\n        if error is None:\n            app = App.objects.filter(pk=form.id).first()\n            if not app:\n                return json_response(error='未找到指定应用')\n            if form.rel_apps is not None:\n                app.rel_apps = json.dumps(form.rel_apps)\n            if form.rel_services is not None:\n                app.rel_services = json.dumps(form.rel_services)\n            if form.sort:\n                if form.sort == 'up':\n                    tmp = App.objects.filter(sort_id__gt=app.sort_id).last()\n                else:\n                    tmp = App.objects.filter(sort_id__lt=app.sort_id).first()\n                if tmp:\n                    tmp.sort_id, app.sort_id = app.sort_id, tmp.sort_id\n                    tmp.save()\n            app.save()\n        return json_response(error=error)\n\n    @auth('deploy.app.del|config.app.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            if Deploy.objects.filter(app_id=form.id).exists():\n                return json_response(error='该应用在应用发布中已存在关联的发布配置，请删除相关发布配置后再尝试删除')\n            # auto delete configs\n            Config.objects.filter(type='app', o_id=form.id).delete()\n            ConfigHistory.objects.filter(type='app', o_id=form.id).delete()\n            for app in App.objects.filter(rel_apps__isnull=False):\n                rel_apps = json.loads(app.rel_apps)\n                if form.id in rel_apps:\n                    rel_apps.remove(form.id)\n                    app.rel_apps = json.dumps(rel_apps)\n                    app.save()\n            App.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\nclass DeployView(View):\n    @auth('deploy.app.view|deploy.request.view')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('app_id', type=int, required=False)\n        ).parse(request.GET, True)\n        if not request.user.is_supper:\n            perms = request.user.deploy_perms\n            form.app_id__in = perms['apps']\n            form.env_id__in = perms['envs']\n        deploys = Deploy.objects.filter(**form) \\\n            .annotate(app_name=F('app__name'), app_key=F('app__key')) \\\n            .order_by('-app__sort_id')\n        return json_response(deploys)\n\n    @auth('deploy.app.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('app_id', type=int, help='请选择应用'),\n            Argument('env_id', type=int, help='请选择环境'),\n            Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),\n            Argument('rst_notify', type=dict, help='请选择发布结果通知方式'),\n            Argument('extend', filter=lambda x: x in dict(Deploy.EXTENDS), help='请选择发布类型'),\n            Argument('is_parallel', type=bool, default=True),\n            Argument('is_audit', type=bool, default=False)\n        ).parse(request.body)\n        if error is None:\n            deploy = Deploy.objects.filter(app_id=form.app_id, env_id=form.env_id).first()\n            if deploy and deploy.id != form.id:\n                return json_response(error='应用在该环境下已经存在发布配置')\n            form.host_ids = json.dumps(form.host_ids)\n            form.rst_notify = json.dumps(form.rst_notify)\n            if form.extend == '1':\n                extend_form, error = JsonParser(\n                    Argument('git_repo', handler=str.strip, help='请输入git仓库地址'),\n                    Argument('dst_dir', handler=str.strip, help='请输入发布部署路径'),\n                    Argument('dst_repo', handler=str.strip, help='请输入发布存储路径'),\n                    Argument('versions', type=int, filter=lambda x: x > 0, help='请输入发布保留版本数量'),\n                    Argument('filter_rule', type=dict, help='参数错误'),\n                    Argument('hook_pre_server', handler=str.strip, default=''),\n                    Argument('hook_post_server', handler=str.strip, default=''),\n                    Argument('hook_pre_host', handler=str.strip, default=''),\n                    Argument('hook_post_host', handler=str.strip, default='')\n                ).parse(request.body)\n                if error:\n                    return json_response(error=error)\n                extend_form.dst_dir = extend_form.dst_dir.rstrip('/')\n                extend_form.filter_rule = json.dumps(extend_form.filter_rule)\n                if form.id:\n                    extend = DeployExtend1.objects.filter(deploy_id=form.id).first()\n                    if extend.git_repo != extend_form.git_repo:\n                        remove_repo(form.id)\n                    Deploy.objects.filter(pk=form.id).update(**form)\n                    DeployExtend1.objects.filter(deploy_id=form.id).update(**extend_form)\n                else:\n                    deploy = Deploy.objects.create(created_by=request.user, **form)\n                    DeployExtend1.objects.create(deploy=deploy, **extend_form)\n            elif form.extend == '2':\n                extend_form, error = JsonParser(\n                    Argument('server_actions', type=list, help='请输入执行动作'),\n                    Argument('host_actions', type=list, help='请输入执行动作')\n                ).parse(request.body)\n                if error:\n                    return json_response(error=error)\n                if len(extend_form.server_actions) + len(extend_form.host_actions) == 0:\n                    return json_response(error='请至少设置一个执行的动作')\n                extend_form.require_upload = any(x.get('src_mode') == '1' for x in extend_form.host_actions)\n                extend_form.server_actions = json.dumps(extend_form.server_actions)\n                extend_form.host_actions = json.dumps(extend_form.host_actions)\n                if form.id:\n                    Deploy.objects.filter(pk=form.id).update(**form)\n                    DeployExtend2.objects.filter(deploy_id=form.id).update(**extend_form)\n                else:\n                    deploy = Deploy.objects.create(created_by=request.user, **form)\n                    DeployExtend2.objects.create(deploy=deploy, **extend_form)\n        return json_response(error=error)\n\n    @auth('deploy.app.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            deploy = Deploy.objects.get(pk=form.id)\n            if deploy.deployrequest_set.exists():\n                return json_response(error='已存在关联的发布记录，请删除关联的发布记录后再尝试删除发布配置')\n            for item in deploy.repository_set.all():\n                item.delete()\n            deploy.delete()\n        return json_response(error=error)\n\n\n@auth('deploy.app.config|deploy.repository.add|deploy.request.add|deploy.request.edit')\ndef get_versions(request, d_id):\n    deploy = Deploy.objects.filter(pk=d_id).first()\n    if not deploy:\n        return json_response(error='未找到指定应用')\n    if deploy.extend == '2':\n        return json_response(error='该应用不支持此操作')\n    branches, tags = fetch_versions(deploy)\n    return json_response({'branches': branches, 'tags': tags})\n\n\n@auth('deploy.app.config|deploy.app.edit')\ndef kit_key(request):\n    form, error = JsonParser(\n        Argument('key', filter=lambda x: x in ('api_key', 'public_key'), help='参数错误')\n    ).parse(request.body)\n    if error is None:\n        api_key = AppSetting.get_default(form.key)\n        return json_response(api_key)\n    return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/config/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/config/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\n\n\nclass Environment(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    key = models.CharField(max_length=50)\n    desc = models.CharField(max_length=255, null=True)\n    sort_id = models.IntegerField(default=0, db_index=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    def __repr__(self):\n        return f'<Environment {self.name!r}>'\n\n    class Meta:\n        db_table = 'environments'\n        ordering = ('-sort_id',)\n\n\nclass Service(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    key = models.CharField(max_length=50, unique=True)\n    desc = models.CharField(max_length=255, null=True)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    def __repr__(self):\n        return f'<Service {self.name!r}>'\n\n    class Meta:\n        db_table = 'services'\n        ordering = ('-id',)\n\n\nclass Config(models.Model, ModelMixin):\n    TYPES = (\n        ('app', 'App'),\n        ('src', 'Service')\n    )\n    type = models.CharField(max_length=5, choices=TYPES)\n    o_id = models.IntegerField()\n    key = models.CharField(max_length=50)\n    env = models.ForeignKey(Environment, on_delete=models.PROTECT)\n    value = models.TextField(null=True)\n    desc = models.CharField(max_length=255, null=True)\n    is_public = models.BooleanField(default=False)\n    updated_at = models.CharField(max_length=20)\n    updated_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    def __repr__(self):\n        return f'<Config {self.key!r}>'\n\n    class Meta:\n        db_table = 'configs'\n        ordering = ('-key',)\n\n\nclass ConfigHistory(models.Model, ModelMixin):\n    ACTIONS = (\n        ('1', '新增'),\n        ('2', '更新'),\n        ('3', '删除')\n    )\n    type = models.CharField(max_length=5)\n    o_id = models.IntegerField()\n    key = models.CharField(max_length=50)\n    env_id = models.IntegerField()\n    value = models.TextField(null=True)\n    desc = models.CharField(max_length=255, null=True)\n    is_public = models.BooleanField()\n    old_value = models.TextField(null=True)\n    action = models.CharField(max_length=2, choices=ACTIONS)\n    updated_at = models.CharField(max_length=20)\n    updated_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    def __repr__(self):\n        return f'<ConfigHistory {self.key!r}>'\n\n    class Meta:\n        db_table = 'config_histories'\n        ordering = ('key',)\n"
  },
  {
    "path": "spug_api/apps/config/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', ConfigView.as_view()),\n    path('parse/json/', parse_json),\n    path('parse/text/', parse_text),\n    path('diff/', post_diff),\n    path('environment/', EnvironmentView.as_view()),\n    path('service/', ServiceView.as_view()),\n    path('history/', HistoryView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/config/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apps.config.models import Config, Service\nfrom apps.app.models import App\nimport json\n\n\ndef compose_configs(app, env_id, no_prefix=False):\n    configs = dict()\n    # app own configs\n    for item in Config.objects.filter(type='app', o_id=app.id, env_id=env_id).only('key', 'value'):\n        key = item.key if no_prefix else f'{app.key}_{item.key}'\n        configs[key] = item.value\n\n    # relation app public configs\n    if app.rel_apps:\n        app_ids = json.loads(app.rel_apps)\n        if app_ids:\n            id_key_map = {x.id: x.key for x in App.objects.filter(id__in=app_ids)}\n            for item in Config.objects.filter(type='app', o_id__in=app_ids, env_id=env_id, is_public=True) \\\n                    .only('key', 'value'):\n                key = item.key if no_prefix else f'{id_key_map[item.o_id]}_{item.key}'\n                configs[key] = item.value\n\n    # relation service configs\n    if app.rel_services:\n        src_ids = json.loads(app.rel_services)\n        if src_ids:\n            id_key_map = {x.id: x.key for x in Service.objects.filter(id__in=src_ids)}\n            for item in Config.objects.filter(type='src', o_id__in=src_ids, env_id=env_id).only('key', 'value'):\n                key = item.key if no_prefix else f'{id_key_map[item.o_id]}_{item.key}'\n                configs[key] = item.value\n    return configs\n"
  },
  {
    "path": "spug_api/apps/config/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom libs import json_response, JsonParser, Argument, auth\nfrom apps.app.models import Deploy, App\nfrom apps.repository.models import Repository\nfrom apps.config.models import *\nimport json\nimport re\n\n\nclass EnvironmentView(View):\n    def get(self, request):\n        query = {}\n        if not request.user.is_supper:\n            query['id__in'] = request.user.deploy_perms['envs']\n        envs = Environment.objects.filter(**query)\n        return json_response(envs)\n\n    @auth('config.env.add|config.env.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入环境名称'),\n            Argument('key', help='请输入唯一标识符'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            if not re.fullmatch(r'\\w+', form.key, re.ASCII):\n                return json_response(error='标识符必须为字母、数字和下划线的组合')\n\n            env = Environment.objects.filter(key=form.key).first()\n            if env and env.id != form.id:\n                return json_response(error=f'唯一标识符 {form.key} 已存在，请更改后重试')\n            if form.id:\n                Environment.objects.filter(pk=form.id).update(**form)\n            else:\n                env = Environment.objects.create(created_by=request.user, **form)\n                env.sort_id = env.id\n                env.save()\n        return json_response(error=error)\n\n    @auth('config.env.edit')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('sort', filter=lambda x: x in ('up', 'down'), required=False)\n        ).parse(request.body)\n        if error is None:\n            env = Environment.objects.filter(pk=form.id).first()\n            if not env:\n                return json_response(error='未找到指定环境')\n            if form.sort:\n                if form.sort == 'up':\n                    tmp = Environment.objects.filter(sort_id__gt=env.sort_id).last()\n                else:\n                    tmp = Environment.objects.filter(sort_id__lt=env.sort_id).first()\n                if tmp:\n                    tmp.sort_id, env.sort_id = env.sort_id, tmp.sort_id\n                    tmp.save()\n            env.save()\n        return json_response(error=error)\n\n    @auth('config.env.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            if Deploy.objects.filter(env_id=form.id).exists():\n                return json_response(error='该环境已关联了发布配置，请删除相关发布配置后再尝试删除')\n            if Repository.objects.filter(env_id=form.id).exists():\n                return json_response(error='该环境关联了构建记录，请在删除应用发布/构建仓库中相关记录后再尝试')\n            # auto delete configs\n            Config.objects.filter(env_id=form.id).delete()\n            ConfigHistory.objects.filter(env_id=form.id).delete()\n            Environment.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\nclass ServiceView(View):\n    @auth('config.src.view')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False)\n        ).parse(request.GET)\n        if error is None:\n            if form.id:\n                service = Service.objects.get(pk=form.id)\n                return json_response(service)\n            services = Service.objects.all()\n            return json_response(services)\n        return json_response(error=error)\n\n    @auth('config.src.add|config.src.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入服务名称'),\n            Argument('key', help='请输入唯一标识符'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            if not re.fullmatch(r'\\w+', form.key, re.ASCII):\n                return json_response(error='标识符必须为字母、数字和下划线的组合')\n\n            service = Service.objects.filter(key=form.key).first()\n            if service and service.id != form.id:\n                return json_response(error='该标识符已存在，请更改后重试')\n            app = App.objects.filter(key=form.key).first()\n            if app:\n                return json_response(error=f'该标识符已被应用 {app.name} 使用，请更改后重试')\n            if form.id:\n                Service.objects.filter(pk=form.id).update(**form)\n            else:\n                Service.objects.create(created_by=request.user, **form)\n        return json_response(error=error)\n\n    @auth('config.src.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            rel_apps = []\n            for app in App.objects.filter(rel_services__isnull=False):\n                rel_services = json.loads(app.rel_services)\n                if form.id in rel_services:\n                    rel_apps.append(app.name)\n            if rel_apps:\n                return json_response(\n                    error=f'该服务在配置中心已被 \"{\", \".join(rel_apps)}\" 依赖，请解除依赖关系后再尝试删除。')\n            # auto delete configs\n            Config.objects.filter(type='src', o_id=form.id).delete()\n            ConfigHistory.objects.filter(type='src', o_id=form.id).delete()\n            Service.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\nclass ConfigView(View):\n    @auth('config.src.view_config|config.app.view_config')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='未指定操作对象'),\n            Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数'),\n            Argument('env_id', type=int, help='缺少必要参数'),\n        ).parse(request.GET)\n        if error is None:\n            form.o_id, data = form.pop('id'), []\n            for item in Config.objects.filter(**form).annotate(update_user=F('updated_by__nickname')):\n                tmp = item.to_dict()\n                tmp['update_user'] = item.update_user\n                data.append(tmp)\n            return json_response(data)\n        return json_response(error=error)\n\n    @auth('config.src.edit_config|config.app.edit_config')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('o_id', type=int, help='缺少必要参数'),\n            Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数'),\n            Argument('envs', type=list, filter=lambda x: len(x), help='请选择环境'),\n            Argument('key', help='请输入Key'),\n            Argument('is_public', type=bool, help='缺少必要参数'),\n            Argument('value', type=str, default=''),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            form.value = form.value.strip()\n            form.updated_at = human_datetime()\n            form.updated_by = request.user\n            envs = form.pop('envs')\n            for env_id in envs:\n                cf = Config.objects.filter(o_id=form.o_id, type=form.type, env_id=env_id, key=form.key).first()\n                if cf:\n                    raise Exception(f'{cf.env.name} 中已存在该Key')\n                Config.objects.create(env_id=env_id, **form)\n                ConfigHistory.objects.create(action='1', env_id=env_id, **form)\n        return json_response(error=error)\n\n    @auth('config.src.edit_config|config.app.edit_config')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='缺少必要参数'),\n            Argument('value', type=str, default=''),\n            Argument('is_public', type=bool, help='缺少必要参数'),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            form.value = form.value.strip()\n            config = Config.objects.filter(pk=form.id).first()\n            if not config:\n                return json_response(error='未找到指定对象')\n            config.desc = form.desc\n            config.is_public = form.is_public\n            if config.value != form.value:\n                old_value = config.value\n                config.value = form.value\n                config.updated_at = human_datetime()\n                config.updated_by = request.user\n                ConfigHistory.objects.create(\n                    action='2',\n                    old_value=old_value,\n                    **config.to_dict(excludes=('id',)))\n            config.save()\n        return json_response(error=error)\n\n    @auth('config.src.edit_config|config.app.edit_config')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='未指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            config = Config.objects.filter(pk=form.id).first()\n            if config:\n                ConfigHistory.objects.create(\n                    action='3',\n                    old_value=config.value,\n                    value='',\n                    updated_at=human_datetime(),\n                    updated_by=request.user,\n                    **config.to_dict(excludes=('id', 'value', 'updated_at', 'updated_by_id'))\n                )\n                config.delete()\n        return json_response(error=error)\n\n\nclass HistoryView(View):\n    @auth('config.src.view_config|config.app.view_config')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('o_id', type=int, help='缺少必要参数'),\n            Argument('env_id', type=int, help='缺少必要参数'),\n            Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数')\n        ).parse(request.body)\n        if error is None:\n            data = []\n            for item in ConfigHistory.objects.filter(**form).annotate(update_user=F('updated_by__nickname')):\n                tmp = item.to_dict()\n                tmp['action_alias'] = item.get_action_display()\n                tmp['update_user'] = item.update_user\n                data.append(tmp)\n            return json_response(data)\n        return json_response(error=error)\n\n\n@auth('config.src.view_config|config.app.view_config')\ndef post_diff(request):\n    form, error = JsonParser(\n        Argument('o_id', type=int, help='缺少必要参数'),\n        Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数'),\n        Argument('envs', type=list, filter=lambda x: len(x), help='缺少必要参数'),\n    ).parse(request.body)\n    if error is None:\n        data, form.env_id__in = {}, form.pop('envs')\n        for item in Config.objects.filter(**form).order_by('key'):\n            if item.key in data:\n                data[item.key][item.env_id] = item.value\n            else:\n                data[item.key] = {'key': item.key, item.env_id: item.value}\n        return json_response(list(data.values()))\n    return json_response(error=error)\n\n\n@auth('config.src.edit_config|config.app.edit_config')\ndef parse_json(request):\n    form, error = JsonParser(\n        Argument('o_id', type=int, help='缺少必要参数'),\n        Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数'),\n        Argument('env_id', type=int, help='缺少必要参数'),\n        Argument('data', type=dict, help='缺少必要参数')\n    ).parse(request.body)\n    if error is None:\n        data = form.pop('data')\n        _parse(request, form, data)\n    return json_response(error=error)\n\n\n@auth('config.src.edit_config|config.app.edit_config')\ndef parse_text(request):\n    form, error = JsonParser(\n        Argument('o_id', type=int, help='缺少必要参数'),\n        Argument('type', filter=lambda x: x in dict(Config.TYPES), help='缺少必要参数'),\n        Argument('env_id', type=int, help='缺少必要参数'),\n        Argument('data', handler=str.strip, help='缺少必要参数')\n    ).parse(request.body)\n    if error is None:\n        data = {}\n        for line in form.pop('data').split('\\n'):\n            line = line.strip()\n            if not line or line[0] in ('#', ';'):\n                continue\n            fields = line.split('=', 1)\n            if len(fields) != 2 or fields[0].strip() == '':\n                return json_response(error=f'解析配置{line!r}失败，确认其遵循 key = value 格式')\n            data[fields[0].strip()] = fields[1].strip()\n        _parse(request, form, data)\n    return json_response(error=error)\n\n\ndef _parse(request, query, data):\n    for item in Config.objects.filter(**query):\n        if item.key in data:\n            value = _filter_value(data.pop(item.key))\n            if item.value != value:\n                old_value = item.value\n                item.value = value\n                item.updated_at = human_datetime()\n                item.updated_by = request.user\n                item.save()\n                ConfigHistory.objects.create(\n                    action='2',\n                    old_value=old_value,\n                    **item.to_dict(excludes=('id',)))\n        else:\n            ConfigHistory.objects.create(\n                action='3',\n                old_value=item.value,\n                value='',\n                updated_at=human_datetime(),\n                updated_by=request.user,\n                **item.to_dict(excludes=('id', 'value', 'updated_at', 'updated_by_id'))\n            )\n            item.delete()\n    for key, value in data.items():\n        query.key = key\n        query.is_public = False\n        query.value = _filter_value(value)\n        query.updated_at = human_datetime()\n        query.updated_by = request.user\n        Config.objects.create(**query)\n        ConfigHistory.objects.create(action='1', **query)\n\n\ndef _filter_value(value):\n    if isinstance(value, (str, int)):\n        value = str(value).strip()\n    else:\n        value = json.dumps(value)\n    return value\n"
  },
  {
    "path": "spug_api/apps/deploy/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/deploy/helper.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.template.defaultfilters import filesizeformat\nfrom libs.utils import human_datetime, render_str, str_decode\nfrom libs.spug import Notification\nfrom apps.host.models import Host\nfrom functools import partial\nimport subprocess\nimport json\nimport os\n\n\nclass SpugError(Exception):\n    pass\n\n\nclass Helper:\n    def __init__(self, rds, key):\n        self.rds = rds\n        self.key = key\n        self.callback = []\n\n    @classmethod\n    def make(cls, rds, key, host_ids=None):\n        if host_ids:\n            counter, tmp_key = 0, f'{key}_tmp'\n            data = rds.lrange(key, counter, counter + 9)\n            while data:\n                for item in data:\n                    counter += 1\n                    print(item)\n                    tmp = json.loads(item.decode())\n                    if tmp['key'] not in host_ids:\n                        rds.rpush(tmp_key, item)\n                data = rds.lrange(key, counter, counter + 9)\n            rds.delete(key)\n            if rds.exists(tmp_key):\n                rds.rename(tmp_key, key)\n        else:\n            rds.delete(key)\n        return cls(rds, key)\n\n    @classmethod\n    def _make_dd_notify(cls, url, action, req, version, host_str):\n        texts = [\n            f'**申请标题：** {req.name}',\n            f'**应用名称：** {req.deploy.app.name}',\n            f'**应用版本：** {version}',\n            f'**发布环境：** {req.deploy.env.name}',\n            f'**发布主机：** {host_str}',\n        ]\n        if action == 'approve_req':\n            texts.insert(0, '## %s ## ' % '发布审核申请')\n            texts.extend([\n                f'**申请人员：** {req.created_by.nickname}',\n                f'**申请时间：** {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        elif action == 'approve_rst':\n            color, text = ('#008000', '通过') if req.status == '1' else ('#f90202', '驳回')\n            texts.insert(0, '## %s ## ' % '发布审核结果')\n            texts.extend([\n                f'**审核人员：** {req.approve_by.nickname}',\n                f'**审核结果：** <font color=\"{color}\">{text}</font>',\n                f'**审核意见：** {req.reason or \"\"}',\n                f'**审核时间：** {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        else:\n            color, text = ('#008000', '成功') if req.status == '3' else ('#f90202', '失败')\n            texts.insert(0, '## %s ## ' % '发布结果通知')\n            if req.approve_at:\n                texts.append(f'**审核人员：** {req.approve_by.nickname}')\n            do_user = req.do_by.nickname if req.type != '3' else 'Webhook'\n            texts.extend([\n                f'**执行人员：** {do_user}',\n                f'**发布结果：** <font color=\"{color}\">{text}</font>',\n                f'**发布时间：** {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'title': 'Spug 发布消息通知',\n                'text': '\\n\\n'.join(texts)\n            },\n            'at': {\n                'isAtAll': True\n            }\n        }\n        Notification.handle_request(url, data, 'dd')\n\n    @classmethod\n    def _make_wx_notify(cls, url, action, req, version, host_str):\n        texts = [\n            f'申请标题： {req.name}',\n            f'应用名称： {req.deploy.app.name}',\n            f'应用版本： {version}',\n            f'发布环境： {req.deploy.env.name}',\n            f'发布主机： {host_str}',\n        ]\n\n        if action == 'approve_req':\n            texts.insert(0, '## %s' % '发布审核申请')\n            texts.extend([\n                f'申请人员： {req.created_by.nickname}',\n                f'申请时间： {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        elif action == 'approve_rst':\n            color, text = ('info', '通过') if req.status == '1' else ('warning', '驳回')\n            texts.insert(0, '## %s' % '发布审核结果')\n            texts.extend([\n                f'审核人员： {req.approve_by.nickname}',\n                f'审核结果： <font color=\"{color}\">{text}</font>',\n                f'审核意见： {req.reason or \"\"}',\n                f'审核时间： {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        else:\n            color, text = ('info', '成功') if req.status == '3' else ('warning', '失败')\n            texts.insert(0, '## %s' % '发布结果通知')\n            if req.approve_at:\n                texts.append(f'审核人员： {req.approve_by.nickname}')\n            do_user = req.do_by.nickname if req.type != '3' else 'Webhook'\n            texts.extend([\n                f'执行人员： {do_user}',\n                f'发布结果： <font color=\"{color}\">{text}</font>',\n                f'发布时间： {human_datetime()}',\n                '> 来自 Spug运维平台'\n            ])\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'content': '\\n'.join(texts)\n            }\n        }\n        Notification.handle_request(url, data, 'wx')\n\n    @classmethod\n    def _make_fs_notify(cls, url, action, req, version, host_str):\n        texts = [\n            f'申请标题： {req.name}',\n            f'应用名称： {req.deploy.app.name}',\n            f'应用版本： {version}',\n            f'发布环境： {req.deploy.env.name}',\n            f'发布主机： {host_str}',\n        ]\n\n        if action == 'approve_req':\n            title = '发布审核申请'\n            texts.extend([\n                f'申请人员： {req.created_by.nickname}',\n                f'申请时间： {human_datetime()}',\n            ])\n        elif action == 'approve_rst':\n            title = '发布审核结果'\n            text = '通过' if req.status == '1' else '驳回'\n            texts.extend([\n                f'审核人员： {req.approve_by.nickname}',\n                f'审核结果： {text}',\n                f'审核意见： {req.reason or \"\"}',\n                f'审核时间： {human_datetime()}',\n            ])\n        else:\n            title = '发布结果通知'\n            text = '成功 ✅' if req.status == '3' else '失败 ❗'\n            if req.approve_at:\n                texts.append(f'审核人员： {req.approve_by.nickname}')\n            do_user = req.do_by.nickname if req.type != '3' else 'Webhook'\n            texts.extend([\n                f'执行人员： {do_user}',\n                f'发布结果： {text}',\n                f'发布时间： {human_datetime()}',\n            ])\n        data = {\n            'msg_type': 'post',\n            'content': {\n                'post': {\n                    'zh_cn': {\n                        'title': title,\n                        'content': [[{'tag': 'text', 'text': x}] for x in texts] + [[{'tag': 'at', 'user_id': 'all'}]]\n                    }\n                }\n            }\n        }\n        Notification.handle_request(url, data, 'fs')\n\n    @classmethod\n    def send_deploy_notify(cls, req, action=None):\n        rst_notify = json.loads(req.deploy.rst_notify)\n        host_ids = json.loads(req.host_ids) if isinstance(req.host_ids, str) else req.host_ids\n        if rst_notify['mode'] != '0' and rst_notify.get('value'):\n            url = rst_notify['value']\n            version = req.version\n            hosts = [{'id': x.id, 'name': x.name} for x in Host.objects.filter(id__in=host_ids)]\n            host_str = ', '.join(x['name'] for x in hosts[:2])\n            if len(hosts) > 2:\n                host_str += f'等{len(hosts)}台主机'\n            if rst_notify['mode'] == '1':\n                cls._make_dd_notify(url, action, req, version, host_str)\n            elif rst_notify['mode'] == '2':\n                data = {\n                    'action': action,\n                    'req_id': req.id,\n                    'req_name': req.name,\n                    'app_id': req.deploy.app_id,\n                    'app_name': req.deploy.app.name,\n                    'env_id': req.deploy.env_id,\n                    'env_name': req.deploy.env.name,\n                    'status': req.status,\n                    'reason': req.reason,\n                    'version': version,\n                    'targets': hosts,\n                    'is_success': req.status == '3',\n                    'created_at': human_datetime()\n                }\n                Notification.handle_request(url, data)\n            elif rst_notify['mode'] == '3':\n                cls._make_wx_notify(url, action, req, version, host_str)\n            elif rst_notify['mode'] == '4':\n                cls._make_fs_notify(url, action, req, version, host_str)\n            else:\n                raise NotImplementedError\n\n    def add_callback(self, func):\n        self.callback.append(func)\n\n    def parse_filter_rule(self, data: str, sep='\\n', env=None):\n        data, files = data.strip(), []\n        if data:\n            for line in data.split(sep):\n                line = line.strip()\n                if line and not line.startswith('#'):\n                    files.append(render_str(line, env))\n        return files\n\n    def _send(self, message):\n        self.rds.rpush(self.key, json.dumps(message))\n\n    def send_info(self, key, message):\n        if message:\n            self._send({'key': key, 'data': message})\n\n    def send_error(self, key, message, with_break=True):\n        message = f'\\r\\n\\033[31m{message}\\033[0m'\n        self._send({'key': key, 'status': 'error', 'data': message})\n        if with_break:\n            raise SpugError\n\n    def send_step(self, key, step, data):\n        self._send({'key': key, 'step': step, 'data': data})\n\n    def clear(self):\n        self.rds.delete(f'{self.key}_tmp')\n        # save logs for two weeks\n        self.rds.expire(self.key, 14 * 24 * 60 * 60)\n        self.rds.close()\n        # callback\n        for func in self.callback:\n            func()\n\n    def progress_callback(self, key):\n        def func(k, n, t):\n            message = f'\\r         {filesizeformat(n):<8}/{filesizeformat(t):>8}  '\n            self.send_info(k, message)\n\n        self.send_info(key, '\\r\\n')\n        return partial(func, key)\n\n    def local(self, command, env=None):\n        if env:\n            env = dict(env.items())\n            env.update(os.environ)\n        task = subprocess.Popen(command, env=env, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)\n        message = b''\n        while True:\n            output = task.stdout.read(1)\n            if not output:\n                break\n            if output in (b'\\r', b'\\n'):\n                message += b'\\r\\n' if output == b'\\n' else b'\\r'\n                message = str_decode(message)\n                self.send_info('local', message)\n                message = b''\n            else:\n                message += output\n        if task.wait() != 0:\n            self.send_error('local', f'exit code: {task.returncode}')\n\n    def remote(self, key, ssh, command, env=None):\n        code = -1\n        for code, out in ssh.exec_command_with_stream(command, environment=env):\n            self.send_info(key, out)\n        if code != 0:\n            self.send_error(key, f'exit code: {code}')\n\n    def remote_raw(self, key, ssh, command):\n        code, out = ssh.exec_command_raw(command)\n        if code != 0:\n            self.send_error(key, f'exit code: {code}, {out}')\n"
  },
  {
    "path": "spug_api/apps/deploy/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom django.conf import settings\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nfrom apps.app.models import Deploy\nfrom apps.repository.models import Repository\nimport json\nimport os\n\n\nclass DeployRequest(models.Model, ModelMixin):\n    STATUS = (\n        ('-3', '发布异常'),\n        ('-1', '已驳回'),\n        ('0', '待审核'),\n        ('1', '待发布'),\n        ('2', '发布中'),\n        ('3', '发布成功'),\n    )\n    TYPES = (\n        ('1', '正常发布'),\n        ('2', '回滚'),\n        ('3', '自动发布'),\n    )\n    deploy = models.ForeignKey(Deploy, on_delete=models.CASCADE)\n    repository = models.ForeignKey(Repository, null=True, on_delete=models.SET_NULL)\n    name = models.CharField(max_length=100)\n    type = models.CharField(max_length=2, choices=TYPES, default='1')\n    extra = models.TextField()\n    host_ids = models.TextField()\n    desc = models.CharField(max_length=255, null=True)\n    status = models.CharField(max_length=2, choices=STATUS)\n    reason = models.CharField(max_length=255, null=True)\n    version = models.CharField(max_length=100, null=True)\n    spug_version = models.CharField(max_length=50, null=True)\n    plan = models.DateTimeField(null=True)\n    fail_host_ids = models.TextField(default='[]')\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n    approve_at = models.CharField(max_length=20, null=True)\n    approve_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n    do_at = models.CharField(max_length=20, null=True)\n    do_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n\n    @property\n    def is_quick_deploy(self):\n        if self.type in ('1', '3') and self.deploy.extend == '1' and self.extra:\n            extra = json.loads(self.extra)\n            return extra[0] in ('branch', 'tag')\n        return False\n\n    def delete(self, using=None, keep_parents=False):\n        super().delete(using, keep_parents)\n        if self.repository_id:\n            if not DeployRequest.objects.filter(repository=self.repository).exists():\n                self.repository.delete()\n        if self.deploy.extend == '2':\n            try:\n                os.remove(os.path.join(settings.REPOS_DIR, str(self.deploy_id), self.spug_version))\n            except FileNotFoundError:\n                pass\n\n    def __repr__(self):\n        return f'<DeployRequest name={self.name}>'\n\n    class Meta:\n        db_table = 'deploy_requests'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/deploy/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('request/', RequestView.as_view()),\n    path('request/info/', get_request_info),\n    path('request/ext1/', post_request_ext1),\n    path('request/ext1/rollback/', post_request_ext1_rollback),\n    path('request/ext2/', post_request_ext2),\n    path('request/upload/', do_upload),\n    path('request/<int:r_id>/', RequestDetailView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/deploy/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django_redis import get_redis_connection\nfrom django.conf import settings\nfrom django.db import close_old_connections\nfrom libs.utils import AttrDict, human_time, render_str\nfrom apps.host.models import Host\nfrom apps.config.utils import compose_configs\nfrom apps.repository.models import Repository\nfrom apps.repository.utils import dispatch as build_repository\nfrom apps.deploy.models import DeployRequest\nfrom apps.deploy.helper import Helper, SpugError\nfrom concurrent import futures\nfrom functools import partial\nimport json\nimport uuid\nimport os\n\nREPOS_DIR = settings.REPOS_DIR\nBUILD_DIR = settings.BUILD_DIR\n\n\ndef dispatch(req, fail_mode=False):\n    rds = get_redis_connection()\n    rds_key = f'{settings.REQUEST_KEY}:{req.id}'\n    if fail_mode:\n        req.host_ids = req.fail_host_ids\n    req.fail_mode = fail_mode\n    req.host_ids = json.loads(req.host_ids)\n    req.fail_host_ids = req.host_ids[:]\n    helper = Helper.make(rds, rds_key, req.host_ids if fail_mode else None)\n\n    try:\n        api_token = uuid.uuid4().hex\n        rds.setex(api_token, 60 * 60, f'{req.deploy.app_id},{req.deploy.env_id}')\n        env = AttrDict(\n            SPUG_APP_NAME=req.deploy.app.name,\n            SPUG_APP_KEY=req.deploy.app.key,\n            SPUG_APP_ID=str(req.deploy.app_id),\n            SPUG_REQUEST_ID=str(req.id),\n            SPUG_REQUEST_NAME=req.name,\n            SPUG_DEPLOY_ID=str(req.deploy.id),\n            SPUG_ENV_ID=str(req.deploy.env_id),\n            SPUG_ENV_KEY=req.deploy.env.key,\n            SPUG_VERSION=req.version,\n            SPUG_BUILD_VERSION=req.spug_version,\n            SPUG_DEPLOY_TYPE=req.type,\n            SPUG_API_TOKEN=api_token,\n            SPUG_REPOS_DIR=REPOS_DIR,\n        )\n        # append configs\n        configs = compose_configs(req.deploy.app, req.deploy.env_id)\n        configs_env = {f'_SPUG_{k.upper()}': v for k, v in configs.items()}\n        env.update(configs_env)\n\n        if req.deploy.extend == '1':\n            _ext1_deploy(req, helper, env)\n        else:\n            _ext2_deploy(req, helper, env)\n        req.status = '3'\n    except Exception as e:\n        req.status = '-3'\n        raise e\n    finally:\n        close_old_connections()\n        DeployRequest.objects.filter(pk=req.id).update(\n            status=req.status,\n            repository=req.repository,\n            fail_host_ids=json.dumps(req.fail_host_ids),\n        )\n        helper.clear()\n        Helper.send_deploy_notify(req)\n\n\ndef _ext1_deploy(req, helper, env):\n    if not req.repository_id:\n        rep = Repository(\n            app_id=req.deploy.app_id,\n            env_id=req.deploy.env_id,\n            deploy_id=req.deploy_id,\n            version=req.version,\n            spug_version=req.spug_version,\n            extra=req.extra,\n            remarks='SPUG AUTO MAKE',\n            created_by_id=req.created_by_id\n        )\n        build_repository(rep, helper)\n        req.repository = rep\n    extras = json.loads(req.extra)\n    if extras[0] == 'repository':\n        extras = extras[1:]\n    if extras[0] == 'branch':\n        env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2])\n    else:\n        env.update(SPUG_GIT_TAG=extras[1])\n    if req.deploy.is_parallel:\n        threads, latest_exception = [], None\n        max_workers = max(10, os.cpu_count() * 5)\n        with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n            for h_id in req.host_ids:\n                new_env = AttrDict(env.items())\n                t = executor.submit(_deploy_ext1_host, req, helper, h_id, new_env)\n                t.h_id = h_id\n                threads.append(t)\n            for t in futures.as_completed(threads):\n                exception = t.exception()\n                if exception:\n                    latest_exception = exception\n                    if not isinstance(exception, SpugError):\n                        helper.send_error(t.h_id, f'Exception: {exception}', False)\n                else:\n                    req.fail_host_ids.remove(t.h_id)\n        if latest_exception:\n            raise latest_exception\n    else:\n        host_ids = sorted(req.host_ids, reverse=True)\n        while host_ids:\n            h_id = host_ids.pop()\n            new_env = AttrDict(env.items())\n            try:\n                _deploy_ext1_host(req, helper, h_id, new_env)\n                req.fail_host_ids.remove(h_id)\n            except Exception as e:\n                helper.send_error(h_id, f'Exception: {e}', False)\n                for h_id in host_ids:\n                    helper.send_error(h_id, '终止发布', False)\n                raise e\n\n\ndef _ext2_deploy(req, helper, env):\n    extend, step = req.deploy.extend_obj, 1\n    host_actions = json.loads(extend.host_actions)\n    server_actions = json.loads(extend.server_actions)\n    env.update({'SPUG_RELEASE': req.version})\n    if req.version:\n        for index, value in enumerate(req.version.split()):\n            env.update({f'SPUG_RELEASE_{index + 1}': value})\n\n    if not req.fail_mode:\n        helper.send_info('local', f'\\033[32m完成√\\033[0m\\r\\n')\n        for action in server_actions:\n            helper.send_step('local', step, f'{human_time()} {action[\"title\"]}...\\r\\n')\n            helper.local(f'cd /tmp && {action[\"data\"]}', env)\n            step += 1\n\n    for action in host_actions:\n        if action.get('type') == 'transfer':\n            action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env)\n            action['dst'] = render_str(action['dst'].strip().rstrip('/'), env)\n            if action.get('src_mode') == '1':  # upload when publish\n                extra = json.loads(req.extra)\n                if 'name' in extra:\n                    action['name'] = extra['name']\n                break\n            helper.send_step('local', step, f'{human_time()} 检测到来源为本地路径的数据传输动作，执行打包...   \\r\\n')\n            action['src'] = action['src'].rstrip('/ ')\n            action['dst'] = action['dst'].rstrip('/ ')\n            if not action['src'] or not action['dst']:\n                helper.send_error('local', f'Invalid path for transfer, src: {action[\"src\"]} dst: {action[\"dst\"]}')\n            if not os.path.exists(action['src']):\n                helper.send_error('local', f'No such file or directory: {action[\"src\"]}')\n            is_dir, exclude = os.path.isdir(action['src']), ''\n            sp_dir, sd_dst = os.path.split(action['src'])\n            contain = sd_dst\n            if action['mode'] != '0' and is_dir:\n                files = helper.parse_filter_rule(action['rule'], ',', env)\n                if files:\n                    if action['mode'] == '1':\n                        contain = ' '.join(f'{sd_dst}/{x}' for x in files)\n                    else:\n                        excludes = []\n                        for x in files:\n                            if x.startswith('/'):\n                                excludes.append(f'--exclude={sd_dst}{x}')\n                            else:\n                                excludes.append(f'--exclude={x}')\n                        exclude = ' '.join(excludes)\n            tar_gz_file = f'{req.spug_version}.tar.gz'\n            helper.local(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}')\n            helper.send_info('local', f'{human_time()} \\033[32m完成√\\033[0m\\r\\n')\n            helper.add_callback(partial(os.remove, os.path.join(sp_dir, tar_gz_file)))\n            break\n    helper.send_step('local', 100, '')\n\n    if host_actions:\n        if req.deploy.is_parallel:\n            threads, latest_exception = [], None\n            max_workers = max(10, os.cpu_count() * 5)\n            with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n                for h_id in req.host_ids:\n                    new_env = AttrDict(env.items())\n                    t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version)\n                    t.h_id = h_id\n                    threads.append(t)\n                for t in futures.as_completed(threads):\n                    exception = t.exception()\n                    if exception:\n                        latest_exception = exception\n                        if not isinstance(exception, SpugError):\n                            helper.send_error(t.h_id, f'Exception: {exception}', False)\n                    else:\n                        req.fail_host_ids.remove(t.h_id)\n            if latest_exception:\n                raise latest_exception\n        else:\n            host_ids = sorted(req.host_ids, reverse=True)\n            while host_ids:\n                h_id = host_ids.pop()\n                new_env = AttrDict(env.items())\n                try:\n                    _deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version)\n                    req.fail_host_ids.remove(h_id)\n                except Exception as e:\n                    helper.send_error(h_id, f'Exception: {e}', False)\n                    for h_id in host_ids:\n                        helper.send_error(h_id, '终止发布', False)\n                    raise e\n    else:\n        req.fail_host_ids = []\n        helper.send_step('local', 100, f'\\r\\n{human_time()} ** 发布成功 **')\n\n\ndef _deploy_ext1_host(req, helper, h_id, env):\n    helper.send_step(h_id, 1, f'\\033[32m就绪√\\033[0m\\r\\n{human_time()} 数据准备...        ')\n    host = Host.objects.filter(pk=h_id).first()\n    if not host:\n        helper.send_error(h_id, 'no such host')\n    env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname})\n    extend = req.deploy.extend_obj\n    extend.dst_dir = render_str(extend.dst_dir, env)\n    extend.dst_repo = render_str(extend.dst_repo, env)\n    env.update(SPUG_DST_DIR=extend.dst_dir)\n    with host.get_ssh(default_env=env) as ssh:\n        base_dst_dir = os.path.dirname(extend.dst_dir)\n        code, _ = ssh.exec_command_raw(\n            f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]')\n        if code == 0:\n            helper.send_error(host.id, f'检测到该主机的发布目录 {extend.dst_dir!r} 已存在，为了数据安全请自行备份后删除该目录，Spug 将会创建并接管该目录。')\n        if req.type == '2':\n            helper.send_step(h_id, 1, '\\033[33m跳过√\\033[0m\\r\\n')\n        else:\n            # clean\n            clean_command = f'ls -d {extend.deploy_id}_* 2> /dev/null | sort -t _ -rnk2 | tail -n +{extend.versions + 1} | xargs rm -rf'\n            helper.remote_raw(host.id, ssh, f'cd {extend.dst_repo} && {clean_command}')\n            # transfer files\n            tar_gz_file = f'{req.spug_version}.tar.gz'\n            try:\n                callback = helper.progress_callback(host.id)\n                ssh.put_file(\n                    os.path.join(BUILD_DIR, tar_gz_file),\n                    os.path.join(extend.dst_repo, tar_gz_file),\n                    callback\n                )\n            except Exception as e:\n                helper.send_error(host.id, f'Exception: {e}')\n\n            command = f'cd {extend.dst_repo} && rm -rf {req.spug_version} && tar xf {tar_gz_file} && rm -f {req.deploy_id}_*.tar.gz'\n            helper.remote_raw(host.id, ssh, command)\n            helper.send_step(h_id, 1, '\\033[32m完成√\\033[0m\\r\\n')\n\n        # pre host\n        repo_dir = os.path.join(extend.dst_repo, req.spug_version)\n        if extend.hook_pre_host:\n            helper.send_step(h_id, 2, f'{human_time()} 发布前任务...       \\r\\n')\n            command = f'cd {repo_dir} && {extend.hook_pre_host}'\n            helper.remote(host.id, ssh, command)\n\n        # do deploy\n        helper.send_step(h_id, 3, f'{human_time()} 执行发布...        ')\n        helper.remote_raw(host.id, ssh, f'rm -f {extend.dst_dir} && ln -sfn {repo_dir} {extend.dst_dir}')\n        helper.send_step(h_id, 3, '\\033[32m完成√\\033[0m\\r\\n')\n\n        # post host\n        if extend.hook_post_host:\n            helper.send_step(h_id, 4, f'{human_time()} 发布后任务...       \\r\\n')\n            command = f'cd {extend.dst_dir} && {extend.hook_post_host}'\n            helper.remote(host.id, ssh, command)\n\n        helper.send_step(h_id, 100, f'\\r\\n{human_time()} ** \\033[32m发布成功\\033[0m **')\n\n\ndef _deploy_ext2_host(helper, h_id, actions, env, spug_version):\n    helper.send_info(h_id, '\\033[32m就绪√\\033[0m\\r\\n')\n    host = Host.objects.filter(pk=h_id).first()\n    if not host:\n        helper.send_error(h_id, 'no such host')\n    env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname})\n    with host.get_ssh(default_env=env) as ssh:\n        for index, action in enumerate(actions):\n            helper.send_step(h_id, 1 + index, f'{human_time()} {action[\"title\"]}...\\r\\n')\n            if action.get('type') == 'transfer':\n                if action.get('src_mode') == '1':\n                    try:\n                        dst = action['dst']\n                        command = f'[ -e {dst} ] || mkdir -p $(dirname {dst}); [ -d {dst} ]'\n                        code, _ = ssh.exec_command_raw(command)\n                        if code == 0:  # is dir\n                            if not action.get('name'):\n                                raise RuntimeError('internal error 1002')\n                            dst = dst.rstrip('/') + '/' + action['name']\n                        callback = helper.progress_callback(host.id)\n                        ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback)\n                    except Exception as e:\n                        helper.send_error(host.id, f'Exception: {e}')\n                    helper.send_info(host.id, 'transfer completed\\r\\n')\n                    continue\n                else:\n                    sp_dir, sd_dst = os.path.split(action['src'])\n                    tar_gz_file = f'{spug_version}.tar.gz'\n                    try:\n                        callback = helper.progress_callback(host.id)\n                        ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}', callback)\n                    except Exception as e:\n                        helper.send_error(host.id, f'Exception: {e}')\n\n                    command = f'mkdir -p /tmp/{spug_version} && tar xf /tmp/{tar_gz_file} -C /tmp/{spug_version}/ '\n                    command += f'&& rm -rf {action[\"dst\"]} && mv /tmp/{spug_version}/{sd_dst} {action[\"dst\"]} '\n                    command += f'&& rm -rf /tmp/{spug_version}* && echo \"transfer completed\"'\n            else:\n                command = f'cd /tmp && {action[\"data\"]}'\n            helper.remote(host.id, ssh, command)\n\n    helper.send_step(h_id, 100, f'\\r\\n{human_time()} ** \\033[32m发布成功\\033[0m **')\n"
  },
  {
    "path": "spug_api/apps/deploy/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom django.conf import settings\nfrom django.http.response import HttpResponseBadRequest\nfrom django_redis import get_redis_connection\nfrom libs import json_response, JsonParser, Argument, human_datetime, human_time, auth\nfrom apps.deploy.models import DeployRequest\nfrom apps.app.models import Deploy, DeployExtend2\nfrom apps.repository.models import Repository\nfrom apps.deploy.utils import dispatch, Helper\nfrom apps.host.models import Host\nfrom collections import defaultdict\nfrom threading import Thread\nfrom datetime import datetime\nimport subprocess\nimport json\nimport os\n\n\nclass RequestView(View):\n    @auth('deploy.request.view')\n    def get(self, request):\n        data, query, counter = [], {}, {}\n        if not request.user.is_supper:\n            perms = request.user.deploy_perms\n            query['deploy__app_id__in'] = perms['apps']\n            query['deploy__env_id__in'] = perms['envs']\n        for item in DeployRequest.objects.filter(**query).annotate(\n                env_id=F('deploy__env_id'),\n                env_name=F('deploy__env__name'),\n                app_id=F('deploy__app_id'),\n                app_name=F('deploy__app__name'),\n                app_host_ids=F('deploy__host_ids'),\n                app_extend=F('deploy__extend'),\n                rep_extra=F('repository__extra'),\n                do_by_user=F('do_by__nickname'),\n                approve_by_user=F('approve_by__nickname'),\n                created_by_user=F('created_by__nickname')):\n            tmp = item.to_dict()\n            tmp['env_id'] = item.env_id\n            tmp['env_name'] = item.env_name\n            tmp['app_id'] = item.app_id\n            tmp['app_name'] = item.app_name\n            tmp['app_extend'] = item.app_extend\n            tmp['host_ids'] = json.loads(item.host_ids)\n            tmp['fail_host_ids'] = json.loads(item.fail_host_ids)\n            tmp['extra'] = json.loads(item.extra) if item.extra else None\n            tmp['rep_extra'] = json.loads(item.rep_extra) if item.rep_extra else None\n            tmp['app_host_ids'] = json.loads(item.app_host_ids)\n            tmp['status_alias'] = item.get_status_display()\n            tmp['created_by_user'] = item.created_by_user\n            tmp['approve_by_user'] = item.approve_by_user\n            tmp['do_by_user'] = item.do_by_user\n            if item.app_extend == '1':\n                tmp['visible_rollback'] = item.deploy_id not in counter\n                counter[item.deploy_id] = True\n            data.append(tmp)\n        return json_response(data)\n\n    @auth('deploy.request.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('mode', filter=lambda x: x in ('count', 'expire', 'deploy'), required=False, help='参数错误'),\n            Argument('value', required=False),\n        ).parse(request.GET)\n        if error is None:\n            if form.id:\n                deploy = DeployRequest.objects.filter(pk=form.id).first()\n                if not deploy or deploy.status not in ('0', '1', '-1'):\n                    return json_response(error='未找到指定发布申请或当前状态不允许删除')\n                deploy.delete()\n                return json_response()\n\n            count = 0\n            if form.mode == 'count':\n                if not str(form.value).isdigit() or int(form.value) < 1:\n                    return json_response(error='请输入正确的保留数量')\n                counter, form.value = defaultdict(int), int(form.value)\n                for item in DeployRequest.objects.all():\n                    counter[item.deploy_id] += 1\n                    if counter[item.deploy_id] > form.value:\n                        count += 1\n                        item.delete()\n            elif form.mode == 'expire':\n                for item in DeployRequest.objects.filter(created_at__lt=form.value):\n                    count += 1\n                    item.delete()\n            elif form.mode == 'deploy':\n                app_id, env_id = str(form.value).split(',')\n                for item in DeployRequest.objects.filter(deploy__app_id=app_id, deploy__env_id=env_id):\n                    count += 1\n                    item.delete()\n            return json_response(count)\n        return json_response(error=error)\n\n\nclass RequestDetailView(View):\n    @auth('deploy.request.view')\n    def get(self, request, r_id):\n        req = DeployRequest.objects.filter(pk=r_id).first()\n        if not req:\n            return json_response(error='未找到指定发布申请')\n        hosts = Host.objects.filter(id__in=json.loads(req.host_ids))\n        outputs = {x.id: {'id': x.id, 'title': x.name, 'data': f'{human_time()} 读取数据...        '} for x in hosts}\n        response = {'outputs': outputs, 'status': req.status}\n        if req.is_quick_deploy:\n            outputs['local'] = {'id': 'local', 'data': ''}\n        if req.deploy.extend == '2':\n            outputs['local'] = {'id': 'local', 'data': f'{human_time()} 读取数据...        '}\n            response['s_actions'] = json.loads(req.deploy.extend_obj.server_actions)\n            response['h_actions'] = json.loads(req.deploy.extend_obj.host_actions)\n            if not response['h_actions']:\n                response['outputs'] = {'local': outputs['local']}\n        rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0\n        data = rds.lrange(key, counter, counter + 9)\n        while data:\n            for item in data:\n                counter += 1\n                item = json.loads(item.decode())\n                if item['key'] in outputs:\n                    if 'data' in item:\n                        outputs[item['key']]['data'] += item['data']\n                    if 'step' in item:\n                        outputs[item['key']]['step'] = item['step']\n                    if 'status' in item:\n                        outputs[item['key']]['status'] = item['status']\n            data = rds.lrange(key, counter, counter + 9)\n        response['index'] = counter\n        if counter == 0:\n            for item in outputs:\n                outputs[item]['data'] += '\\r\\n\\r\\n未读取到数据，Spug 仅保存最近2周的日志信息。'\n\n        if req.is_quick_deploy:\n            if outputs['local']['data']:\n                outputs['local']['data'] = f'{human_time()} 读取数据...        ' + outputs['local']['data']\n            else:\n                outputs['local'].update(step=100, data=f'{human_time()} 已构建完成忽略执行。')\n        return json_response(response)\n\n    @auth('deploy.request.do')\n    def post(self, request, r_id):\n        form, _ = JsonParser(Argument('mode', default='all')).parse(request.body)\n        query = {'pk': r_id}\n        if not request.user.is_supper:\n            perms = request.user.deploy_perms\n            query['deploy__app_id__in'] = perms['apps']\n            query['deploy__env_id__in'] = perms['envs']\n        req = DeployRequest.objects.filter(**query).first()\n        if not req:\n            return json_response(error='未找到指定发布申请')\n        if req.status not in ('1', '-3'):\n            return json_response(error='该申请单当前状态还不能执行发布')\n\n        host_ids = req.fail_host_ids if form.mode == 'fail' else req.host_ids\n        hosts = Host.objects.filter(id__in=json.loads(host_ids))\n        message = f'{human_time()} 等待调度...        '\n        outputs = {x.id: {'id': x.id, 'title': x.name, 'step': 0, 'data': message} for x in hosts}\n        req.status = '2'\n        req.do_at = human_datetime()\n        req.do_by = request.user\n        req.save()\n        Thread(target=dispatch, args=(req, form.mode == 'fail')).start()\n        if req.is_quick_deploy:\n            if req.repository_id:\n                outputs['local'] = {'id': 'local', 'step': 100, 'data': f'{human_time()} 已构建完成忽略执行。'}\n            else:\n                outputs['local'] = {'id': 'local', 'step': 0, 'data': f'{human_time()} 建立连接...        '}\n        if req.deploy.extend == '2':\n            outputs['local'] = {'id': 'local', 'step': 0, 'data': f'{human_time()} 建立连接...        '}\n            s_actions = json.loads(req.deploy.extend_obj.server_actions)\n            h_actions = json.loads(req.deploy.extend_obj.host_actions)\n            for item in h_actions:\n                if item.get('type') == 'transfer' and item.get('src_mode') == '0':\n                    s_actions.append({'title': '执行打包'})\n            if not h_actions:\n                outputs = {'local': outputs['local']}\n            return json_response({'s_actions': s_actions, 'h_actions': h_actions, 'outputs': outputs})\n        return json_response({'outputs': outputs})\n\n    @auth('deploy.request.approve')\n    def patch(self, request, r_id):\n        form, error = JsonParser(\n            Argument('reason', required=False),\n            Argument('is_pass', type=bool, help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            req = DeployRequest.objects.filter(pk=r_id).first()\n            if not req:\n                return json_response(error='未找到指定申请')\n            if not form.is_pass and not form.reason:\n                return json_response(error='请输入驳回原因')\n            if req.status != '0':\n                return json_response(error='该申请当前状态不允许审核')\n            req.approve_at = human_datetime()\n            req.approve_by = request.user\n            req.status = '1' if form.is_pass else '-1'\n            req.reason = form.reason\n            req.save()\n            Thread(target=Helper.send_deploy_notify, args=(req, 'approve_rst')).start()\n        return json_response(error=error)\n\n\n@auth('deploy.request.add|deploy.request.edit')\ndef post_request_ext1(request):\n    form, error = JsonParser(\n        Argument('id', type=int, required=False),\n        Argument('deploy_id', type=int, help='参数错误'),\n        Argument('name', help='请输入申请标题'),\n        Argument('extra', type=list, help='请选择发布版本'),\n        Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),\n        Argument('type', default='1'),\n        Argument('plan', required=False),\n        Argument('desc', required=False),\n    ).parse(request.body)\n    if error is None:\n        deploy = Deploy.objects.get(pk=form.deploy_id)\n        form.spug_version = Repository.make_spug_version(deploy.id)\n        if form.extra[0] == 'tag':\n            if not form.extra[1]:\n                return json_response(error='请选择要发布的版本')\n            form.version = form.extra[1]\n        elif form.extra[0] == 'branch':\n            if not form.extra[2]:\n                return json_response(error='请选择要发布的分支及Commit ID')\n            form.version = f'{form.extra[1]}#{form.extra[2][:6]}'\n        elif form.extra[0] == 'repository':\n            if not form.extra[1]:\n                return json_response(error='请选择要发布的版本')\n            repository = Repository.objects.get(pk=form.extra[1])\n            form.repository_id = repository.id\n            form.version = repository.version\n            form.spug_version = repository.spug_version\n            form.extra = ['repository'] + json.loads(repository.extra)\n        else:\n            return json_response(error='参数错误')\n\n        form.extra = json.dumps(form.extra)\n        form.status = '0' if deploy.is_audit else '1'\n        form.host_ids = json.dumps(sorted(form.host_ids))\n        if form.id:\n            req = DeployRequest.objects.get(pk=form.id)\n            is_required_notify = deploy.is_audit and req.status == '-1'\n            DeployRequest.objects.filter(pk=form.id).update(created_by=request.user, reason=None, **form)\n        else:\n            req = DeployRequest.objects.create(created_by=request.user, **form)\n            is_required_notify = deploy.is_audit\n        if is_required_notify:\n            Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start()\n    return json_response(error=error)\n\n\n@auth('deploy.request.do')\ndef post_request_ext1_rollback(request):\n    form, error = JsonParser(\n        Argument('request_id', type=int, help='请选择要回滚的版本'),\n        Argument('name', help='请输入申请标题'),\n        Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),\n        Argument('desc', required=False),\n    ).parse(request.body)\n    if error is None:\n        req = DeployRequest.objects.get(pk=form.pop('request_id'))\n        requests = DeployRequest.objects.filter(deploy=req.deploy, status__in=('3', '-3'))\n        versions = list({x.spug_version: 1 for x in requests}.keys())\n        if req.spug_version not in versions[:req.deploy.extend_obj.versions + 1]:\n            return json_response(error='选择的版本超出了发布配置中设置的版本数量，无法快速回滚，可通过新建发布申请选择构建仓库里的该版本再次发布。')\n\n        form.status = '0' if req.deploy.is_audit else '1'\n        form.host_ids = json.dumps(sorted(form.host_ids))\n        new_req = DeployRequest.objects.create(\n            deploy_id=req.deploy_id,\n            repository_id=req.repository_id,\n            type='2',\n            extra=req.extra,\n            version=req.version,\n            spug_version=req.spug_version,\n            created_by=request.user,\n            **form\n        )\n        if req.deploy.is_audit:\n            Thread(target=Helper.send_deploy_notify, args=(new_req, 'approve_req')).start()\n    return json_response(error=error)\n\n\n@auth('deploy.request.add|deploy.request.edit')\ndef post_request_ext2(request):\n    form, error = JsonParser(\n        Argument('id', type=int, required=False),\n        Argument('deploy_id', type=int, help='缺少必要参数'),\n        Argument('name', help='请输申请标题'),\n        Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),\n        Argument('extra', type=dict, required=False),\n        Argument('version', default=''),\n        Argument('type', default='1'),\n        Argument('plan', required=False),\n        Argument('desc', required=False),\n    ).parse(request.body)\n    if error is None:\n        deploy = Deploy.objects.filter(pk=form.deploy_id).first()\n        if not deploy:\n            return json_response(error='未找到该发布配置')\n        extra = form.pop('extra')\n        if DeployExtend2.objects.filter(deploy=deploy, host_actions__contains='\"src_mode\": \"1\"').exists():\n            if not extra:\n                return json_response(error='该应用的发布配置中使用了数据传输动作且设置为发布时上传，请上传要传输的数据')\n            form.spug_version = extra['path']\n            form.extra = json.dumps(extra)\n        else:\n            form.spug_version = Repository.make_spug_version(deploy.id)\n        form.name = form.name.replace(\"'\", '')\n        form.status = '0' if deploy.is_audit else '1'\n        form.host_ids = json.dumps(form.host_ids)\n        if form.id:\n            req = DeployRequest.objects.get(pk=form.id)\n            is_required_notify = deploy.is_audit and req.status == '-1'\n            form.update(created_by=request.user, reason=None)\n            req.update_by_dict(form)\n        else:\n            req = DeployRequest.objects.create(created_by=request.user, **form)\n            is_required_notify = deploy.is_audit\n        if is_required_notify:\n            Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start()\n    return json_response(error=error)\n\n\n@auth('deploy.request.view')\ndef get_request_info(request):\n    form, error = JsonParser(\n        Argument('id', type=int, help='参数错误')\n    ).parse(request.GET)\n    if error is None:\n        req = DeployRequest.objects.get(pk=form.id)\n        response = req.to_dict(selects=('status', 'reason'))\n        response['fail_host_ids'] = json.loads(req.fail_host_ids)\n        response['status_alias'] = req.get_status_display()\n        return json_response(response)\n    return json_response(error=error)\n\n\n@auth('deploy.request.add')\ndef do_upload(request):\n    repos_dir = settings.REPOS_DIR\n    file = request.FILES['file']\n    deploy_id = request.POST.get('deploy_id')\n    if file and deploy_id:\n        dir_name = os.path.join(repos_dir, deploy_id)\n        file_name = datetime.now().strftime(\"%Y%m%d%H%M%S\")\n        command = f'mkdir -p {dir_name} && cd {dir_name} && ls | sort  -rn | tail -n +11 | xargs rm -rf'\n        code, outputs = subprocess.getstatusoutput(command)\n        if code != 0:\n            return json_response(error=outputs)\n        with open(os.path.join(dir_name, file_name), 'wb') as f:\n            for chunk in file.chunks():\n                f.write(chunk)\n        return json_response(file_name)\n    else:\n        return HttpResponseBadRequest()\n"
  },
  {
    "path": "spug_api/apps/exec/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/exec/executors.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django_redis import get_redis_connection\nfrom libs.utils import human_seconds_time\nfrom libs.ssh import SSH\nimport threading\nimport socket\nimport json\nimport time\n\n\ndef exec_worker_handler(job):\n    job = Job(**json.loads(job))\n    threading.Thread(target=job.run).start()\n\n\nclass Job:\n    def __init__(self, key, name, hostname, port, username, pkey, command, interpreter, params=None, token=None,\n                 term=None):\n        self.ssh = SSH(hostname, port, username, pkey, term=term)\n        self.key = key\n        self.command = self._handle_command(command, interpreter)\n        self.token = token\n        self.rds = get_redis_connection()\n        self.env = dict(\n            SPUG_HOST_ID=str(self.key),\n            SPUG_HOST_NAME=name,\n            SPUG_HOST_HOSTNAME=hostname,\n            SPUG_SSH_PORT=str(port),\n            SPUG_SSH_USERNAME=username,\n            SPUG_INTERPRETER=interpreter\n        )\n        if isinstance(params, dict):\n            self.env.update({f'_SPUG_{k}': str(v) for k, v in params.items()})\n\n    def _send(self, message):\n        self.rds.publish(self.token, json.dumps(message))\n\n    def _handle_command(self, command, interpreter):\n        if interpreter == 'python':\n            attach = 'INTERPRETER=python\\ncommand -v python3 &> /dev/null && INTERPRETER=python3'\n            return f'{attach}\\n$INTERPRETER << EOF\\n# -*- coding: UTF-8 -*-\\n{command}\\nEOF'\n        return command\n\n    def send(self, data):\n        self._send({'key': self.key, 'data': data})\n\n    def send_status(self, code):\n        self._send({'key': self.key, 'status': code})\n\n    def run(self):\n        if not self.token:\n            with self.ssh:\n                return self.ssh.exec_command(self.command, self.env)\n        flag = time.time()\n        self.send('\\r\\n\\x1b[36m### Executing ...\\x1b[0m\\r\\n')\n        code = -1\n        try:\n            with self.ssh:\n                for code, out in self.ssh.exec_command_with_stream(self.command, self.env):\n                    self.send(out)\n            human_time = human_seconds_time(time.time() - flag)\n            self.send(f'\\r\\n\\x1b[36m** 执行结束，总耗时：{human_time} **\\x1b[0m')\n        except socket.timeout:\n            code = 130\n            self.send('\\r\\n\\x1b[31m### Time out\\x1b[0m')\n        except Exception as e:\n            code = 131\n            self.send(f'\\r\\n\\x1b[31m### Exception {e}\\x1b[0m')\n            raise e\n        finally:\n            self.send_status(code)\n"
  },
  {
    "path": "spug_api/apps/exec/management/commands/runworker.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom django.conf import settings\nfrom django.db import connections\nfrom django_redis import get_redis_connection\nfrom concurrent.futures import ThreadPoolExecutor\nfrom apps.schedule.executors import schedule_worker_handler\nfrom apps.monitor.executors import monitor_worker_handler\nfrom apps.exec.executors import exec_worker_handler\nfrom apps.notify.models import Notify\nfrom threading import Thread\nimport logging\nimport time\nimport os\n\nEXEC_WORKER_KEY = settings.EXEC_WORKER_KEY\nMONITOR_WORKER_KEY = settings.MONITOR_WORKER_KEY\nSCHEDULE_WORKER_KEY = settings.SCHEDULE_WORKER_KEY\n\nlogging.basicConfig(level=logging.WARNING, format='%(asctime)s %(message)s')\n\n\nclass Worker:\n    def __init__(self):\n        self.rds = get_redis_connection()\n        self._executor = ThreadPoolExecutor(max_workers=max(100, os.cpu_count() * 50))\n\n    def job_done(self, future):\n        connections.close_all()\n\n    def queue_monitor(self):\n        counter = 0\n        while True:\n            time.sleep((counter or 1) ** 3 * 10)\n            qsize = self._executor._work_queue.qsize()\n            if qsize > 0:\n                if counter > 0:\n                    content = '请检查监控、任务计划或批量执行等避免长耗时任务，必要时可重启服务清空队列。'\n                    try:\n                        Notify.make_system_notify(f'执行队列堆积（{qsize}）', content)\n                    except Exception as e:\n                        logging.warning(e)\n                    finally:\n                        connections.close_all()\n                    logging.warning(f'!!! 执行队列堆积（{qsize}）')\n                counter += 1\n            else:\n                counter = 0\n\n    def run(self):\n        logging.warning('Running worker')\n        Thread(target=self.queue_monitor, daemon=True).start()\n        self.rds.delete(EXEC_WORKER_KEY, MONITOR_WORKER_KEY, SCHEDULE_WORKER_KEY)\n        while True:\n            key, job = self.rds.blpop([EXEC_WORKER_KEY, SCHEDULE_WORKER_KEY, MONITOR_WORKER_KEY])\n            key = key.decode()\n            if key == SCHEDULE_WORKER_KEY:\n                future = self._executor.submit(schedule_worker_handler, job)\n            elif key == MONITOR_WORKER_KEY:\n                future = self._executor.submit(monitor_worker_handler, job)\n            elif key == EXEC_WORKER_KEY:\n                future = self._executor.submit(exec_worker_handler, job)\n            else:\n                continue\n            future.add_done_callback(self.job_done)\n\n\nclass Command(BaseCommand):\n    help = 'Start worker process'\n\n    def handle(self, *args, **options):\n        w = Worker()\n        w.run()\n"
  },
  {
    "path": "spug_api/apps/exec/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nimport json\n\n\nclass ExecTemplate(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    type = models.CharField(max_length=50)\n    body = models.TextField()\n    interpreter = models.CharField(max_length=20, default='sh')\n    host_ids = models.TextField(default='[]')\n    desc = models.CharField(max_length=255, null=True)\n    parameters = models.TextField(default='[]')\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n    updated_at = models.CharField(max_length=20, null=True)\n    updated_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n\n    def __repr__(self):\n        return '<ExecTemplate %r>' % self.name\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['host_ids'] = json.loads(self.host_ids)\n        tmp['parameters'] = json.loads(self.parameters)\n        return tmp\n\n    class Meta:\n        db_table = 'exec_templates'\n        ordering = ('-id',)\n\n\nclass ExecHistory(models.Model, ModelMixin):\n    user = models.ForeignKey(User, on_delete=models.CASCADE)\n    template = models.ForeignKey(ExecTemplate, on_delete=models.SET_NULL, null=True)\n    digest = models.CharField(max_length=32, db_index=True)\n    interpreter = models.CharField(max_length=20)\n    command = models.TextField()\n    params = models.TextField(default='{}')\n    host_ids = models.TextField()\n    updated_at = models.CharField(max_length=20, default=human_datetime)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['host_ids'] = json.loads(self.host_ids)\n        if self.template:\n            tmp['template_name'] = self.template.name\n            tmp['interpreter'] = self.template.interpreter\n            tmp['parameters'] = json.loads(self.template.parameters)\n            tmp['command'] = self.template.body\n        return tmp\n\n    class Meta:\n        db_table = 'exec_histories'\n        ordering = ('-updated_at',)\n\n\nclass Transfer(models.Model, ModelMixin):\n    user = models.ForeignKey(User, on_delete=models.CASCADE)\n    digest = models.CharField(max_length=32, db_index=True)\n    host_id = models.IntegerField(null=True)\n    src_dir = models.CharField(max_length=255)\n    dst_dir = models.CharField(max_length=255)\n    host_ids = models.TextField()\n    updated_at = models.CharField(max_length=20, default=human_datetime)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['host_ids'] = json.loads(self.host_ids)\n        return tmp\n\n    class Meta:\n        db_table = 'exec_transfer'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/exec/transfer.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.conf import settings\nfrom django.db import close_old_connections\nfrom django_redis import get_redis_connection\nfrom apps.exec.models import Transfer\nfrom apps.account.utils import has_host_perm\nfrom apps.host.models import Host\nfrom apps.setting.utils import AppSetting\nfrom libs import json_response, JsonParser, Argument, auth\nfrom libs.utils import str_decode, human_seconds_time\nfrom concurrent import futures\nfrom threading import Thread\nimport subprocess\nimport tempfile\nimport uuid\nimport json\nimport time\nimport os\n\n\nclass TransferView(View):\n    @auth('exec.transfer.do')\n    def get(self, request):\n        records = Transfer.objects.filter(user=request.user)\n        return json_response([x.to_view() for x in records])\n\n    @auth('exec.transfer.do')\n    def post(self, request):\n        data = request.POST.get('data')\n        form, error = JsonParser(\n            Argument('host', required=False),\n            Argument('dst_dir', help='请输入目标路径'),\n            Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择目标主机'),\n        ).parse(data)\n        if error is None:\n            if not has_host_perm(request.user, form.host_ids):\n                return json_response(error='无权访问主机，请联系管理员')\n            host_id = None\n            token = uuid.uuid4().hex\n            base_dir = os.path.join(settings.TRANSFER_DIR, token)\n            if form.host:\n                host_id, path = json.loads(form.host)\n                if not path.strip('/'):\n                    return json_response(error='请输入正确的数据源路径')\n                host = Host.objects.get(pk=host_id)\n                with host.get_ssh() as ssh:\n                    code, _ = ssh.exec_command_raw(f'[ -d {path} ]')\n                    if code != 0:\n                        return json_response(error='数据源路径必须为该主机上已存在的目录')\n                os.makedirs(base_dir)\n                with tempfile.NamedTemporaryFile(mode='w') as fp:\n                    fp.write(host.pkey or AppSetting.get('private_key'))\n                    fp.flush()\n                    target = f'{host.username}@{host.hostname}:{path}'\n                    command = f'sshfs -o ro -o ssh_command=\"ssh -p {host.port} -i {fp.name}\" {target} {base_dir}'\n                    task = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)\n                    if task.returncode != 0:\n                        os.system(f'umount -f {base_dir} &> /dev/null ; rm -rf {base_dir}')\n                        return json_response(error=task.stdout.decode())\n            else:\n                os.makedirs(base_dir)\n                index = 0\n                while True:\n                    file = request.FILES.get(f'file{index}')\n                    if not file:\n                        break\n                    with open(os.path.join(base_dir, file.name), 'wb') as f:\n                        for chunk in file.chunks():\n                            f.write(chunk)\n                    index += 1\n            Transfer.objects.create(\n                user=request.user,\n                digest=token,\n                host_id=host_id,\n                src_dir=base_dir,\n                dst_dir=form.dst_dir,\n                host_ids=json.dumps(form.host_ids),\n            )\n            return json_response(token)\n        return json_response(error=error)\n\n    @auth('exec.transfer.do')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('token', help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            task = Transfer.objects.get(digest=form.token)\n            Thread(target=_dispatch_sync, args=(task,)).start()\n        return json_response(error=error)\n\n\ndef _dispatch_sync(task):\n    rds = get_redis_connection()\n    threads = []\n    max_workers = max(10, os.cpu_count() * 5)\n    with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n        for host in Host.objects.filter(id__in=json.loads(task.host_ids)):\n            t = executor.submit(_do_sync, rds, task, host)\n            t.token = task.digest\n            t.key = host.id\n            threads.append(t)\n        for t in futures.as_completed(threads):\n            exc = t.exception()\n            if exc:\n                rds.publish(\n                    t.token,\n                    json.dumps({'key': t.key, 'status': -1, 'data': f'\\x1b[31mException: {exc}\\x1b[0m'})\n                )\n    if task.host_id:\n        command = f'umount -f {task.src_dir} && rm -rf {task.src_dir}'\n    else:\n        command = f'rm -rf {task.src_dir}'\n    subprocess.run(command, shell=True)\n    close_old_connections()\n\n\ndef _do_sync(rds, task, host):\n    token = task.digest\n    rds.publish(token, json.dumps({'key': host.id, 'data': '\\r\\n\\x1b[36m### Executing ...\\x1b[0m\\r\\n'}))\n    with tempfile.NamedTemporaryFile(mode='w') as fp:\n        fp.write(host.pkey or AppSetting.get('private_key'))\n        fp.write('\\n')\n        fp.flush()\n\n        flag = time.time()\n        options = '-azv --progress' if task.host_id else '-rzv --progress'\n        argument = f'{task.src_dir}/ {host.username}@{host.hostname}:{task.dst_dir}'\n        command = f'rsync {options} -h -e \"ssh -p {host.port} -o StrictHostKeyChecking=no -i {fp.name}\" {argument}'\n        task = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)\n        message = b''\n        while True:\n            output = task.stdout.read(1)\n            if not output:\n                break\n            if output in (b'\\r', b'\\n'):\n                message += b'\\r\\n' if output == b'\\n' else b'\\r'\n                message = str_decode(message)\n                if 'rsync: command not found' in message:\n                    data = '\\r\\n\\x1b[31m检测到该主机未安装rsync，可通过批量执行/执行任务模块进行以下命令批量安装\\x1b[0m'\n                    data += '\\r\\nCentos/Redhat: yum install -y rsync'\n                    data += '\\r\\nUbuntu/Debian: apt install -y rsync'\n                    rds.publish(token, json.dumps({'key': host.id, 'data': data}))\n                    break\n                rds.publish(token, json.dumps({'key': host.id, 'data': message}))\n                message = b''\n            else:\n                message += output\n        status = task.wait()\n        if status == 0:\n            human_time = human_seconds_time(time.time() - flag)\n            rds.publish(token, json.dumps({'key': host.id, 'data': f'\\r\\n\\x1b[32m** 分发完成，总耗时：{human_time} **\\x1b[0m'}))\n        rds.publish(token, json.dumps({'key': host.id, 'status': task.wait()}))\n"
  },
  {
    "path": "spug_api/apps/exec/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.conf.urls import url\n\nfrom apps.exec.views import *\nfrom apps.exec.transfer import TransferView\n\nurlpatterns = [\n    url(r'template/$', TemplateView.as_view()),\n    url(r'do/$', TaskView.as_view()),\n    url(r'transfer/$', TransferView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/exec/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django_redis import get_redis_connection\nfrom django.conf import settings\nfrom libs import json_response, JsonParser, Argument, human_datetime, auth\nfrom apps.exec.models import ExecTemplate, ExecHistory\nfrom apps.host.models import Host\nfrom apps.account.utils import has_host_perm\nimport uuid\nimport json\n\n\nclass TemplateView(View):\n    @auth('exec.template.view|exec.task.do|schedule.schedule.add|schedule.schedule.edit|\\\n    monitor.monitor.add|monitor.monitor.edit')\n    def get(self, request):\n        templates = ExecTemplate.objects.all()\n        types = [x['type'] for x in templates.order_by('type').values('type').distinct()]\n        return json_response({'types': types, 'templates': [x.to_view() for x in templates]})\n\n    @auth('exec.template.add|exec.template.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入模版名称'),\n            Argument('type', help='请选择模版类型'),\n            Argument('body', help='请输入模版内容'),\n            Argument('interpreter', default='sh'),\n            Argument('host_ids', type=list, handler=json.dumps, default=[]),\n            Argument('parameters', type=list, handler=json.dumps, default=[]),\n            Argument('desc', required=False)\n        ).parse(request.body)\n        if error is None:\n            if form.id:\n                form.updated_at = human_datetime()\n                form.updated_by = request.user\n                ExecTemplate.objects.filter(pk=form.pop('id')).update(**form)\n            else:\n                form.created_by = request.user\n                ExecTemplate.objects.create(**form)\n        return json_response(error=error)\n\n    @auth('exec.template.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            ExecTemplate.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n\n\nclass TaskView(View):\n    @auth('exec.task.do')\n    def get(self, request):\n        records = ExecHistory.objects.filter(user=request.user).select_related('template')\n        return json_response([x.to_view() for x in records])\n\n    @auth('exec.task.do')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择执行主机'),\n            Argument('command', help='请输入执行命令内容'),\n            Argument('interpreter', default='sh'),\n            Argument('template_id', type=int, required=False),\n            Argument('params', type=dict, handler=json.dumps, default={})\n        ).parse(request.body)\n        if error is None:\n            if not has_host_perm(request.user, form.host_ids):\n                return json_response(error='无权访问主机，请联系管理员')\n            token, rds = uuid.uuid4().hex, get_redis_connection()\n            form.host_ids.sort()\n            if form.template_id:\n                template = ExecTemplate.objects.filter(pk=form.template_id).first()\n                if not template or template.body != form.command:\n                    form.template_id = None\n\n            ExecHistory.objects.create(\n                user=request.user,\n                digest=token,\n                interpreter=form.interpreter,\n                template_id=form.template_id,\n                command=form.command,\n                host_ids=json.dumps(form.host_ids),\n                params=form.params\n            )\n            return json_response(token)\n        return json_response(error=error)\n\n    @auth('exec.task.do')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('token', help='参数错误'),\n            Argument('cols', type=int, required=False),\n            Argument('rows', type=int, required=False)\n        ).parse(request.body)\n        if error is None:\n            term = None\n            if form.cols and form.rows:\n                term = {'width': form.cols, 'height': form.rows}\n            rds = get_redis_connection()\n            task = ExecHistory.objects.get(digest=form.token)\n            for host in Host.objects.filter(id__in=json.loads(task.host_ids)):\n                data = dict(\n                    key=host.id,\n                    name=host.name,\n                    token=task.digest,\n                    interpreter=task.interpreter,\n                    hostname=host.hostname,\n                    port=host.port,\n                    username=host.username,\n                    command=task.command,\n                    pkey=host.private_key,\n                    params=json.loads(task.params),\n                    term=term\n                )\n                rds.rpush(settings.EXEC_WORKER_KEY, json.dumps(data))\n        return json_response(error=error)\n\n\n\n"
  },
  {
    "path": "spug_api/apps/file/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/file/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', FileView.as_view()),\n    path('object/', ObjectView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/file/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.http import FileResponse\nimport stat\nimport time\nimport os\n\nKB = 1024\nMB = 1024 * 1024\nGB = 1024 * 1024 * 1024\nTB = 1024 * 1024 * 1024 * 1024\n\n\nclass FileResponseAfter(FileResponse):\n    def __init__(self, callback, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.callback = callback\n\n    def close(self):\n        super().close()\n        self.callback()\n\n\ndef parse_mode(obj):\n    if obj.st_mode:\n        mt = stat.S_IFMT(obj.st_mode)\n        if mt == stat.S_IFIFO:\n            kind = 'p'\n        elif mt == stat.S_IFCHR:\n            kind = 'c'\n        elif mt == stat.S_IFDIR:\n            kind = 'd'\n        elif mt == stat.S_IFBLK:\n            kind = 'b'\n        elif mt == stat.S_IFREG:\n            kind = '-'\n        elif mt == stat.S_IFLNK:\n            kind = 'l'\n        elif mt == stat.S_IFSOCK:\n            kind = 's'\n        else:\n            kind = '?'\n        code = obj._rwx(\n            (obj.st_mode & 448) >> 6, obj.st_mode & stat.S_ISUID\n        )\n        code += obj._rwx(\n            (obj.st_mode & 56) >> 3, obj.st_mode & stat.S_ISGID\n        )\n        code += obj._rwx(\n            obj.st_mode & 7, obj.st_mode & stat.S_ISVTX, True\n        )\n        return kind + code\n    else:\n        return '?---------'\n\n\ndef format_size(size):\n    if size:\n        if size < KB:\n            return f'{size}B'\n        if size < MB:\n            return f'{size / KB:.1f}K'\n        if size < GB:\n            return f'{size / MB:.1f}M'\n        if size < TB:\n            return f'{size / GB:.1f}G'\n        return f'{size / TB:.1f}T'\n    else:\n        return ''\n\n\ndef fetch_dir_list(host, path):\n    with host.get_ssh() as ssh:\n        objects = []\n        for item in ssh.list_dir_attr(path):\n            code = parse_mode(item)\n            kind, is_link, name = '?', False, getattr(item, 'filename', '?')\n            if stat.S_ISLNK(item.st_mode):\n                is_link = True\n                try:\n                    item = ssh.sftp_stat(os.path.join(path, name))\n                except FileNotFoundError:\n                    pass\n            if stat.S_ISREG(item.st_mode):\n                kind = '-'\n            elif stat.S_ISDIR(item.st_mode):\n                kind = 'd'\n            if (item.st_mtime is None) or (item.st_mtime == int(0xffffffff)):\n                date = '(unknown date)'\n            else:\n                date = time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(item.st_mtime))\n            objects.append({\n                'name': name,\n                'size': '' if kind == 'd' else format_size(item.st_size or ''),\n                'date': date,\n                'kind': kind,\n                'code': code,\n                'is_link': is_link\n            })\n    return objects\n"
  },
  {
    "path": "spug_api/apps/file/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django_redis import get_redis_connection\nfrom apps.host.models import Host\nfrom apps.account.utils import has_host_perm\nfrom apps.file.utils import FileResponseAfter, fetch_dir_list\nfrom libs import json_response, JsonParser, Argument, auth\nfrom functools import partial\nimport os\n\n\nclass FileView(View):\n    @auth('host.console.list')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('path', help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            if not has_host_perm(request.user, form.id):\n                return json_response(error='无权访问主机，请联系管理员')\n            host = Host.objects.get(pk=form.id)\n            if not host:\n                return json_response(error='未找到指定主机')\n            objects = fetch_dir_list(host, form.path)\n            return json_response(objects)\n        return json_response(error=error)\n\n\nclass ObjectView(View):\n    @auth('host.console.list')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('file', help='请输入文件路径')\n        ).parse(request.GET)\n        if error is None:\n            if not has_host_perm(request.user, form.id):\n                return json_response(error='无权访问主机，请联系管理员')\n            host = Host.objects.filter(pk=form.id).first()\n            if not host:\n                return json_response(error='未找到指定主机')\n            filename = os.path.basename(form.file)\n            ssh_cli = host.get_ssh().get_client()\n            sftp = ssh_cli.open_sftp()\n            f = sftp.open(form.file)\n            return FileResponseAfter(ssh_cli.close, f, as_attachment=True, filename=filename)\n        return json_response(error=error)\n\n    @auth('host.console.upload')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('token', help='参数错误'),\n            Argument('path', help='参数错误'),\n        ).parse(request.POST)\n        if error is None:\n            if not has_host_perm(request.user, form.id):\n                return json_response(error='无权访问主机，请联系管理员')\n            file = request.FILES.get('file')\n            if not file:\n                return json_response(error='请选择要上传的文件')\n            host = Host.objects.get(pk=form.id)\n            if not host:\n                return json_response(error='未找到指定主机')\n            rds_cli = get_redis_connection()\n            callback = partial(self._compute_progress, rds_cli, form.token, file.size)\n            with host.get_ssh() as ssh:\n                ssh.put_file_by_fl(file, f'{form.path}/{file.name}', callback=callback)\n        return json_response(error=error)\n\n    @auth('host.console.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('file', help='请输入文件路径')\n        ).parse(request.GET)\n        if error is None:\n            if not has_host_perm(request.user, form.id):\n                return json_response(error='无权访问主机，请联系管理员')\n            host = Host.objects.get(pk=form.id)\n            if not host:\n                return json_response(error='未找到指定主机')\n            with host.get_ssh() as ssh:\n                ssh.remove_file(form.file)\n        return json_response(error=error)\n\n    def _compute_progress(self, rds_cli, token, total, value, *args):\n        percent = '%.1f' % (value / total * 100)\n        rds_cli.publish(token, percent)\n"
  },
  {
    "path": "spug_api/apps/home/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/home/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs.mixins import ModelMixin\nimport json\n\n\nclass Notice(models.Model, ModelMixin):\n    title = models.CharField(max_length=100)\n    content = models.TextField()\n    is_stress = models.BooleanField(default=False)\n    read_ids = models.TextField(default='[]')\n    sort_id = models.IntegerField(default=0, db_index=True)\n    created_at = models.DateTimeField(auto_now_add=True)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['read_ids'] = json.loads(self.read_ids)\n        return tmp\n\n    class Meta:\n        db_table = 'notices'\n        ordering = ('-sort_id',)\n\n\nclass Navigation(models.Model, ModelMixin):\n    title = models.CharField(max_length=64)\n    desc = models.CharField(max_length=128)\n    logo = models.TextField()\n    links = models.TextField()\n    sort_id = models.IntegerField(default=0, db_index=True)\n    created_at = models.DateTimeField(auto_now_add=True)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['links'] = json.loads(self.links)\n        return tmp\n\n    class Meta:\n        db_table = 'navigations'\n        ordering = ('-sort_id',)\n"
  },
  {
    "path": "spug_api/apps/home/navigation.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom libs import json_response, JsonParser, Argument\nfrom apps.home.models import Navigation\nimport json\n\n\nclass NavView(View):\n    def get(self, request):\n        navs = Navigation.objects.all()\n        return json_response([x.to_view() for x in navs])\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('title', help='请输入导航标题'),\n            Argument('desc', help='请输入导航描述'),\n            Argument('logo', help='请上传导航logo'),\n            Argument('links', type=list, filter=lambda x: len(x), help='请设置导航链接'),\n        ).parse(request.body)\n        if error is None:\n            form.links = json.dumps(form.links)\n            if form.id:\n                Navigation.objects.filter(pk=form.id).update(**form)\n            else:\n                nav = Navigation.objects.create(**form)\n                nav.sort_id = nav.id\n                nav.save()\n        return json_response(error=error)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('sort', filter=lambda x: x in ('up', 'down'), required=False),\n        ).parse(request.body)\n        if error is None:\n            nav = Navigation.objects.filter(pk=form.id).first()\n            if not nav:\n                return json_response(error='未找到指定记录')\n            if form.sort:\n                if form.sort == 'up':\n                    tmp = Navigation.objects.filter(sort_id__gt=nav.sort_id).last()\n                else:\n                    tmp = Navigation.objects.filter(sort_id__lt=nav.sort_id).first()\n                if tmp:\n                    tmp.sort_id, nav.sort_id = nav.sort_id, tmp.sort_id\n                    tmp.save()\n            nav.save()\n        return json_response(error=error)\n\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            Navigation.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/home/notice.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom libs import json_response, JsonParser, Argument\nfrom apps.home.models import Notice\nimport json\n\n\nclass NoticeView(View):\n    def get(self, request):\n        notices = Notice.objects.all()\n        return json_response([x.to_view() for x in notices])\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('title', help='请输入标题'),\n            Argument('content', help='请输入内容'),\n            Argument('is_stress', type=bool, default=False),\n        ).parse(request.body)\n        if error is None:\n            if form.is_stress:\n                Notice.objects.update(is_stress=False)\n            if form.id:\n                Notice.objects.filter(pk=form.id).update(**form)\n            else:\n                notice = Notice.objects.create(**form)\n                notice.sort_id = notice.id\n                notice.save()\n        return json_response(error=error)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('sort', filter=lambda x: x in ('up', 'down'), required=False),\n            Argument('read', required=False)\n        ).parse(request.body)\n        if error is None:\n            notice = Notice.objects.filter(pk=form.id).first()\n            if not notice:\n                return json_response(error='未找到指定记录')\n            if form.sort:\n                if form.sort == 'up':\n                    tmp = Notice.objects.filter(sort_id__gt=notice.sort_id).last()\n                else:\n                    tmp = Notice.objects.filter(sort_id__lt=notice.sort_id).first()\n                if tmp:\n                    tmp.sort_id, notice.sort_id = notice.sort_id, tmp.sort_id\n                    tmp.save()\n            if form.read:\n                read_ids = json.loads(notice.read_ids)\n                read_ids.append(str(request.user.id))\n                notice.read_ids = json.dumps(read_ids)\n            notice.save()\n        return json_response(error=error)\n\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            Notice.objects.filter(pk=form.id).delete()\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/home/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\nfrom apps.home.notice import NoticeView\nfrom apps.home.navigation import NavView\n\nurlpatterns = [\n    path('statistic/', get_statistic),\n    path('alarm/', get_alarm),\n    path('deploy/', get_deploy),\n    path('request/', get_request),\n    path('notice/', NoticeView.as_view()),\n    path('navigation/', NavView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/home/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apps.app.models import App\nfrom apps.host.models import Host\nfrom apps.schedule.models import Task\nfrom apps.monitor.models import Detection\nfrom apps.alarm.models import Alarm\nfrom apps.deploy.models import Deploy, DeployRequest\nfrom apps.account.utils import get_host_perms\nfrom libs.utils import json_response, human_date, parse_time\nfrom libs.parser import JsonParser, Argument\nfrom libs.decorators import auth\nfrom datetime import datetime, timedelta\nimport json\n\n\n@auth('dashboard.dashboard.view')\ndef get_statistic(request):\n    if request.user.is_supper:\n        app = App.objects.count()\n        host = Host.objects.count()\n    else:\n        deploy_perms, host_perms = request.user.deploy_perms, get_host_perms(request.user)\n        app = App.objects.filter(id__in=deploy_perms['apps']).count()\n        host = len(host_perms)\n    data = {\n        'app': app,\n        'host': host,\n        'task': Task.objects.count(),\n        'detection': Detection.objects.count()\n    }\n    return json_response(data)\n\n\n@auth('dashboard.dashboard.view')\ndef get_alarm(request):\n    form, error = JsonParser(\n        Argument('type', required=False),\n        Argument('name', required=False)\n    ).parse(request.GET, True)\n    if error is None:\n        now = datetime.now()\n        data = {human_date(now - timedelta(days=x + 1)): 0 for x in range(14)}\n        for alarm in Alarm.objects.filter(status='1', created_at__gt=human_date(now - timedelta(days=14)), **form):\n            date = alarm.created_at[:10]\n            if date in data:\n                data[date] += 1\n        data = [{'date': k, 'value': v} for k, v in data.items()]\n        return json_response(data)\n    return json_response(error=error)\n\n\n@auth('dashboard.dashboard.view')\ndef get_request(request):\n    form, error = JsonParser(\n        Argument('duration', type=list, help='参数错误')\n    ).parse(request.body)\n    if error is None:\n        s_date = form.duration[0]\n        e_date = (parse_time(form.duration[1]) + timedelta(days=1)).strftime('%Y-%m-%d')\n        data = {x.id: {'name': x.name, 'count': 0} for x in App.objects.all()}\n        for req in DeployRequest.objects.filter(created_at__gt=s_date, created_at__lt=e_date):\n            data[req.deploy.app_id]['count'] += 1\n        data = sorted(data.values(), key=lambda x: x['count'], reverse=True)[:20]\n        return json_response(data)\n    return json_response(error=error)\n\n\n@auth('dashboard.dashboard.view')\ndef get_deploy(request):\n    host = Host.objects.filter(deleted_at__isnull=True).count()\n    data = {x.id: {'name': x.name, 'count': 0} for x in App.objects.all()}\n    for dep in Deploy.objects.all():\n        data[dep.app_id]['count'] += len(json.loads(dep.host_ids))\n    data = filter(lambda x: x['count'], data.values())\n    return json_response({'host': host, 'res': list(data)})\n"
  },
  {
    "path": "spug_api/apps/host/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/host/add.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom libs import json_response, JsonParser, Argument, auth\nfrom apps.host.models import Host, HostExtend, Group\nfrom apps.host import utils\nimport json\n\n\n@auth('host.host.add')\ndef get_regions(request):\n    form, error = JsonParser(\n        Argument('type', filter=lambda x: x in ('ali', 'tencent'), help='参数错误'),\n        Argument('ak', help='请输入AccessKey ID'),\n        Argument('ac', help='请输入AccessKey Secret'),\n    ).parse(request.GET)\n    if error is None:\n        response = []\n        if form.type == 'ali':\n            for item in utils.fetch_ali_regions(form.ak, form.ac):\n                response.append({'id': item['RegionId'], 'name': item['LocalName']})\n        else:\n            for item in utils.fetch_tencent_regions(form.ak, form.ac):\n                response.append({'id': item['Region'], 'name': item['RegionName']})\n        return json_response(response)\n    return json_response(error=error)\n\n\n@auth('host.host.add')\ndef cloud_import(request):\n    form, error = JsonParser(\n        Argument('type', filter=lambda x: x in ('ali', 'tencent'), help='参数错误'),\n        Argument('ak', help='请输入AccessKey ID'),\n        Argument('ac', help='请输入AccessKey Secret'),\n        Argument('region_id', help='请选择区域'),\n        Argument('group_id', type=int, help='请选择分组'),\n        Argument('username', help='请输入默认SSH用户名'),\n        Argument('port', type=int, help='请输入默认SSH端口号'),\n        Argument('host_type', filter=lambda x: x in ('public', 'private'), help='请选择连接地址'),\n    ).parse(request.body)\n    if error is None:\n        group = Group.objects.filter(pk=form.group_id).first()\n        if not group:\n            return json_response(error='未找到指定分组')\n        if form.type == 'ali':\n            instances = utils.fetch_ali_instances(form.ak, form.ac, form.region_id)\n        else:\n            instances = utils.fetch_tencent_instances(form.ak, form.ac, form.region_id)\n\n        host_add_ids = []\n        for item in instances:\n            instance_id = item['instance_id']\n            host_name = item.pop('instance_name')\n            public_ips = item['public_ip_address'] or []\n            private_ips = item['private_ip_address'] or []\n            item['public_ip_address'] = json.dumps(public_ips)\n            item['private_ip_address'] = json.dumps(private_ips)\n            if HostExtend.objects.filter(instance_id=instance_id).exists():\n                HostExtend.objects.filter(instance_id=instance_id).update(**item)\n            else:\n                if form.host_type == 'public':\n                    hostname = public_ips[0] if public_ips else ''\n                else:\n                    hostname = private_ips[0] if private_ips else ''\n                host = Host.objects.create(\n                    name=host_name,\n                    hostname=hostname,\n                    port=form.port,\n                    username=form.username,\n                    created_by=request.user)\n                HostExtend.objects.create(host=host, **item)\n                host_add_ids.append(host.id)\n        if host_add_ids:\n            group.hosts.add(*host_add_ids)\n        return json_response(len(instances))\n    return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/host/extend.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom libs import json_response, JsonParser, Argument, human_datetime, auth\nfrom apps.host.models import Host, HostExtend\nfrom apps.host.utils import check_os_type, fetch_host_extend\nimport json\n\n\nclass ExtendView(View):\n    @auth('host.host.add|host.host.edit')\n    def get(self, request):\n        form, error = JsonParser(\n            Argument('host_id', type=int, help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            host = Host.objects.filter(pk=form.host_id).first()\n            if not host:\n                return json_response(error='未找到指定主机')\n            if not host.is_verified:\n                return json_response(error='该主机还未验证')\n            with host.get_ssh() as ssh:\n                response = fetch_host_extend(ssh)\n            return json_response(response)\n        return json_response(error=error)\n\n    @auth('host.host.add|host.host.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('host_id', type=int, help='参数错误'),\n            Argument('instance_id', required=False),\n            Argument('os_name', help='请输入操作系统'),\n            Argument('cpu', type=int, help='请输入CPU核心数'),\n            Argument('memory', type=float, help='请输入内存大小'),\n            Argument('disk', type=list, filter=lambda x: len(x), help='请添加磁盘'),\n            Argument('private_ip_address', type=list, filter=lambda x: len(x), help='请添加内网IP'),\n            Argument('public_ip_address', type=list, required=False),\n            Argument('instance_charge_type', default='Other'),\n            Argument('internet_charge_type', default='Other'),\n            Argument('created_time', required=False),\n            Argument('expired_time', required=False)\n        ).parse(request.body)\n        if error is None:\n            host = Host.objects.filter(pk=form.host_id).first()\n            form.disk = json.dumps(form.disk)\n            form.public_ip_address = json.dumps(form.public_ip_address) if form.public_ip_address else '[]'\n            form.private_ip_address = json.dumps(form.private_ip_address)\n            form.updated_at = human_datetime()\n            form.os_type = check_os_type(form.os_name)\n            if hasattr(host, 'hostextend'):\n                extend = host.hostextend\n                extend.update_by_dict(form)\n            else:\n                extend = HostExtend.objects.create(host=host, **form)\n            return json_response(extend.to_view())\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/host/group.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom libs import json_response, JsonParser, Argument, auth\nfrom apps.host.models import Group\nfrom apps.account.models import Role\n\n\ndef fetch_children(data, with_hosts):\n    if data:\n        sub_data = dict()\n        for item in Group.objects.filter(parent_id__in=data.keys()):\n            tmp = item.to_view(with_hosts)\n            sub_data[item.id] = tmp\n            data[item.parent_id]['children'].append(tmp)\n        return fetch_children(sub_data, with_hosts)\n\n\ndef merge_children(data, prefix, childes):\n    prefix = f'{prefix}/' if prefix else ''\n    for item in childes:\n        name = f'{prefix}{item[\"title\"]}'\n        item['name'] = name\n        if item.get('children'):\n            merge_children(data, name, item['children'])\n        else:\n            data[item['key']] = name\n\n\ndef filter_by_perm(data, result, ids):\n    for item in data:\n        if 'children' in item:\n            if item['key'] in ids:\n                result.append(item)\n            elif item['children']:\n                filter_by_perm(item['children'], result, ids)\n\n\nclass GroupView(View):\n    def get(self, request):\n        with_hosts = request.GET.get('with_hosts')\n        data, data2 = dict(), dict()\n        for item in Group.objects.filter(parent_id=0):\n            data[item.id] = item.to_view(with_hosts)\n        fetch_children(data, with_hosts)\n        if not data:\n            grp = Group.objects.create(name='Default', sort_id=1)\n            data[grp.id] = grp.to_view()\n        if request.user.is_supper:\n            tree_data = list(data.values())\n        else:\n            tree_data, ids = [], request.user.group_perms\n            filter_by_perm(data.values(), tree_data, ids)\n        merge_children(data2, '', tree_data)\n        return json_response({'treeData': tree_data, 'groups': data2})\n\n    @auth('admin')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('parent_id', type=int, default=0),\n            Argument('name', help='请输入分组名称')\n        ).parse(request.body)\n        if error is None:\n            if form.id:\n                Group.objects.filter(pk=form.id).update(name=form.name)\n            else:\n                group = Group.objects.create(**form)\n                group.sort_id = group.id\n                group.save()\n        return json_response(error=error)\n\n    @auth('admin')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('s_id', type=int, help='参数错误'),\n            Argument('d_id', type=int, help='参数错误'),\n            Argument('action', type=int, help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            src = Group.objects.get(pk=form.s_id)\n            dst = Group.objects.get(pk=form.d_id)\n            if form.action == 0:\n                src.parent_id = dst.id\n                dst = Group.objects.filter(parent_id=dst.id).first()\n                if not dst:\n                    src.save()\n                    return json_response()\n                form.action = -1\n            src.parent_id = dst.parent_id\n            if src.sort_id > dst.sort_id:\n                if form.action == -1:\n                    dst = Group.objects.filter(sort_id__gt=dst.sort_id).last()\n                Group.objects.filter(sort_id__lt=src.sort_id, sort_id__gte=dst.sort_id).update(sort_id=F('sort_id') + 1)\n            else:\n                if form.action == 1:\n                    dst = Group.objects.filter(sort_id__lt=dst.sort_id).first()\n                Group.objects.filter(sort_id__lte=dst.sort_id, sort_id__gt=src.sort_id).update(sort_id=F('sort_id') - 1)\n            src.sort_id = dst.sort_id\n            src.save()\n        return json_response(error=error)\n\n    @auth('admin')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误')\n        ).parse(request.GET)\n        if error is None:\n            group = Group.objects.filter(pk=form.id).first()\n            if not group:\n                return json_response(error='未找到指定分组')\n            if Group.objects.filter(parent_id=group.id).exists():\n                return json_response(error='请移除子分组后再尝试删除')\n            if group.hosts.exists():\n                return json_response(error='请移除分组下的主机后再尝试删除')\n            if not Group.objects.exclude(pk=form.id).exists():\n                return json_response(error='请至少保留一个分组')\n            role = Role.objects.filter(group_perms__regex=fr'[^0-9]{form.id}[^0-9]').first()\n            if role:\n                return json_response(error=f'账户角色【{role.name}】的主机权限关联该分组，请解除关联后再尝试删除')\n            group.delete()\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/host/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nfrom apps.setting.utils import AppSetting\nfrom libs.ssh import SSH\nimport json\n\n\nclass Host(models.Model, ModelMixin):\n    name = models.CharField(max_length=100)\n    hostname = models.CharField(max_length=50)\n    port = models.IntegerField(null=True)\n    username = models.CharField(max_length=50)\n    pkey = models.TextField(null=True)\n    desc = models.CharField(max_length=255, null=True)\n    is_verified = models.BooleanField(default=False)\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n\n    @property\n    def private_key(self):\n        return self.pkey or AppSetting.get('private_key')\n\n    def get_ssh(self, pkey=None, default_env=None):\n        pkey = pkey or self.private_key\n        return SSH(self.hostname, self.port, self.username, pkey, default_env=default_env)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        if hasattr(self, 'hostextend'):\n            tmp.update(self.hostextend.to_view())\n        tmp['group_ids'] = []\n        return tmp\n\n    def __repr__(self):\n        return '<Host %r>' % self.name\n\n    class Meta:\n        db_table = 'hosts'\n        ordering = ('-id',)\n\n\nclass HostExtend(models.Model, ModelMixin):\n    INSTANCE_CHARGE_TYPES = (\n        ('PrePaid', '包年包月'),\n        ('PostPaid', '按量计费'),\n        ('Other', '其他')\n    )\n    INTERNET_CHARGE_TYPES = (\n        ('PayByTraffic', '按流量计费'),\n        ('PayByBandwidth', '按带宽计费'),\n        ('Other', '其他')\n    )\n    host = models.OneToOneField(Host, on_delete=models.CASCADE)\n    instance_id = models.CharField(max_length=64, null=True)\n    zone_id = models.CharField(max_length=30, null=True)\n    cpu = models.IntegerField()\n    memory = models.FloatField()\n    disk = models.CharField(max_length=255, default='[]')\n    os_name = models.CharField(max_length=50)\n    os_type = models.CharField(max_length=20)\n    private_ip_address = models.CharField(max_length=255)\n    public_ip_address = models.CharField(max_length=255)\n    instance_charge_type = models.CharField(max_length=20, choices=INSTANCE_CHARGE_TYPES)\n    internet_charge_type = models.CharField(max_length=20, choices=INTERNET_CHARGE_TYPES)\n    created_time = models.CharField(max_length=20, null=True)\n    expired_time = models.CharField(max_length=20, null=True)\n    updated_at = models.CharField(max_length=20, default=human_datetime)\n\n    def to_view(self):\n        tmp = self.to_dict(excludes=('id',))\n        tmp['disk'] = json.loads(self.disk)\n        tmp['private_ip_address'] = json.loads(self.private_ip_address)\n        tmp['public_ip_address'] = json.loads(self.public_ip_address)\n        tmp['instance_charge_type_alias'] = self.get_instance_charge_type_display()\n        tmp['internet_charge_type_alisa'] = self.get_internet_charge_type_display()\n        return tmp\n\n    class Meta:\n        db_table = 'host_extend'\n\n\nclass Group(models.Model, ModelMixin):\n    name = models.CharField(max_length=50)\n    parent_id = models.IntegerField(default=0)\n    sort_id = models.IntegerField(default=0)\n    hosts = models.ManyToManyField(Host, related_name='groups')\n\n    def to_view(self, with_hosts=False):\n        response = dict(key=self.id, value=self.id, title=self.name, children=[])\n        if with_hosts:\n            def make_item(x):\n                return dict(title=x.name, hostname=x.hostname, key=f'{self.id}_{x.id}', id=x.id, isLeaf=True)\n\n            response['children'] = [make_item(x) for x in self.hosts.all()]\n        return response\n\n    class Meta:\n        db_table = 'host_groups'\n        ordering = ('-sort_id',)\n"
  },
  {
    "path": "spug_api/apps/host/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom apps.host.views import *\nfrom apps.host.group import GroupView\nfrom apps.host.extend import ExtendView\nfrom apps.host.add import get_regions, cloud_import\n\nurlpatterns = [\n    path('', HostView.as_view()),\n    path('extend/', ExtendView.as_view()),\n    path('group/', GroupView.as_view()),\n    path('import/', post_import),\n    path('import/cloud/', cloud_import),\n    path('import/region/', get_regions),\n    path('parse/', post_parse),\n    path('valid/', batch_valid),\n]\n"
  },
  {
    "path": "spug_api/apps/host/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django_redis import get_redis_connection\nfrom libs.helper import make_ali_request, make_tencent_request\nfrom libs.ssh import SSH, AuthenticationException\nfrom libs.utils import AttrDict, human_datetime\nfrom libs.validators import ip_validator\nfrom apps.host.models import HostExtend\nfrom apps.setting.utils import AppSetting\nfrom collections import defaultdict\nfrom datetime import datetime, timezone\nfrom concurrent import futures\nimport ipaddress\nimport json\nimport math\nimport os\n\n\ndef check_os_type(os_name):\n    os_name = os_name.lower()\n    types = ('centos', 'coreos', 'debian', 'suse', 'ubuntu', 'windows', 'freebsd', 'tencent', 'alibaba', 'fedora')\n    for t in types:\n        if t in os_name:\n            return t\n    return 'unknown'\n\n\ndef check_instance_charge_type(value, supplier):\n    if supplier == 'ali':\n        if value in ('PrePaid', 'PostPaid'):\n            return value\n        else:\n            return 'Other'\n    if supplier == 'tencent':\n        if value == 'PREPAID':\n            return 'PrePaid'\n        if value == 'POSTPAID_BY_HOUR':\n            return 'PostPaid'\n        return 'Other'\n\n\ndef check_internet_charge_type(value, supplier):\n    if supplier == 'ali':\n        if value in ('PayByTraffic', 'PayByBandwidth'):\n            return value\n        else:\n            return 'Other'\n    if supplier == 'tencent':\n        if value == 'TRAFFIC_POSTPAID_BY_HOUR':\n            return 'PayByTraffic'\n        if value in ('BANDWIDTH_PREPAID', 'BANDWIDTH_POSTPAID_BY_HOUR'):\n            return 'PayByBandwidth'\n        return 'Other'\n\n\ndef parse_utc_date(value):\n    if not value:\n        return None\n    s_format = '%Y-%m-%dT%H:%M:%SZ'\n    if len(value) == 17:\n        s_format = '%Y-%m-%dT%H:%MZ'\n    date = datetime.strptime(value, s_format).replace(tzinfo=timezone.utc)\n    return date.astimezone().strftime('%Y-%m-%d %H:%M:%S')\n\n\ndef fetch_ali_regions(ak, ac):\n    params = dict(Action='DescribeRegions')\n    res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params)\n    if 'Regions' in res:\n        return res['Regions']['Region']\n    else:\n        raise Exception(res)\n\n\ndef fetch_ali_disks(ak, ac, region_id, page_number=1):\n    data, page_size = defaultdict(list), 20\n    params = dict(\n        Action='DescribeDisks',\n        RegionId=region_id,\n        PageNumber=page_number,\n        PageSize=page_size\n    )\n    res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params)\n    if 'Disks' in res:\n        for item in res['Disks']['Disk']:\n            data[item['InstanceId']].append(item['Size'])\n        if len(res['Disks']['Disk']) == page_size:\n            page_number += 1\n            new_data = fetch_ali_disks(ak, ac, region_id, page_number)\n            data.update(new_data)\n        return data\n    else:\n        raise Exception(res)\n\n\ndef fetch_ali_instances(ak, ac, region_id, page_number=1):\n    data, page_size = {}, 20\n    params = dict(\n        Action='DescribeInstances',\n        RegionId=region_id,\n        PageNumber=page_number,\n        PageSize=page_size\n    )\n    res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params)\n    if 'Instances' not in res:\n        raise Exception(res)\n    for item in res['Instances']['Instance']:\n        if 'NetworkInterfaces' in item:\n            network_interface = item['NetworkInterfaces']['NetworkInterface']\n        else:\n            network_interface = []\n        data[item['InstanceId']] = dict(\n            instance_id=item['InstanceId'],\n            instance_name=item['InstanceName'],\n            os_name=item['OSName'],\n            os_type=check_os_type(item['OSName']),\n            cpu=item['Cpu'],\n            memory=item['Memory'] / 1024,\n            created_time=parse_utc_date(item['CreationTime']),\n            expired_time=parse_utc_date(item['ExpiredTime']),\n            instance_charge_type=check_instance_charge_type(item['InstanceChargeType'], 'ali'),\n            internet_charge_type=check_internet_charge_type(item['InternetChargeType'], 'ali'),\n            public_ip_address=item['PublicIpAddress']['IpAddress'],\n            private_ip_address=[x['PrimaryIpAddress'] for x in network_interface if x.get('PrimaryIpAddress')],\n            zone_id=item['ZoneId']\n        )\n    if len(res['Instances']['Instance']) == page_size:\n        new_data = fetch_ali_instances(ak, ac, region_id, page_number + 1)\n        data.update(new_data)\n    if page_number != 1:\n        return data\n    for instance_id, disk in fetch_ali_disks(ak, ac, region_id).items():\n        if instance_id in data:\n            data[instance_id]['disk'] = disk\n    return list(data.values())\n\n\ndef fetch_tencent_regions(ak, ac):\n    params = dict(Action='DescribeRegions')\n    res = make_tencent_request(ak, ac, 'cvm.tencentcloudapi.com', params)\n    if 'RegionSet' in res['Response']:\n        return res['Response']['RegionSet']\n    else:\n        raise Exception(res)\n\n\ndef fetch_tencent_instances(ak, ac, region_id, page_number=1):\n    data, page_size = [], 20\n    params = dict(\n        Action='DescribeInstances',\n        Region=region_id,\n        Offset=(page_number - 1) * page_size,\n        Limit=page_size\n    )\n    res = make_tencent_request(ak, ac, 'cvm.tencentcloudapi.com', params)\n    if 'InstanceSet' not in res['Response']:\n        raise Exception(res)\n    for item in res['Response']['InstanceSet']:\n        data_disks = list(map(lambda x: x['DiskSize'], item['DataDisks']))\n        internet_charge_type = item['InternetAccessible']['InternetChargeType']\n        data.append(dict(\n            instance_id=item['InstanceId'],\n            instance_name=item['InstanceName'],\n            os_name=item['OsName'],\n            os_type=check_os_type(item['OsName']),\n            cpu=item['CPU'],\n            memory=item['Memory'],\n            disk=[item['SystemDisk']['DiskSize']] + data_disks,\n            created_time=parse_utc_date(item['CreatedTime']),\n            expired_time=parse_utc_date(item['ExpiredTime']),\n            instance_charge_type=check_instance_charge_type(item['InstanceChargeType'], 'tencent'),\n            internet_charge_type=check_internet_charge_type(internet_charge_type, 'tencent'),\n            public_ip_address=item['PublicIpAddresses'],\n            private_ip_address=item['PrivateIpAddresses'],\n            zone_id=item['Placement']['Zone']\n        ))\n    if len(res['Response']['InstanceSet']) == page_size:\n        page_number += 1\n        new_data = fetch_tencent_instances(ak, ac, region_id, page_number)\n        data.extend(new_data)\n    return data\n\n\ndef fetch_host_extend(ssh):\n    public_ip_address = set()\n    private_ip_address = set()\n    response = {'disk': []}\n    code, out = ssh.exec_command_raw('nproc')\n    if code != 0:\n        code, out = ssh.exec_command_raw(\"grep -c '^processor' /proc/cpuinfo\")\n    if code == 0:\n        response['cpu'] = int(out.strip())\n\n    code, out = ssh.exec_command_raw(\"cat /etc/os-release | grep PRETTY_NAME | awk -F \\\\\\\" '{print $2}'\")\n    if '/etc/os-release' in out:\n        code, out = ssh.exec_command_raw(\"cat /etc/issue | head -1 | awk '{print $1,$2,$3}'\")\n    if code == 0:\n        response['os_name'] = out.strip()[:50]\n\n    code, out = ssh.exec_command_raw('hostname -I')\n    if code == 0:\n        for ip in out.strip().split():\n            if len(ip) > 15:   # ignore ipv6\n                continue\n            if ipaddress.ip_address(ip).is_global:\n                if len(public_ip_address) < 10:\n                    public_ip_address.add(ip)\n            elif len(private_ip_address) < 10:\n                private_ip_address.add(ip)\n\n    ssh_hostname = ssh.arguments.get('hostname')\n    if ip_validator(ssh_hostname):\n        if ipaddress.ip_address(ssh_hostname).is_global:\n            if ssh_hostname in public_ip_address:\n                public_ip_address.remove(ssh_hostname)\n            public_ip_address = [ssh_hostname] + list(public_ip_address)\n        else:\n            if ssh_hostname in private_ip_address:\n                private_ip_address.remove(ssh_hostname)\n            private_ip_address = [ssh_hostname] + list(private_ip_address)\n\n    code, out = ssh.exec_command_raw('lsblk -dbn -o SIZE -e 11 2> /dev/null')\n    if code == 0:\n        disks = []\n        for item in out.strip().splitlines():\n            item = item.strip()\n            size = math.ceil(int(item) / 1024 / 1024 / 1024)\n            if size > 10:\n                disks.append(size)\n        response['disk'] = disks[:10]\n\n    code, out = ssh.exec_command_raw(\"dmidecode -t 17 | grep -E 'Size: [0-9]+' | awk '{s+=$2} END {print s,$3}'\")\n    if code == 0:\n        fields = out.strip().split()\n        if len(fields) == 2 and fields[1] in ('GB', 'MB'):\n            size, unit = out.strip().split()\n            if unit == 'GB':\n                response['memory'] = size\n            else:\n                response['memory'] = round(int(size) / 1024, 0)\n    if 'memory' not in response:\n        code, out = ssh.exec_command_raw(\"cat /proc/meminfo | grep 'MemTotal' | awk '{print $2}'\")\n        if code == 0:\n            response['memory'] = math.ceil(int(out) / 1024 / 1024)\n\n    response['public_ip_address'] = list(public_ip_address)\n    response['private_ip_address'] = list(private_ip_address)\n    return response\n\n\ndef batch_sync_host(token, hosts, password=None):\n    private_key, public_key = AppSetting.get_ssh_key()\n    threads, latest_exception, rds = [], None, get_redis_connection()\n    max_workers = max(10, os.cpu_count() * 5)\n    with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n        for host in hosts:\n            if hasattr(host, 'password'):\n                password = host.password\n            t = executor.submit(_sync_host_extend, host, private_key, public_key, password)\n            t.host = host\n            threads.append(t)\n        for t in futures.as_completed(threads):\n            exception = t.exception()\n            if exception:\n                rds.rpush(token, json.dumps({'key': t.host.id, 'status': 'fail', 'message': f'{exception}'}))\n            else:\n                rds.rpush(token, json.dumps({'key': t.host.id, 'status': 'ok'}))\n                t.host.is_verified = True\n                t.host.save()\n        rds.expire(token, 60)\n\n\ndef _sync_host_extend(host, private_key=None, public_key=None, password=None, ssh=None):\n    if not ssh:\n        kwargs = host.to_dict(selects=('hostname', 'port', 'username'))\n        with _get_ssh(kwargs, host.pkey, private_key, public_key, password) as ssh:\n            return _sync_host_extend(host, ssh=ssh)\n    form = AttrDict(fetch_host_extend(ssh))\n    form.disk = json.dumps(form.disk)\n    form.public_ip_address = json.dumps(form.public_ip_address)\n    form.private_ip_address = json.dumps(form.private_ip_address)\n    form.updated_at = human_datetime()\n    form.os_type = check_os_type(form.os_name)\n    if hasattr(host, 'hostextend'):\n        extend = host.hostextend\n        extend.update_by_dict(form)\n    else:\n        extend = HostExtend.objects.create(host=host, **form)\n    return extend\n\n\ndef _get_ssh(kwargs, pkey=None, private_key=None, public_key=None, password=None):\n    try:\n        ssh = SSH(pkey=pkey or private_key, **kwargs)\n        ssh.get_client()\n        return ssh\n    except AuthenticationException as e:\n        if password:\n            with SSH(password=str(password), **kwargs) as ssh:\n                ssh.add_public_key(public_key)\n            return _get_ssh(kwargs, private_key)\n        raise e\n"
  },
  {
    "path": "spug_api/apps/host/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom django.http.response import HttpResponseBadRequest\nfrom libs import json_response, JsonParser, Argument, AttrDict, auth\nfrom apps.setting.utils import AppSetting\nfrom apps.account.utils import get_host_perms\nfrom apps.host.models import Host, Group\nfrom apps.host.utils import batch_sync_host, _sync_host_extend\nfrom apps.exec.models import ExecTemplate\nfrom apps.app.models import Deploy\nfrom apps.schedule.models import Task\nfrom apps.monitor.models import Detection\nfrom libs.ssh import SSH, AuthenticationException\nfrom paramiko.ssh_exception import BadAuthenticationType\nfrom openpyxl import load_workbook\nfrom threading import Thread\nimport socket\nimport uuid\n\n\nclass HostView(View):\n    def get(self, request):\n        hosts = Host.objects.select_related('hostextend')\n        if not request.user.is_supper:\n            hosts = hosts.filter(id__in=get_host_perms(request.user))\n        hosts = {x.id: x.to_view() for x in hosts}\n        for rel in Group.hosts.through.objects.filter(host_id__in=hosts.keys()):\n            hosts[rel.host_id]['group_ids'].append(rel.group_id)\n        return json_response(list(hosts.values()))\n\n    @auth('host.host.add|host.host.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('group_ids', type=list, filter=lambda x: len(x), help='请选择主机分组'),\n            Argument('name', help='请输主机名称'),\n            Argument('username', handler=str.strip, help='请输入登录用户名'),\n            Argument('hostname', handler=str.strip, help='请输入主机名或IP'),\n            Argument('port', type=int, help='请输入SSH端口'),\n            Argument('pkey', required=False),\n            Argument('desc', required=False),\n            Argument('password', required=False),\n        ).parse(request.body)\n        if error is None:\n            if not _do_host_verify(form):\n                return json_response('auth fail')\n\n            group_ids = form.pop('group_ids')\n            other = Host.objects.filter(name=form.name).first()\n            if other and (not form.id or other.id != form.id):\n                return json_response(error=f'已存在的主机名称【{form.name}】')\n            if form.id:\n                Host.objects.filter(pk=form.id).update(is_verified=True, **form)\n                host = Host.objects.get(pk=form.id)\n            else:\n                host = Host.objects.create(created_by=request.user, is_verified=True, **form)\n            host.groups.set(group_ids)\n            response = host.to_view()\n            response['group_ids'] = group_ids\n            return json_response(response)\n        return json_response(error=error)\n\n    @auth('host.host.add|host.host.edit')\n    def put(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            host = Host.objects.get(pk=form.id)\n            with host.get_ssh() as ssh:\n                _sync_host_extend(host, ssh=ssh)\n        return json_response(error=error)\n\n    @auth('admin')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择主机'),\n            Argument('s_group_id', type=int, help='参数错误'),\n            Argument('t_group_id', type=int, help='参数错误'),\n            Argument('is_copy', type=bool, help='参数错误'),\n        ).parse(request.body)\n        if error is None:\n            if form.t_group_id == form.s_group_id:\n                return json_response(error='不能选择本分组的主机')\n            s_group = Group.objects.get(pk=form.s_group_id)\n            t_group = Group.objects.get(pk=form.t_group_id)\n            t_group.hosts.add(*form.host_ids)\n            if not form.is_copy:\n                s_group.hosts.remove(*form.host_ids)\n        return json_response(error=error)\n\n    @auth('host.host.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('group_id', type=int, required=False),\n        ).parse(request.GET)\n        if error is None:\n            if form.id:\n                host_ids = [form.id]\n            elif form.group_id:\n                group = Group.objects.get(pk=form.group_id)\n                host_ids = [x.id for x in group.hosts.all()]\n            else:\n                return json_response(error='参数错误')\n            for host_id in host_ids:\n                regex = fr'[^0-9]{host_id}[^0-9]'\n                deploy = Deploy.objects.filter(host_ids__regex=regex) \\\n                    .annotate(app_name=F('app__name'), env_name=F('env__name')).first()\n                if deploy:\n                    return json_response(error=f'应用【{deploy.app_name}】在【{deploy.env_name}】的发布配置关联了该主机，请解除关联后再尝试删除该主机')\n                task = Task.objects.filter(targets__regex=regex).first()\n                if task:\n                    return json_response(error=f'任务计划中的任务【{task.name}】关联了该主机，请解除关联后再尝试删除该主机')\n                detection = Detection.objects.filter(type__in=('3', '4'), targets__regex=regex).first()\n                if detection:\n                    return json_response(error=f'监控中心的任务【{detection.name}】关联了该主机，请解除关联后再尝试删除该主机')\n                tpl = ExecTemplate.objects.filter(host_ids__regex=regex).first()\n                if tpl:\n                    return json_response(error=f'执行模板【{tpl.name}】关联了该主机，请解除关联后再尝试删除该主机')\n            Host.objects.filter(id__in=host_ids).delete()\n        return json_response(error=error)\n\n\n@auth('host.host.add')\ndef post_import(request):\n    group_id = request.POST.get('group_id')\n    file = request.FILES['file']\n    hosts = []\n    ws = load_workbook(file, read_only=True)['Sheet1']\n    summary = {'fail': 0, 'success': 0, 'invalid': [], 'skip': [], 'repeat': []}\n    for i, row in enumerate(ws.rows, start=1):\n        if i == 1:  # 第1行是表头 略过\n            continue\n        if not all([row[x].value for x in range(4)]):\n            summary['invalid'].append(i)\n            summary['fail'] += 1\n            continue\n        data = AttrDict(\n            name=row[0].value,\n            hostname=row[1].value,\n            port=row[2].value,\n            username=row[3].value,\n            desc=row[5].value\n        )\n        if Host.objects.filter(hostname=data.hostname, port=data.port, username=data.username).exists():\n            summary['skip'].append(i)\n            summary['fail'] += 1\n            continue\n        if Host.objects.filter(name=data.name).exists():\n            summary['repeat'].append(i)\n            summary['fail'] += 1\n            continue\n        host = Host.objects.create(created_by=request.user, **data)\n        host.groups.add(group_id)\n        summary['success'] += 1\n        host.password = row[4].value\n        hosts.append(host)\n    token = uuid.uuid4().hex\n    if hosts:\n        Thread(target=batch_sync_host, args=(token, hosts)).start()\n    return json_response({'summary': summary, 'token': token, 'hosts': {x.id: {'name': x.name} for x in hosts}})\n\n\n@auth('host.host.add')\ndef post_parse(request):\n    file = request.FILES['file']\n    if file:\n        data = file.read()\n        return json_response(data.decode())\n    else:\n        return HttpResponseBadRequest()\n\n\n@auth('host.host.add')\ndef batch_valid(request):\n    form, error = JsonParser(\n        Argument('password', required=False),\n        Argument('range', filter=lambda x: x in ('1', '2'), help='参数错误')\n    ).parse(request.body)\n    if error is None:\n        if form.range == '1':  # all hosts\n            hosts = Host.objects.all()\n        else:\n            hosts = Host.objects.filter(is_verified=False).all()\n        token = uuid.uuid4().hex\n        Thread(target=batch_sync_host, args=(token, hosts, form.password)).start()\n        return json_response({'token': token, 'hosts': {x.id: {'name': x.name} for x in hosts}})\n    return json_response(error=error)\n\n\ndef _do_host_verify(form):\n    password = form.pop('password')\n    if form.pkey:\n        try:\n            with SSH(form.hostname, form.port, form.username, form.pkey) as ssh:\n                ssh.ping()\n            return True\n        except BadAuthenticationType:\n            raise Exception('该主机不支持密钥认证，请参考官方文档，错误代码：E01')\n        except AuthenticationException:\n            raise Exception('上传的独立密钥认证失败，请检查该密钥是否能正常连接主机（推荐使用全局密钥）')\n        except socket.timeout:\n            raise Exception('连接主机超时，请检查网络')\n\n    private_key, public_key = AppSetting.get_ssh_key()\n    if password:\n        try:\n            with SSH(form.hostname, form.port, form.username, password=password) as ssh:\n                ssh.add_public_key(public_key)\n        except BadAuthenticationType:\n            raise Exception('该主机不支持密码认证，请参考官方文档，错误代码：E00')\n        except AuthenticationException:\n            raise Exception('密码连接认证失败，请检查密码是否正确')\n        except socket.timeout:\n            raise Exception('连接主机超时，请检查网络')\n\n    try:\n        with SSH(form.hostname, form.port, form.username, private_key) as ssh:\n            ssh.ping()\n    except BadAuthenticationType:\n        raise Exception('该主机不支持密钥认证，请参考官方文档，错误代码：E01')\n    except AuthenticationException:\n        if password:\n            raise Exception('密钥认证失败，请参考官方文档，错误代码：E02')\n        return False\n    except socket.timeout:\n        raise Exception('连接主机超时，请检查网络')\n    return True\n"
  },
  {
    "path": "spug_api/apps/monitor/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/monitor/executors.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django_redis import get_redis_connection\nfrom apps.host.models import Host\nfrom apps.monitor.utils import handle_notify\nfrom socket import socket\nimport subprocess\nimport platform\nimport requests\nimport logging\nimport json\nimport time\nimport re\n\nlogging.captureWarnings(True)\nregex = re.compile(r'Failed to establish a new connection: (.*)\\'\\)+')\n\n\ndef site_check(url, limit):\n    try:\n        res = requests.get(url, timeout=30)\n        if limit:\n            duration = int(res.elapsed.total_seconds() * 1000)\n            if duration > int(limit):\n                return False, f'响应时间 {duration}ms 大于 {limit}ms'\n        return 200 <= res.status_code < 400, f'返回HTTP状态码 {res.status_code}'\n    except Exception as e:\n        error = e.__str__()\n        exps = re.findall(regex, error)\n        if exps:\n            error = exps[0]\n        return False, error\n\n\ndef port_check(addr, port):\n    try:\n        sock = socket()\n        sock.settimeout(5)\n        sock.connect((addr, int(port)))\n        sock.close()\n        return True, '端口状态检测正常'\n    except Exception as e:\n        return False, f'异常信息：{e}'\n\n\ndef ping_check(addr):\n    try:\n        if platform.system().lower() == 'windows':\n            command = f'ping -n 1 -w 3000 {addr}'\n        else:\n            command = f'ping -c 1 -W 3 {addr}'\n        task = subprocess.run(command, shell=True, stdout=subprocess.PIPE)\n        if task.returncode == 0:\n            return True, 'Ping检测正常'\n        else:\n            return False, 'Ping检测失败'\n    except Exception as e:\n        return False, f'异常信息：{e}'\n\n\ndef host_executor(host, command):\n    try:\n        with host.get_ssh() as ssh:\n            exit_code, out = ssh.exec_command_raw(command)\n        if exit_code == 0:\n            return True, out or '检测状态正常'\n        else:\n            return False, out or f'退出状态码：{exit_code}'\n    except Exception as e:\n        return False, f'异常信息：{e}'\n\n\ndef monitor_worker_handler(job):\n    task_id, tp, addr, extra, threshold, quiet = json.loads(job)\n    target = addr\n    if tp == '1':\n        is_ok, message = site_check(addr, extra)\n    elif tp == '2':\n        is_ok, message = port_check(addr, extra)\n    elif tp == '5':\n        is_ok, message = ping_check(addr)\n    elif tp not in ('3', '4'):\n        is_ok, message = False, f'invalid monitor type for {tp!r}'\n    else:\n        command = f'ps -ef|grep -v grep|grep {extra!r}' if tp == '3' else extra\n        host = Host.objects.filter(pk=addr).first()\n        if not host:\n            is_ok, message = False, f'unknown host id for {addr!r}'\n        else:\n            is_ok, message = host_executor(host, command)\n        target = f'{host.name}({host.hostname})'\n\n    rds, key, f_count, f_time = get_redis_connection(), f'spug:det:{task_id}', f'c_{addr}', f't_{addr}'\n    v_count, v_time = rds.hmget(key, f_count, f_time)\n    if is_ok:\n        if v_count:\n            rds.hdel(key, f_count, f_time)\n        if v_time:\n            logging.warning('send recovery notification')\n            handle_notify(task_id, target, is_ok, message, int(v_count) + 1)\n        return\n    v_count = rds.hincrby(key, f_count)\n    if v_count >= threshold:\n        if not v_time or int(time.time()) - int(v_time) >= quiet * 60:\n            rds.hset(key, f_time, int(time.time()))\n            logging.warning('send fault alarm notification')\n            handle_notify(task_id, target, is_ok, message, v_count)\n\n\ndef dispatch(tp, addr, extra):\n    if tp == '1':\n        return site_check(addr, extra)\n    elif tp == '2':\n        return port_check(addr, extra)\n    elif tp == '5':\n        return ping_check(addr)\n    elif tp == '3':\n        command = f'ps -ef|grep -v grep|grep {extra!r}'\n    elif tp == '4':\n        command = extra\n    else:\n        raise TypeError(f'invalid monitor type: {tp!r}')\n    host = Host.objects.filter(pk=addr).first()\n    return host_executor(host, command)\n"
  },
  {
    "path": "spug_api/apps/monitor/management/commands/runmonitor.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom apps.monitor.scheduler import Scheduler\nimport logging\n\nlogging.basicConfig(level=logging.WARNING, format='%(asctime)s %(message)s')\n\n\nclass Command(BaseCommand):\n    help = 'Start monitor process'\n\n    def handle(self, *args, **options):\n        s = Scheduler()\n        s.run()\n"
  },
  {
    "path": "spug_api/apps/monitor/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nimport json\n\n\nclass Detection(models.Model, ModelMixin):\n    TYPES = (\n        ('1', '站点检测'),\n        ('2', '端口检测'),\n        ('3', '进程检测'),\n        ('4', '自定义脚本'),\n        ('5', 'Ping检测'),\n    )\n    STATUS = (\n        (0, '正常'),\n        (1, '异常'),\n    )\n    name = models.CharField(max_length=50)\n    type = models.CharField(max_length=2, choices=TYPES)\n    group = models.CharField(max_length=255, null=True)\n    targets = models.TextField()\n    extra = models.TextField(null=True)\n    desc = models.CharField(max_length=255, null=True)\n    is_active = models.BooleanField(default=True)\n    rate = models.IntegerField(default=5)\n    threshold = models.IntegerField(default=3)\n    quiet = models.IntegerField(default=24 * 60)\n    fault_times = models.SmallIntegerField(default=0)\n    notify_mode = models.CharField(max_length=255)\n    notify_grp = models.CharField(max_length=255)\n    latest_run_time = models.CharField(max_length=20, null=True)\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n    updated_at = models.CharField(max_length=20, null=True)\n    updated_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['type_alias'] = self.get_type_display()\n        tmp['notify_mode'] = json.loads(self.notify_mode)\n        tmp['notify_grp'] = json.loads(self.notify_grp)\n        tmp['targets'] = json.loads(self.targets)\n        return tmp\n\n    def __repr__(self):\n        return '<Detection %r>' % self.name\n\n    class Meta:\n        db_table = 'detections'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/monitor/scheduler.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom apscheduler.executors.pool import ThreadPoolExecutor\nfrom apscheduler.triggers.interval import IntervalTrigger\nfrom django_redis import get_redis_connection\nfrom django.conf import settings\nfrom django.db import connections\nfrom django.db.utils import DatabaseError\nfrom apps.monitor.models import Detection\nfrom libs import AttrDict, human_datetime\nfrom datetime import datetime, timedelta\nfrom random import randint\nimport logging\nimport json\n\nMONITOR_WORKER_KEY = settings.MONITOR_WORKER_KEY\n\n\nclass Scheduler:\n    timezone = settings.TIME_ZONE\n\n    def __init__(self):\n        self.scheduler = BackgroundScheduler(timezone=self.timezone, executors={'default': ThreadPoolExecutor(30)})\n\n    def _dispatch(self, task_id, tp, targets, extra, threshold, quiet):\n        Detection.objects.filter(pk=task_id).update(latest_run_time=human_datetime())\n        rds_cli = get_redis_connection()\n        for t in json.loads(targets):\n            rds_cli.rpush(MONITOR_WORKER_KEY, json.dumps([task_id, tp, t, extra, threshold, quiet]))\n        connections.close_all()\n\n    def _init(self):\n        self.scheduler.start()\n        try:\n            for item in Detection.objects.filter(is_active=True):\n                now = datetime.now()\n                trigger = IntervalTrigger(minutes=int(item.rate), timezone=self.timezone)\n                self.scheduler.add_job(\n                    self._dispatch,\n                    trigger,\n                    id=str(item.id),\n                    args=(item.id, item.type, item.targets, item.extra, item.threshold, item.quiet),\n                    next_run_time=now + timedelta(seconds=randint(0, 60))\n                )\n            connections.close_all()\n        except DatabaseError:\n            pass\n\n    def run(self):\n        rds_cli = get_redis_connection()\n        self._init()\n        rds_cli.delete(settings.MONITOR_KEY)\n        logging.warning('Running monitor')\n        while True:\n            _, data = rds_cli.brpop(settings.MONITOR_KEY)\n            task = AttrDict(json.loads(data))\n            if task.action in ('add', 'modify'):\n                trigger = IntervalTrigger(minutes=int(task.rate), timezone=self.timezone)\n                self.scheduler.add_job(\n                    self._dispatch,\n                    trigger,\n                    id=str(task.id),\n                    args=(task.id, task.type, task.targets, task.extra, task.threshold, task.quiet),\n                    replace_existing=True\n                )\n            elif task.action == 'remove':\n                job = self.scheduler.get_job(str(task.id))\n                if job:\n                    job.remove()\n"
  },
  {
    "path": "spug_api/apps/monitor/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', DetectionView.as_view()),\n    path('overview/', get_overview),\n    path('test/', run_test),\n]\n"
  },
  {
    "path": "spug_api/apps/monitor/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import close_old_connections\nfrom apps.alarm.models import Alarm\nfrom apps.monitor.models import Detection\nfrom libs.spug import Notification\nimport json\n\n\ndef seconds_to_human(seconds):\n    text = ''\n    if seconds > 3600:\n        text = f'{int(seconds / 3600)}小时'\n        seconds = seconds % 3600\n    if seconds > 60:\n        text += f'{int(seconds / 60)}分钟'\n        seconds = seconds % 60\n    if seconds:\n        text += f'{seconds}秒'\n    return text\n\n\ndef _record_alarm(det, target, duration, status):\n    Alarm.objects.create(\n        name=det.name,\n        type=det.get_type_display(),\n        target=target,\n        status=status,\n        duration=duration,\n        notify_grp=det.notify_grp,\n        notify_mode=det.notify_mode)\n\n\ndef handle_notify(task_id, target, is_ok, out, fault_times):\n    close_old_connections()\n    det = Detection.objects.get(pk=task_id)\n    duration = seconds_to_human(det.rate * fault_times * 60)\n    event = '2' if is_ok else '1'\n    _record_alarm(det, target, duration, event)\n    grp = json.loads(det.notify_grp)\n    notify = Notification(grp, event, target, det.name, out, duration)\n    notify.dispatch_monitor(json.loads(det.notify_mode))\n"
  },
  {
    "path": "spug_api/apps/monitor/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.conf import settings\nfrom django_redis import get_redis_connection\nfrom libs import json_response, JsonParser, Argument, human_datetime, auth\nfrom apps.monitor.models import Detection\nfrom apps.monitor.executors import dispatch\nfrom apps.setting.utils import AppSetting\nfrom datetime import datetime\nimport json\n\n\nclass DetectionView(View):\n    @auth('dashboard.dashboard.view|monitor.monitor.view')\n    def get(self, request):\n        detections = Detection.objects.all()\n        groups = [x['group'] for x in detections.order_by('group').values('group').distinct()]\n        return json_response({'groups': groups, 'detections': [x.to_view() for x in detections]})\n\n    @auth('monitor.monitor.add|monitor.monitor.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('name', help='请输入任务名称'),\n            Argument('group', help='请选择任务分组'),\n            Argument('targets', type=list, filter=lambda x: len(x), help='请输入监控地址'),\n            Argument('type', filter=lambda x: x in dict(Detection.TYPES), help='请选择监控类型'),\n            Argument('extra', required=False),\n            Argument('desc', required=False),\n            Argument('rate', type=int, default=5),\n            Argument('threshold', type=int, default=3),\n            Argument('quiet', type=int, default=24 * 60),\n            Argument('notify_grp', type=list, help='请选择报警联系组'),\n            Argument('notify_mode', type=list, help='请选择报警方式'),\n        ).parse(request.body)\n        if error is None:\n            if set(form.notify_mode).intersection(['1', '2', '6']):\n                if not AppSetting.get_default('spug_push_key'):\n                    return json_response(error='报警方式微信、短信、电话需要配置推送服务（系统设置/推送服务设置），请配置后再启用该报警方式。')\n\n            form.targets = json.dumps(form.targets)\n            form.notify_grp = json.dumps(form.notify_grp)\n            form.notify_mode = json.dumps(form.notify_mode)\n            if form.id:\n                Detection.objects.filter(pk=form.id).update(\n                    updated_at=human_datetime(),\n                    updated_by=request.user,\n                    **form)\n                task = Detection.objects.filter(pk=form.id).first()\n                if task and task.is_active:\n                    form.action = 'modify'\n                    rds_cli = get_redis_connection()\n                    rds_cli.lpush(settings.MONITOR_KEY, json.dumps(form))\n            else:\n                dtt = Detection.objects.create(created_by=request.user, **form)\n                form.action = 'add'\n                form.id = dtt.id\n                rds_cli = get_redis_connection()\n                rds_cli.lpush(settings.MONITOR_KEY, json.dumps(form))\n        return json_response(error=error)\n\n    @auth('monitor.monitor.edit')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象'),\n            Argument('is_active', type=bool, required=False)\n        ).parse(request.body, True)\n        if error is None:\n            Detection.objects.filter(pk=form.id).update(**form)\n            if form.get('is_active') is not None:\n                if form.is_active:\n                    task = Detection.objects.filter(pk=form.id).first()\n                    message = {'id': form.id, 'action': 'add'}\n                    message.update(task.to_dict(selects=('targets', 'extra', 'rate', 'type', 'threshold', 'quiet')))\n                else:\n                    message = {'id': form.id, 'action': 'remove'}\n                rds_cli = get_redis_connection()\n                rds_cli.lpush(settings.MONITOR_KEY, json.dumps(message))\n        return json_response(error=error)\n\n    @auth('monitor.monitor.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            task = Detection.objects.filter(pk=form.id).first()\n            if task:\n                if task.is_active:\n                    return json_response(error='该监控项正在运行中，请先停止后再尝试删除')\n                task.delete()\n        return json_response(error=error)\n\n\n@auth('monitor.monitor.add|monitor.monitor.edit')\ndef run_test(request):\n    form, error = JsonParser(\n        Argument('type', help='请选择监控类型'),\n        Argument('targets', type=list, filter=lambda x: len(x), help='请输入监控地址'),\n        Argument('extra', required=False)\n    ).parse(request.body)\n    if error is None:\n        is_success, message = dispatch(form.type, form.targets[0], form.extra)\n        return json_response({'is_success': is_success, 'message': message})\n    return json_response(error=error)\n\n\n@auth('monitor.monitor.view')\ndef get_overview(request):\n    response = []\n    rds = get_redis_connection()\n    for item in Detection.objects.all():\n        data = {}\n        for key in json.loads(item.targets):\n            key = str(key)\n            data[key] = {\n                'id': f'{item.id}_{key}',\n                'group': item.group,\n                'name': item.name,\n                'type': item.get_type_display(),\n                'target': key,\n                'desc': item.desc,\n                'status': '0',\n                'latest_run_time': item.latest_run_time,\n            }\n            if item.is_active:\n                if item.latest_run_time:\n                    data[key]['status'] = '1'\n                else:\n                    data[key]['status'] = '10'\n        if item.is_active:\n            for key, val in rds.hgetall(f'spug:det:{item.id}').items():\n                prefix, key = key.decode().split('_', 1)\n                if key in data:\n                    val = int(val)\n                    if prefix == 'c':\n                        if data[key]['status'] == '1':\n                            data[key]['status'] = '2'\n                        data[key]['count'] = val\n                    elif prefix == 't':\n                        date = datetime.fromtimestamp(val).strftime('%Y-%m-%d %H:%M:%S')\n                        data[key].update(status='3', notified_at=date)\n        response.extend(list(data.values()))\n    return json_response(response)\n"
  },
  {
    "path": "spug_api/apps/notify/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/notify/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom django.core.cache import cache\nfrom libs import ModelMixin, human_datetime\nfrom libs.channel import Channel\nimport hashlib\n\n\nclass Notify(models.Model, ModelMixin):\n    TYPES = (\n        ('1', '通知'),\n        ('2', '待办'),\n    )\n    SOURCES = (\n        ('monitor', '监控中心'),\n        ('schedule', '任务计划'),\n        ('flag', '应用发布'),\n        ('alert', '系统警告'),\n    )\n    title = models.CharField(max_length=255)\n    source = models.CharField(max_length=10, choices=SOURCES)\n    type = models.CharField(max_length=2, choices=TYPES)\n    content = models.TextField(null=True)\n    unread = models.BooleanField(default=True)\n    link = models.CharField(max_length=255, null=True)\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n\n    @classmethod\n    def make_system_notify(cls, title, content):\n        cls._make_notify('alert', '1', title, content)\n\n    @classmethod\n    def make_monitor_notify(cls, title, content):\n        cls._make_notify('monitor', '1', title, content)\n\n    @classmethod\n    def make_schedule_notify(cls, title, content):\n        cls._make_notify('schedule', '1', title, content)\n\n    @classmethod\n    def make_deploy_notify(cls, title, content):\n        cls._make_notify('flag', '1', title, content)\n\n    @classmethod\n    def _make_notify(cls, source, type, title, content):\n        tmp_str = f'{source},{type},{title},{content}'\n        digest = hashlib.md5(tmp_str.encode()).hexdigest()\n        unique_key = f'spug:notify:{digest}'\n        if not cache.get(unique_key):   # 限制相同内容的发送频率\n            cache.set(unique_key, 1, 3600)\n            cls.objects.create(source=source, title=title, type=type, content=content)\n        Channel.send_notify(title, content)\n\n    def __repr__(self):\n        return '<Notify %r>' % self.title\n\n    class Meta:\n        db_table = 'notifies'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/notify/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', NotifyView.as_view()),\n]\n"
  },
  {
    "path": "spug_api/apps/notify/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom apps.notify.models import Notify\nfrom libs import json_response, JsonParser, Argument\n\n\nclass NotifyView(View):\n    def get(self, request):\n        notifies = Notify.objects.all()\n        return json_response(notifies)\n\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('ids', type=list, help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            Notify.objects.filter(id__in=form.ids).update(unread=False)\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/repository/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/repository/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom django.conf import settings\nfrom libs.mixins import ModelMixin\nfrom apps.app.models import App, Environment, Deploy\nfrom apps.account.models import User\nfrom datetime import datetime\nimport json\nimport os\n\n\nclass Repository(models.Model, ModelMixin):\n    STATUS = (\n        ('0', '未开始'),\n        ('1', '构建中'),\n        ('2', '失败'),\n        ('5', '成功'),\n    )\n    app = models.ForeignKey(App, on_delete=models.PROTECT)\n    env = models.ForeignKey(Environment, on_delete=models.PROTECT)\n    deploy = models.ForeignKey(Deploy, on_delete=models.PROTECT)\n    version = models.CharField(max_length=100)\n    spug_version = models.CharField(max_length=50)\n    remarks = models.CharField(max_length=255, null=True)\n    extra = models.TextField()\n    status = models.CharField(max_length=2, choices=STATUS, default='0')\n    created_at = models.DateTimeField(auto_now_add=True)\n    created_by = models.ForeignKey(User, on_delete=models.PROTECT)\n\n    @staticmethod\n    def make_spug_version(deploy_id):\n        return f'{deploy_id}_{datetime.now().strftime(\"%Y%m%d%H%M%S\")}'\n\n    def to_view(self):\n        tmp = self.to_dict()\n        tmp['extra'] = json.loads(self.extra)\n        tmp['status_alias'] = self.get_status_display()\n        if hasattr(self, 'app_name'):\n            tmp['app_name'] = self.app_name\n        if hasattr(self, 'env_name'):\n            tmp['env_name'] = self.env_name\n        if hasattr(self, 'created_by_user'):\n            tmp['created_by_user'] = self.created_by_user\n        return tmp\n\n    def delete(self, using=None, keep_parents=False):\n        super().delete(using, keep_parents)\n        try:\n            build_file = f'{self.spug_version}.tar.gz'\n            os.remove(os.path.join(settings.BUILD_DIR, build_file))\n        except FileNotFoundError:\n            pass\n\n    class Meta:\n        db_table = 'repositories'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/repository/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', RepositoryView.as_view()),\n    path('<int:r_id>/', get_detail),\n    path('request/', get_requests),\n]\n"
  },
  {
    "path": "spug_api/apps/repository/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django_redis import get_redis_connection\nfrom django.conf import settings\nfrom django.db import close_old_connections\nfrom libs.utils import AttrDict, human_time, render_str\nfrom apps.repository.models import Repository\nfrom apps.app.utils import fetch_repo\nfrom apps.config.utils import compose_configs\nfrom apps.deploy.helper import Helper\nimport json\nimport uuid\nimport os\n\nREPOS_DIR = settings.REPOS_DIR\nBUILD_DIR = settings.BUILD_DIR\n\n\ndef dispatch(rep: Repository, helper=None):\n    rep.status = '1'\n    alone_build = helper is None\n    if not helper:\n        rds = get_redis_connection()\n        rds_key = f'{settings.BUILD_KEY}:{rep.spug_version}'\n        helper = Helper.make(rds, rds_key)\n        rep.save()\n    try:\n        api_token = uuid.uuid4().hex\n        helper.rds.setex(api_token, 60 * 60, f'{rep.app_id},{rep.env_id}')\n        helper.send_info('local', f'\\033[32m完成√\\033[0m\\r\\n{human_time()} 构建准备...        ')\n        env = AttrDict(\n            SPUG_APP_NAME=rep.app.name,\n            SPUG_APP_KEY=rep.app.key,\n            SPUG_APP_ID=str(rep.app_id),\n            SPUG_DEPLOY_ID=str(rep.deploy_id),\n            SPUG_BUILD_ID=str(rep.id),\n            SPUG_ENV_ID=str(rep.env_id),\n            SPUG_ENV_KEY=rep.env.key,\n            SPUG_VERSION=rep.version,\n            SPUG_BUILD_VERSION=rep.spug_version,\n            SPUG_API_TOKEN=api_token,\n            SPUG_REPOS_DIR=REPOS_DIR,\n        )\n        # append configs\n        configs = compose_configs(rep.app, rep.env_id)\n        configs_env = {f'_SPUG_{k.upper()}': v for k, v in configs.items()}\n        env.update(configs_env)\n\n        _build(rep, helper, env)\n        rep.status = '5'\n    except Exception as e:\n        rep.status = '2'\n        raise e\n    finally:\n        helper.local(f'cd {REPOS_DIR} && rm -rf {rep.spug_version}')\n        close_old_connections()\n        if alone_build:\n            helper.clear()\n            rep.save()\n            return rep\n        elif rep.status == '5':\n            rep.save()\n\n\ndef _build(rep: Repository, helper, env):\n    extend = rep.deploy.extend_obj\n    extras = json.loads(rep.extra)\n    git_dir = os.path.join(REPOS_DIR, str(rep.deploy_id))\n    build_dir = os.path.join(REPOS_DIR, rep.spug_version)\n    tar_file = os.path.join(BUILD_DIR, f'{rep.spug_version}.tar.gz')\n    if extras[0] == 'branch':\n        tree_ish = extras[2]\n        env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2])\n    else:\n        tree_ish = extras[1]\n        env.update(SPUG_GIT_TAG=extras[1])\n    env.update(SPUG_DST_DIR=render_str(extend.dst_dir, env))\n    fetch_repo(rep.deploy_id, extend.git_repo)\n    helper.send_info('local', '\\033[32m完成√\\033[0m\\r\\n')\n\n    if extend.hook_pre_server:\n        helper.send_step('local', 1, f'{human_time()} 检出前任务...\\r\\n')\n        helper.local(f'cd {git_dir} && {extend.hook_pre_server}', env)\n\n    helper.send_step('local', 2, f'{human_time()} 执行检出...        ')\n    command = f'cd {git_dir} && git archive --prefix={rep.spug_version}/ {tree_ish} | (cd .. && tar xf -)'\n    helper.local(command)\n    helper.send_info('local', '\\033[32m完成√\\033[0m\\r\\n')\n\n    if extend.hook_post_server:\n        helper.send_step('local', 3, f'{human_time()} 检出后任务...\\r\\n')\n        helper.local(f'cd {build_dir} && {extend.hook_post_server}', env)\n\n    helper.send_step('local', 4, f'\\r\\n{human_time()} 执行打包...        ')\n    filter_rule, exclude, contain = json.loads(extend.filter_rule), '', rep.spug_version\n    files = helper.parse_filter_rule(filter_rule['data'], env=env)\n    if files:\n        if filter_rule['type'] == 'exclude':\n            excludes = []\n            for x in files:\n                if x.startswith('/'):\n                    excludes.append(f'--exclude={rep.spug_version}{x}')\n                else:\n                    excludes.append(f'--exclude={x}')\n            exclude = ' '.join(excludes)\n        else:\n            contain = ' '.join(f'{rep.spug_version}/{x}' for x in files)\n    helper.local(f'mkdir -p {BUILD_DIR} && cd {REPOS_DIR} && tar zcf {tar_file} {exclude} {contain}')\n    helper.send_step('local', 5, f'\\033[32m完成√\\033[0m')\n    helper.send_step('local', 100, f'\\r\\n\\r\\n{human_time()} ** \\033[32m构建成功\\033[0m **')\n"
  },
  {
    "path": "spug_api/apps/repository/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django.db.models import F\nfrom django.conf import settings\nfrom django_redis import get_redis_connection\nfrom libs import json_response, JsonParser, Argument, human_time, AttrDict, auth\nfrom apps.repository.models import Repository\nfrom apps.deploy.models import DeployRequest\nfrom apps.repository.utils import dispatch\nfrom apps.app.models import Deploy\nfrom threading import Thread\nimport json\n\n\nclass RepositoryView(View):\n    @auth('deploy.repository.view|deploy.request.add|deploy.request.edit')\n    def get(self, request):\n        apps = request.user.deploy_perms['apps']\n        deploy_id = request.GET.get('deploy_id')\n        data = Repository.objects.filter(app_id__in=apps).annotate(\n            app_name=F('app__name'),\n            env_name=F('env__name'),\n            created_by_user=F('created_by__nickname'))\n        if deploy_id:\n            data = data.filter(deploy_id=deploy_id, status='5')\n            return json_response([x.to_view() for x in data])\n\n        response = dict()\n        for item in data:\n            if item.app_id in response:\n                response[item.app_id]['child'].append(item.to_view())\n            else:\n                tmp = item.to_view()\n                tmp['child'] = [item.to_view()]\n                response[item.app_id] = tmp\n        return json_response(list(response.values()))\n\n    @auth('deploy.repository.add')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('deploy_id', type=int, help='参数错误'),\n            Argument('version', help='请输入构建版本'),\n            Argument('extra', type=list, help='参数错误'),\n            Argument('remarks', required=False)\n        ).parse(request.body)\n        if error is None:\n            deploy = Deploy.objects.filter(pk=form.deploy_id).first()\n            if not deploy:\n                return json_response(error='未找到指定发布配置')\n            form.extra = json.dumps(form.extra)\n            form.spug_version = Repository.make_spug_version(deploy.id)\n            rep = Repository.objects.create(\n                app_id=deploy.app_id,\n                env_id=deploy.env_id,\n                created_by=request.user,\n                **form)\n            Thread(target=dispatch, args=(rep,)).start()\n            return json_response(rep.to_view())\n        return json_response(error=error)\n\n    @auth('deploy.repository.add|deploy.repository.build')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='参数错误'),\n            Argument('action', help='参数错误')\n        ).parse(request.body)\n        if error is None:\n            rep = Repository.objects.filter(pk=form.id).first()\n            if not rep:\n                return json_response(error='未找到指定构建记录')\n            if form.action == 'rebuild':\n                Thread(target=dispatch, args=(rep,)).start()\n                return json_response(rep.to_view())\n        return json_response(error=error)\n\n    @auth('deploy.repository.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            repository = Repository.objects.filter(pk=form.id).first()\n            if not repository:\n                return json_response(error='未找到指定构建记录')\n            if repository.deployrequest_set.exists():\n                return json_response(error='已关联发布申请无法删除')\n            repository.delete()\n        return json_response(error=error)\n\n\n@auth('deploy.repository.view')\ndef get_requests(request):\n    form, error = JsonParser(\n        Argument('repository_id', type=int, help='参数错误')\n    ).parse(request.GET)\n    if error is None:\n        requests = []\n        for item in DeployRequest.objects.filter(repository_id=form.repository_id):\n            data = item.to_dict(selects=('id', 'name', 'created_at'))\n            data['host_ids'] = json.loads(item.host_ids)\n            data['status_alias'] = item.get_status_display()\n            requests.append(data)\n        return json_response(requests)\n\n\n@auth('deploy.repository.view')\ndef get_detail(request, r_id):\n    repository = Repository.objects.filter(pk=r_id).first()\n    if not repository:\n        return json_response(error='未找到指定构建记录')\n    rds, counter = get_redis_connection(), 0\n    if repository.remarks == 'SPUG AUTO MAKE':\n        req = repository.deployrequest_set.last()\n        key = f'{settings.REQUEST_KEY}:{req.id}'\n    else:\n        key = f'{settings.BUILD_KEY}:{repository.spug_version}'\n    data = rds.lrange(key, counter, counter + 9)\n    response = AttrDict(data='', step=0, s_status='process', status=repository.status)\n    while data:\n        for item in data:\n            counter += 1\n            item = json.loads(item.decode())\n            if item['key'] == 'local':\n                if 'data' in item:\n                    response.data += item['data']\n                if 'step' in item:\n                    response.step = item['step']\n                if 'status' in item:\n                    response.status = item['status']\n        data = rds.lrange(key, counter, counter + 9)\n    response.index = counter\n    if repository.status in ('0', '1'):\n        response.data = f'{human_time()} 建立连接...        ' + response.data\n    elif not response.data:\n        response.data = f'{human_time()} 读取数据...        \\r\\n\\r\\n未读取到数据，Spug 仅保存最近2周的构建日志。'\n    else:\n        response.data = f'{human_time()} 读取数据...        ' + response.data\n    return json_response(response)\n"
  },
  {
    "path": "spug_api/apps/schedule/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/schedule/builtin.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import connections\nfrom django.conf import settings\nfrom apps.account.models import History, User\nfrom apps.alarm.models import Alarm\nfrom apps.schedule.models import Task, History as TaskHistory\nfrom apps.deploy.models import DeployRequest\nfrom apps.app.models import DeployExtend1\nfrom apps.exec.models import ExecHistory, Transfer\nfrom apps.notify.models import Notify\nfrom apps.deploy.utils import dispatch\nfrom apps.repository.models import Repository\nfrom libs.utils import parse_time, human_datetime, human_date\nfrom datetime import datetime, timedelta\nfrom threading import Thread\nfrom collections import defaultdict\nfrom pathlib import Path\nimport time\nimport os\n\n\ndef auto_run_by_day():\n    try:\n        date_7 = human_date(datetime.now() - timedelta(days=7))\n        date_30 = human_date(datetime.now() - timedelta(days=30))\n        History.objects.filter(created_at__lt=date_30).delete()\n        Notify.objects.filter(created_at__lt=date_7, unread=False).delete()\n        Alarm.objects.filter(created_at__lt=date_30).delete()\n        for item in DeployExtend1.objects.all():\n            index = 0\n            for req in DeployRequest.objects.filter(deploy_id=item.deploy_id, repository_id__isnull=False):\n                if index > item.versions and req.repository_id:\n                    req.repository.delete()\n                index += 1\n\n        timer = defaultdict(int)\n        for item in ExecHistory.objects.all():\n            if timer[item.user_id] >= 10:\n                item.delete()\n            else:\n                timer[item.user_id] += 1\n\n        timer = defaultdict(int)\n        for item in Transfer.objects.all():\n            if timer[item.user_id] >= 10:\n                item.delete()\n            else:\n                timer[item.user_id] += 1\n\n        for task in Task.objects.all():\n            try:\n                record = TaskHistory.objects.filter(task_id=task.id)[50]\n                TaskHistory.objects.filter(task_id=task.id, id__lt=record.id).delete()\n            except IndexError:\n                pass\n\n        timestamp = time.time() - 2 * 3600\n        for item in Path(settings.TRANSFER_DIR).iterdir():\n            if item.name != '.gitkeep':\n                if item.stat().st_atime < timestamp:\n                    transfer_dir = item.absolute()\n                    os.system(f'umount -f {transfer_dir} &> /dev/null ; rm -rf {transfer_dir}')\n    finally:\n        connections.close_all()\n\n\ndef auto_run_by_minute():\n    try:\n        now = datetime.now()\n        for req in DeployRequest.objects.filter(status='2'):\n            if (now - parse_time(req.do_at)).seconds > 3600:\n                req.status = '-3'\n                req.save()\n\n        for rep in Repository.objects.filter(status='1'):\n            if (now - parse_time(rep.created_at)).seconds > 3600:\n                rep.status = '2'\n                rep.save()\n\n        for req in DeployRequest.objects.filter(status='1', plan__lte=now):\n            req.status = '2'\n            req.do_at = human_datetime()\n            req.do_by = req.created_by\n            req.save()\n            Thread(target=dispatch, args=(req,)).start()\n    finally:\n        connections.close_all()\n"
  },
  {
    "path": "spug_api/apps/schedule/executors.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom libs.ssh import AuthenticationException\nfrom django.db import close_old_connections, transaction\nfrom apps.host.models import Host\nfrom apps.schedule.models import History, Task\nfrom apps.schedule.utils import send_fail_notify\nimport subprocess\nimport socket\nimport time\nimport json\n\n\ndef local_executor(command):\n    code, out, now = 1, None, time.time()\n    task = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n    try:\n        code = task.wait(3600)\n        out = task.stdout.read() + task.stderr.read()\n        out = out.decode()\n    except subprocess.TimeoutExpired:\n        # task.kill()\n        out = 'timeout, wait more than 1 hour'\n    return code, round(time.time() - now, 3), out\n\n\ndef host_executor(host, command):\n    code, out, now = 1, None, time.time()\n    try:\n        with host.get_ssh() as ssh:\n            code, out = ssh.exec_command_raw(command)\n    except AuthenticationException:\n        out = 'ssh authentication fail'\n    except socket.error as e:\n        out = f'network error {e}'\n    return code, round(time.time() - now, 3), out\n\n\ndef dispatch_job(host_id, interpreter, command):\n    if interpreter == 'python':\n        attach = 'INTERPRETER=python\\ncommand -v python3 &> /dev/null && INTERPRETER=python3'\n        command = f'{attach}\\n$INTERPRETER << EOF\\n# -*- coding: UTF-8 -*-\\n{command}\\nEOF'\n    if host_id == 'local':\n        code, duration, out = local_executor(command)\n    else:\n        host = Host.objects.filter(pk=host_id).first()\n        if not host:\n            code, duration, out = 1, 0, f'unknown host id for {host_id!r}'\n        else:\n            code, duration, out = host_executor(host, command)\n    return code, duration, out\n\n\ndef schedule_worker_handler(job):\n    history_id, host_id, interpreter, command = json.loads(job)\n    code, duration, out = dispatch_job(host_id, interpreter, command)\n\n    close_old_connections()\n    with transaction.atomic():\n        history = History.objects.select_for_update().get(pk=history_id)\n        output = json.loads(history.output)\n        output[str(host_id)] = [code, duration, out]\n        history.output = json.dumps(output)\n        if all(output.values()):\n            history.status = '1' if sum(x[0] for x in output.values()) == 0 else '2'\n        history.save()\n    if history.status == '2':\n        task = Task.objects.get(pk=history.task_id)\n        send_fail_notify(task)\n"
  },
  {
    "path": "spug_api/apps/schedule/management/commands/runscheduler.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.core.management.base import BaseCommand\nfrom apps.schedule.scheduler import Scheduler\nimport logging\n\nlogging.basicConfig(level=logging.WARNING, format='%(asctime)s %(message)s')\n\n\nclass Command(BaseCommand):\n    help = 'Start schedule process'\n\n    def handle(self, *args, **options):\n        s = Scheduler()\n        s.run()\n"
  },
  {
    "path": "spug_api/apps/schedule/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom libs import ModelMixin, human_datetime\nfrom apps.account.models import User\nimport json\n\n\nclass History(models.Model, ModelMixin):\n    STATUS = (\n        (0, '执行中'),\n        (1, '成功'),\n        (2, '失败'),\n    )\n    task_id = models.IntegerField()\n    status = models.SmallIntegerField(choices=STATUS)\n    run_time = models.CharField(max_length=20)\n    output = models.TextField()\n\n    def to_list(self):\n        tmp = super().to_dict(selects=('id', 'status', 'run_time'))\n        tmp['status_alias'] = self.get_status_display()\n        return tmp\n\n    class Meta:\n        db_table = 'task_histories'\n        ordering = ('-id',)\n\n\nclass Task(models.Model, ModelMixin):\n    TRIGGERS = (\n        ('date', '一次性'),\n        ('calendarinterval', '日历间隔'),\n        ('cron', 'UNIX cron'),\n        ('interval', '普通间隔')\n    )\n    name = models.CharField(max_length=50)\n    type = models.CharField(max_length=50)\n    interpreter = models.CharField(max_length=20, default='sh')\n    command = models.TextField()\n    targets = models.TextField()\n    trigger = models.CharField(max_length=20, choices=TRIGGERS)\n    trigger_args = models.CharField(max_length=255)\n    is_active = models.BooleanField(default=False)\n    desc = models.CharField(max_length=255, null=True)\n    latest = models.ForeignKey(History, on_delete=models.PROTECT, null=True)\n    rst_notify = models.CharField(max_length=255, null=True)\n\n    created_at = models.CharField(max_length=20, default=human_datetime)\n    created_by = models.ForeignKey(User, models.PROTECT, related_name='+')\n    updated_at = models.CharField(max_length=20, null=True)\n    updated_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)\n\n    def to_dict(self, *args, **kwargs):\n        tmp = super().to_dict(*args, **kwargs)\n        tmp['targets'] = json.loads(self.targets)\n        tmp['latest_status'] = self.latest.status if self.latest else None\n        tmp['latest_run_time'] = self.latest.run_time if self.latest else None\n        tmp['latest_status_alias'] = self.latest.get_status_display() if self.latest else None\n        tmp['rst_notify'] = json.loads(self.rst_notify) if self.rst_notify else {'mode': '0'}\n        if self.trigger == 'cron':\n            tmp['trigger_args'] = json.loads(self.trigger_args)\n        return tmp\n\n    def __repr__(self):\n        return '<Task %r>' % self.name\n\n    class Meta:\n        db_table = 'tasks'\n        ordering = ('-id',)\n"
  },
  {
    "path": "spug_api/apps/schedule/scheduler.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom apscheduler.executors.pool import ThreadPoolExecutor\nfrom apscheduler.triggers.interval import IntervalTrigger\nfrom apscheduler.triggers.date import DateTrigger\nfrom apscheduler.triggers.cron import CronTrigger\nfrom django_redis import get_redis_connection\nfrom django.db import connections\nfrom django.db.utils import DatabaseError\nfrom apps.schedule.models import Task, History\nfrom apps.schedule.builtin import auto_run_by_day, auto_run_by_minute\nfrom django.conf import settings\nfrom libs import AttrDict, human_datetime\nimport logging\nimport json\n\nSCHEDULE_WORKER_KEY = settings.SCHEDULE_WORKER_KEY\n\n\nclass Scheduler:\n    timezone = settings.TIME_ZONE\n    week_map = {\n        '/': '/',\n        '-': '-',\n        ',': ',',\n        '*': '*',\n        '7': '6',\n        '0': '6',\n        '1': '0',\n        '2': '1',\n        '3': '2',\n        '4': '3',\n        '5': '4',\n        '6': '5',\n    }\n\n    def __init__(self):\n        self.scheduler = BackgroundScheduler(timezone=self.timezone, executors={'default': ThreadPoolExecutor(30)})\n\n    @classmethod\n    def covert_week(cls, week_str):\n        return ''.join(map(lambda x: cls.week_map[x], week_str))\n\n    @classmethod\n    def parse_trigger(cls, trigger, trigger_args):\n        if trigger == 'interval':\n            return IntervalTrigger(seconds=int(trigger_args), timezone=cls.timezone)\n        elif trigger == 'date':\n            return DateTrigger(run_date=trigger_args, timezone=cls.timezone)\n        elif trigger == 'cron':\n            args = json.loads(trigger_args) if not isinstance(trigger_args, dict) else trigger_args\n            minute, hour, day, month, week = args['rule'].split()\n            week = cls.covert_week(week)\n            return CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=week,\n                               start_date=args['start'], end_date=args['stop'])\n        else:\n            raise TypeError(f'unknown schedule policy: {trigger!r}')\n\n    def _init_builtin_jobs(self):\n        self.scheduler.add_job(auto_run_by_day, 'cron', hour=1, minute=20)\n        self.scheduler.add_job(auto_run_by_minute, 'interval', minutes=1)\n\n    def _dispatch(self, task_id, interpreter, command, targets):\n        output = {x: None for x in targets}\n        history = History.objects.create(\n            task_id=task_id,\n            status='0',\n            run_time=human_datetime(),\n            output=json.dumps(output)\n        )\n        Task.objects.filter(pk=task_id).update(latest_id=history.id)\n        rds_cli = get_redis_connection()\n        for t in targets:\n            rds_cli.rpush(SCHEDULE_WORKER_KEY, json.dumps([history.id, t, interpreter, command]))\n        connections.close_all()\n\n    def _init(self):\n        self.scheduler.start()\n        self._init_builtin_jobs()\n        try:\n            for task in Task.objects.filter(is_active=True):\n                trigger = self.parse_trigger(task.trigger, task.trigger_args)\n                self.scheduler.add_job(\n                    self._dispatch,\n                    trigger,\n                    id=str(task.id),\n                    args=(task.id, task.interpreter, task.command, json.loads(task.targets)),\n                )\n            connections.close_all()\n        except DatabaseError:\n            pass\n\n    def run(self):\n        rds_cli = get_redis_connection()\n        self._init()\n        rds_cli.delete(settings.SCHEDULE_KEY)\n        logging.warning('Running scheduler')\n        while True:\n            _, data = rds_cli.brpop(settings.SCHEDULE_KEY)\n            task = AttrDict(json.loads(data))\n            if task.action in ('add', 'modify'):\n                trigger = self.parse_trigger(task.trigger, task.trigger_args)\n                self.scheduler.add_job(\n                    self._dispatch,\n                    trigger,\n                    id=str(task.id),\n                    args=(task.id, task.interpreter, task.command, task.targets),\n                    replace_existing=True\n                )\n            elif task.action == 'remove':\n                job = self.scheduler.get_job(str(task.id))\n                if job:\n                    job.remove()\n"
  },
  {
    "path": "spug_api/apps/schedule/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\n\nfrom .views import *\n\nurlpatterns = [\n    path('', Schedule.as_view()),\n    path('<int:t_id>/', HistoryView.as_view()),\n    path('run_time/', next_run_time),\n]\n"
  },
  {
    "path": "spug_api/apps/schedule/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom libs.utils import human_datetime\nfrom libs.spug import Notification\nfrom libs.push import push_server\nfrom apps.setting.utils import AppSetting\nimport json\n\n\ndef send_fail_notify(task, msg=None):\n    rst_notify = json.loads(task.rst_notify)\n    mode = rst_notify.get('mode')\n    url = rst_notify.get('value')\n    if mode != '0' and url:\n        _do_notify(task, mode, url, msg)\n\n\ndef _do_notify(task, mode, url, msg):\n    if mode == '1':\n        texts = [\n            '## <font color=\"#f90202\">任务执行失败通知</font> ## ',\n            f'**任务名称：** {task.name} ',\n            f'**任务类型：** {task.type} ',\n            f'**描述信息：** {msg or \"请在任务计划执行历史中查看详情\"} ',\n            f'**发生时间：** {human_datetime()} ',\n            '> 来自 Spug运维平台'\n        ]\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'title': '任务执行失败通知',\n                'text': '\\n\\n'.join(texts)\n            },\n            'at': {\n                'isAtAll': True\n            }\n        }\n        Notification.handle_request(url, data, 'dd')\n    elif mode == '2':\n        data = {\n            'task_id': task.id,\n            'task_name': task.name,\n            'task_type': task.type,\n            'message': msg or '请在任务计划执行历史中查看详情',\n            'created_at': human_datetime()\n        }\n        Notification.handle_request(url, data)\n    elif mode == '3':\n        texts = [\n            '## <font color=\"warning\">任务执行失败通知</font>',\n            f'任务名称： {task.name}',\n            f'任务类型： {task.type}',\n            f'描述信息： {msg or \"请在任务计划执行历史中查看详情\"}',\n            f'发生时间： {human_datetime()}',\n            '> 来自 Spug运维平台'\n        ]\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'content': '\\n'.join(texts)\n            }\n        }\n        Notification.handle_request(url, data, 'wx')\n    elif mode == '4':\n        data = {\n            'msg_type': 'post',\n            'content': {\n                'post': {\n                    'zh_cn': {\n                        'title': '任务执行失败通知',\n                        'content': [\n                            [{'tag': 'text', 'text': f'任务名称： {task.name}'}],\n                            [{'tag': 'text', 'text': f'任务类型： {task.type}'}],\n                            [{'tag': 'text', 'text': f'描述信息： {msg or \"请在任务计划执行历史中查看详情\"}'}],\n                            [{'tag': 'text', 'text': f'发生时间： {human_datetime()}'}],\n                            [{'tag': 'at', 'user_id': 'all'}],\n                        ]\n                    }\n                }\n            }\n        }\n        Notification.handle_request(url, data, 'fs')\n    elif mode == '5':\n        spug_push_key = AppSetting.get_default('spug_push_key')\n        if not spug_push_key:\n            return\n        data = {\n            'source': 'schedule',\n            'token': spug_push_key,\n            'targets': url,\n            'dataset': {\n                'name': task.name,\n                'type': task.type,\n                'message': msg or '请在任务计划执行历史中查看详情',\n            }\n        }\n        Notification.handle_request(f'{push_server}/spug/message/', data, 'spug')\n"
  },
  {
    "path": "spug_api/apps/schedule/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom django_redis import get_redis_connection\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom apscheduler.triggers.cron import CronTrigger\nfrom apps.schedule.scheduler import Scheduler\nfrom apps.schedule.models import Task, History\nfrom apps.schedule.executors import dispatch_job\nfrom apps.host.models import Host\nfrom django.conf import settings\nfrom libs import json_response, JsonParser, Argument, human_datetime, auth\nimport json\n\n\nclass Schedule(View):\n    @auth('schedule.schedule.view')\n    def get(self, request):\n        tasks = Task.objects.all()\n        types = [x['type'] for x in tasks.order_by('type').values('type').distinct()]\n        return json_response({'types': types, 'tasks': [x.to_dict() for x in tasks]})\n\n    @auth('schedule.schedule.add|schedule.schedule.edit')\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, required=False),\n            Argument('type', help='请输入任务类型'),\n            Argument('name', help='请输入任务名称'),\n            Argument('interpreter', help='请选择执行解释器'),\n            Argument('command', help='请输入任务内容'),\n            Argument('rst_notify', type=dict, help='请选择执行失败通知方式'),\n            Argument('targets', type=list, filter=lambda x: len(x), help='请选择执行对象'),\n            Argument('trigger', filter=lambda x: x in dict(Task.TRIGGERS), help='请选择触发器类型'),\n            Argument('trigger_args', help='请输入触发器参数'),\n            Argument('desc', required=False),\n        ).parse(request.body)\n        if error is None:\n            form.targets = json.dumps(form.targets)\n            form.rst_notify = json.dumps(form.rst_notify)\n            if form.trigger == 'cron':\n                args = json.loads(form.trigger_args)['rule'].split()\n                if len(args) != 5:\n                    return json_response(error='无效的执行规则，请更正后再试')\n                minute, hour, day, month, week = args\n                week = '0' if week == '7' else week\n                try:\n                    CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=week)\n                except ValueError:\n                    return json_response(error='无效的执行规则，请更正后再试')\n            if form.id:\n                Task.objects.filter(pk=form.id).update(\n                    updated_at=human_datetime(),\n                    updated_by=request.user,\n                    **form\n                )\n                task = Task.objects.filter(pk=form.id).first()\n                if task and task.is_active:\n                    form.action = 'modify'\n                    form.targets = json.loads(form.targets)\n                    rds_cli = get_redis_connection()\n                    rds_cli.lpush(settings.SCHEDULE_KEY, json.dumps(form))\n            else:\n                Task.objects.create(created_by=request.user, **form)\n        return json_response(error=error)\n\n    @auth('schedule.schedule.edit')\n    def patch(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象'),\n            Argument('is_active', type=bool, required=False)\n        ).parse(request.body, True)\n        if error is None:\n            task = Task.objects.get(pk=form.id)\n            if form.get('is_active') is not None:\n                task.is_active = form.is_active\n                task.latest_id = None\n                if form.is_active:\n                    message = {'id': form.id, 'action': 'add'}\n                    message.update(task.to_dict(selects=('interpreter', 'trigger', 'trigger_args', 'command', 'targets')))\n                else:\n                    message = {'id': form.id, 'action': 'remove'}\n                rds_cli = get_redis_connection()\n                rds_cli.lpush(settings.SCHEDULE_KEY, json.dumps(message))\n            task.save()\n        return json_response(error=error)\n\n    @auth('schedule.schedule.del')\n    def delete(self, request):\n        form, error = JsonParser(\n            Argument('id', type=int, help='请指定操作对象')\n        ).parse(request.GET)\n        if error is None:\n            task = Task.objects.filter(pk=form.id).first()\n            if task:\n                if task.is_active:\n                    return json_response(error='该任务在运行中，请先停止任务再尝试删除')\n                task.delete()\n                History.objects.filter(task_id=task.id).delete()\n        return json_response(error=error)\n\n\nclass HistoryView(View):\n    @auth('schedule.schedule.view')\n    def get(self, request, t_id):\n        task = Task.objects.filter(pk=t_id).first()\n        if not task:\n            return json_response(error='未找到指定任务')\n\n        h_id = request.GET.get('id')\n        if h_id:\n            h_id = task.latest_id if h_id == 'latest' else h_id\n            return json_response(self._fetch_detail(h_id))\n        histories = History.objects.filter(task_id=t_id)\n        return json_response([x.to_list() for x in histories])\n\n    @auth('schedule.schedule.edit')\n    def post(self, request, t_id):\n        task = Task.objects.filter(pk=t_id).first()\n        if not task:\n            return json_response(error='未找到指定任务')\n        outputs, status = {}, 1\n        for host_id in json.loads(task.targets):\n            code, duration, out = dispatch_job(host_id, task.interpreter, task.command)\n            if code != 0:\n                status = 2\n            outputs[host_id] = [code, duration, out]\n\n        history = History.objects.create(\n            task_id=task.id,\n            status=status,\n            run_time=human_datetime(),\n            output=json.dumps(outputs)\n        )\n        return json_response(history.id)\n\n    def _fetch_detail(self, h_id):\n        record = History.objects.filter(pk=h_id).first()\n        outputs = json.loads(record.output)\n        host_ids = (x for x in outputs.keys() if x != 'local')\n        hosts_info = {str(x.id): x.name for x in Host.objects.filter(id__in=host_ids)}\n        data = {'run_time': record.run_time, 'success': 0, 'failure': 0, 'duration': 0, 'outputs': []}\n        for host_id, value in outputs.items():\n            if not value:\n                continue\n            code, duration, out = value\n            key = 'success' if code == 0 else 'failure'\n            data[key] += 1\n            data['duration'] += duration\n            data['outputs'].append({\n                'name': hosts_info.get(host_id, '本机'),\n                'code': code,\n                'duration': duration,\n                'output': out})\n        data['duration'] = f\"{data['duration'] / len(outputs):.3f}\"\n        return data\n\n\n@auth('schedule.schedule.view|schedule.schedule.add|schedule.schedule.edit')\ndef next_run_time(request):\n    form, error = JsonParser(\n        Argument('rule', help='参数错误'),\n        Argument('start', required=False),\n        Argument('stop', required=False)\n    ).parse(request.body)\n    if error is None:\n        try:\n            minute, hour, day, month, week = form.rule.split()\n            week = Scheduler.covert_week(week)\n            trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=week,\n                                  start_date=form.start, end_date=form.stop)\n        except (ValueError, KeyError):\n            return json_response({'success': False, 'msg': '无效的执行规则'})\n        scheduler = BackgroundScheduler(timezone=settings.TIME_ZONE)\n        scheduler.start()\n        job = scheduler.add_job(lambda: None, trigger)\n        run_time = job.next_run_time\n        scheduler.shutdown()\n        if run_time:\n            return json_response({'success': True, 'msg': run_time.strftime('%Y-%m-%d %H:%M:%S')})\n        else:\n            return json_response({'success': False, 'msg': '无法被触发'})\n    return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/setting/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/apps/setting/models.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import models\nfrom apps.account.models import User\nfrom libs import ModelMixin\nimport json\n\nKEYS_DEFAULT = {\n    'MFA': {'enable': False},\n    'verify_ip': True,\n    'bind_ip': True,\n    'ldap_service': {},\n    'spug_key': None,\n    'api_key': None,\n    'mail_service': {},\n    'private_key': None,\n    'public_key': None,\n    'spug_push_key': None,\n}\n\n\nclass Setting(models.Model, ModelMixin):\n    key = models.CharField(max_length=50, unique=True)\n    value = models.TextField()\n    desc = models.CharField(max_length=255, null=True)\n\n    def to_view(self):\n        tmp = self.to_dict(selects=('key',))\n        tmp['value'] = self.real_val\n        return tmp\n\n    @property\n    def real_val(self):\n        if self.value:\n            return json.loads(self.value)\n        else:\n            return KEYS_DEFAULT.get(self.key)\n\n    def __repr__(self):\n        return '<Setting %r>' % self.key\n\n    class Meta:\n        db_table = 'settings'\n\n\nclass UserSetting(models.Model, ModelMixin):\n    user = models.ForeignKey(User, on_delete=models.CASCADE)\n    key = models.CharField(max_length=32)\n    value = models.TextField()\n\n    class Meta:\n        db_table = 'user_settings'\n        unique_together = ('user', 'key')\n"
  },
  {
    "path": "spug_api/apps/setting/urls.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# from django.urls import path\nfrom django.conf.urls import url\nfrom apps.setting.views import *\nfrom apps.setting.user import UserSettingView\n\nurlpatterns = [\n    url(r'^$', SettingView.as_view()),\n    url(r'^user/$', UserSettingView.as_view()),\n    url(r'^ldap_test/$', ldap_test),\n    url(r'^email_test/$', email_test),\n    url(r'^mfa/$', MFAView.as_view()),\n    url(r'^about/$', get_about),\n    url(r'^push/bind/$', handle_push_bind),\n    url(r'^push/balance/$', handle_push_balance),\n]\n"
  },
  {
    "path": "spug_api/apps/setting/user.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom libs import JsonParser, Argument, json_response\nfrom apps.setting.models import UserSetting\n\n\nclass UserSettingView(View):\n    def get(self, request):\n        response = {}\n        for item in UserSetting.objects.filter(user=request.user):\n            response[item.key] = item.value\n        return json_response(response)\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('key', help='参数错误'),\n            Argument('value', help='参数错误'),\n        ).parse(request.body)\n        if error is None:\n            UserSetting.objects.update_or_create(\n                user=request.user,\n                key=form.key,\n                defaults={'value': form.value}\n            )\n            return self.get(request)\n        return json_response(error=error)\n"
  },
  {
    "path": "spug_api/apps/setting/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom functools import lru_cache\nfrom apps.setting.models import Setting, KEYS_DEFAULT\nfrom libs.ssh import SSH\nimport json\n\n\nclass AppSetting:\n    @classmethod\n    @lru_cache(maxsize=64)\n    def get(cls, key):\n        info = Setting.objects.filter(key=key).first()\n        if not info:\n            raise KeyError(f'no such key for {key!r}')\n        return info.real_val\n\n    @classmethod\n    def get_default(cls, key, default=None):\n        info = Setting.objects.filter(key=key).first()\n        if not info:\n            return default\n        return info.real_val\n\n    @classmethod\n    def set(cls, key, value, desc=None):\n        if key in KEYS_DEFAULT:\n            value = json.dumps(value)\n            Setting.objects.update_or_create(key=key, defaults={'value': value, 'desc': desc})\n        else:\n            raise KeyError('invalid key')\n\n    @classmethod\n    def delete(cls, key):\n        Setting.objects.filter(key=key).delete()\n\n    @classmethod\n    def get_ssh_key(cls):\n        public_key = cls.get_default('public_key')\n        private_key = cls.get_default('private_key')\n        if not private_key or not public_key:\n            private_key, public_key = SSH.generate_key()\n            cls.set('private_key', private_key)\n            cls.set('public_key', public_key)\n        return private_key, public_key\n"
  },
  {
    "path": "spug_api/apps/setting/views.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nimport django\nfrom django.core.cache import cache\nfrom django.conf import settings\nfrom libs import JsonParser, Argument, json_response, auth\nfrom libs.utils import generate_random_str\nfrom libs.mail import Mail\nfrom libs.push import get_balance, send_login_code\nfrom libs.mixins import AdminView\nfrom apps.setting.utils import AppSetting\nfrom apps.setting.models import Setting, KEYS_DEFAULT\nfrom copy import deepcopy\nimport platform\nimport ldap\n\n\nclass SettingView(AdminView):\n    def get(self, request):\n        response = deepcopy(KEYS_DEFAULT)\n        for item in Setting.objects.all():\n            if item.key == 'spug_push_key':\n                response[item.key] = f'{item.real_val[:8]}********{item.real_val[-8:]}'\n            else:\n                response[item.key] = item.real_val\n        return json_response(response)\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('data', type=list, help='缺少必要的参数')\n        ).parse(request.body)\n        if error is None:\n            for item in form.data:\n                AppSetting.set(**item)\n        return json_response(error=error)\n\n\nclass MFAView(AdminView):\n    def get(self, request):\n        if not request.user.wx_token:\n            return json_response(\n                error='检测到当前账户未配置推送标识（账户管理/编辑），请配置后再尝试启用MFA认证，否则可能造成系统无法正常登录。')\n        spug_push_key = AppSetting.get_default('spug_push_key')\n        if not spug_push_key:\n            return json_response(error='检测到当前账户未绑定推送服务，请在系统设置/推送服务设置中绑定推送助手账户。')\n        code = generate_random_str(6)\n        send_login_code(spug_push_key, request.user.wx_token, code)\n        cache.set(f'{request.user.username}:code', code, 300)\n        return json_response()\n\n    def post(self, request):\n        form, error = JsonParser(\n            Argument('enable', type=bool, help='参数错误'),\n            Argument('code', required=False)\n        ).parse(request.body)\n        if error is None:\n            if form.enable:\n                if not form.code:\n                    return json_response(error='请输入验证码')\n                key = f'{request.user.username}:code'\n                code = cache.get(key)\n                if not code:\n                    return json_response(error='验证码已失效，请重新获取')\n                if code != form.code:\n                    ttl = cache.ttl(key)\n                    cache.expire(key, ttl - 100)\n                    return json_response(error='验证码错误')\n                cache.delete(key)\n            AppSetting.set('MFA', {'enable': form.enable})\n        return json_response(error=error)\n\n\n@auth('admin')\ndef ldap_test(request):\n    form, error = JsonParser(\n        Argument('server'),\n        Argument('port', type=int),\n        Argument('admin_dn'),\n        Argument('password'),\n    ).parse(request.body)\n    if error is None:\n        try:\n            con = ldap.initialize(\"ldap://{0}:{1}\".format(form.server, form.port), bytes_mode=False)\n            con.simple_bind_s(form.admin_dn, form.password)\n            return json_response()\n        except Exception as e:\n            error = eval(str(e))\n            return json_response(error=error['desc'])\n    return json_response(error=error)\n\n\n@auth('admin')\ndef email_test(request):\n    form, error = JsonParser(\n        Argument('server', help='请输入邮件服务地址'),\n        Argument('port', type=int, help='请输入邮件服务端口号'),\n        Argument('username', help='请输入邮箱账号'),\n        Argument('password', help='请输入密码/授权码'),\n    ).parse(request.body)\n    if error is None:\n        try:\n            mail = Mail(**form)\n            server = mail.get_server()\n            server.quit()\n            return json_response()\n        except Exception as e:\n            error = f'{e}'\n    return json_response(error=error)\n\n\n@auth('admin')\ndef get_about(request):\n    return json_response({\n        'python_version': platform.python_version(),\n        'system_version': platform.platform(),\n        'spug_version': settings.SPUG_VERSION,\n        'django_version': django.get_version()\n    })\n\n\n@auth('admin')\ndef handle_push_bind(request):\n    form, error = JsonParser(\n        Argument('spug_push_key', required=False),\n    ).parse(request.body)\n    if error is None:\n        if not form.spug_push_key:\n            AppSetting.delete('spug_push_key')\n            return json_response()\n\n        try:\n            res = get_balance(form.spug_push_key)\n        except Exception as e:\n            return json_response(error=f'绑定失败：{e}')\n\n        AppSetting.set('spug_push_key', form.spug_push_key)\n        return json_response(res)\n    return json_response(error=error)\n\n\n@auth('admin')\ndef handle_push_balance(request):\n    token = AppSetting.get_default('spug_push_key')\n    if not token:\n        return json_response(error='请先配置推送服务绑定账户')\n    res = get_balance(token)\n    return json_response(res)\n"
  },
  {
    "path": "spug_api/consumer/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/consumer/consumers.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.conf import settings\nfrom django_redis import get_redis_connection\nfrom asgiref.sync import async_to_sync\nfrom apps.host.models import Host\nfrom consumer.utils import BaseConsumer\nfrom apps.account.utils import has_host_perm\nfrom libs.utils import str_decode\nfrom threading import Thread\nimport time\nimport json\n\n\nclass ComConsumer(BaseConsumer):\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        token = self.scope['url_route']['kwargs']['token']\n        module = self.scope['url_route']['kwargs']['module']\n        if module == 'build':\n            self.key = f'{settings.BUILD_KEY}:{token}'\n        elif module == 'request':\n            self.key = f'{settings.REQUEST_KEY}:{token}'\n        elif module == 'host':\n            self.key = token\n        else:\n            raise TypeError(f'unknown module for {module}')\n        self.rds = get_redis_connection()\n\n    def disconnect(self, code):\n        self.rds.close()\n\n    def get_response(self, index):\n        counter = 0\n        while counter < 30:\n            response = self.rds.lindex(self.key, index)\n            if response:\n                return response.decode()\n            counter += 1\n            time.sleep(0.2)\n\n    def receive(self, text_data='', **kwargs):\n        if text_data.isdigit():\n            index = int(text_data)\n            response = self.get_response(index)\n            while response:\n                index += 1\n                self.send(text_data=response)\n                response = self.get_response(index)\n        self.send(text_data='pong')\n\n\nclass SSHConsumer(BaseConsumer):\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.id = self.scope['url_route']['kwargs']['id']\n        self.chan = None\n        self.ssh = None\n\n    def loop_read(self):\n        is_ready, data = False, b''\n        while True:\n            out = self.chan.recv(32 * 1024)\n            if not out:\n                self.close(3333)\n                break\n            data += out\n            try:\n                text = data.decode()\n            except UnicodeDecodeError:\n                try:\n                    text = data.decode(encoding='GBK')\n                except UnicodeDecodeError:\n                    time.sleep(0.01)\n                    if self.chan.recv_ready():\n                        continue\n                    text = data.decode(errors='ignore')\n\n            if not is_ready:\n                self.send(text_data='\\033[2J\\033[3J\\033[1;1H')\n                is_ready = True\n            self.send(text_data=text)\n            data = b''\n\n    def receive(self, text_data=None, bytes_data=None):\n        data = text_data or bytes_data\n        if data and self.chan:\n            data = json.loads(data)\n            # print('write: {!r}'.format(data))\n            resize = data.get('resize')\n            if resize and len(resize) == 2:\n                self.chan.resize_pty(*resize)\n            else:\n                self.chan.send(data['data'])\n\n    def disconnect(self, code):\n        if self.chan:\n            self.chan.close()\n        if self.ssh:\n            self.ssh.close()\n\n    def init(self):\n        if has_host_perm(self.user, self.id):\n            self.send(text_data='\\r\\n正在连接至主机 ...')\n            host = Host.objects.filter(pk=self.id).first()\n            if not host:\n                return self.close_with_message('未找到指定主机，请刷新页面重试。')\n\n            try:\n                self.ssh = host.get_ssh().get_client()\n            except Exception as e:\n                return self.close_with_message(f'连接主机失败: {e}')\n\n            self.chan = self.ssh.invoke_shell(term='xterm')\n            self.chan.transport.set_keepalive(30)\n            Thread(target=self.loop_read).start()\n        else:\n            self.close_with_message('你当前无权限操作该主机，请联系管理员授权。')\n\n\nclass NotifyConsumer(BaseConsumer):\n    def init(self):\n        async_to_sync(self.channel_layer.group_add)('notify', self.channel_name)\n\n    def disconnect(self, code):\n        async_to_sync(self.channel_layer.group_discard)('notify', self.channel_name)\n\n    def receive(self, **kwargs):\n        self.send(text_data='pong')\n\n    def notify_message(self, event):\n        self.send(text_data=json.dumps(event))\n\n\nclass PubSubConsumer(BaseConsumer):\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.token = self.scope['url_route']['kwargs']['token']\n        self.rds = get_redis_connection()\n        self.p = self.rds.pubsub(ignore_subscribe_messages=True)\n        self.p.subscribe(self.token)\n\n    def disconnect(self, code):\n        self.p.close()\n        self.rds.close()\n\n    def receive(self, **kwargs):\n        response = self.p.get_message(timeout=10)\n        while response:\n            data = str_decode(response['data'])\n            self.send(text_data=data)\n            response = self.p.get_message(timeout=10)\n        self.send(text_data='pong')\n"
  },
  {
    "path": "spug_api/consumer/routing.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.urls import path\nfrom channels.routing import URLRouter\nfrom consumer.consumers import *\n\nws_router = URLRouter([\n    path('ws/ssh/<int:id>/', SSHConsumer),\n    path('ws/subscribe/<str:token>/', PubSubConsumer),\n    path('ws/<str:module>/<str:token>/', ComConsumer),\n    path('ws/notify/', NotifyConsumer),\n])\n"
  },
  {
    "path": "spug_api/consumer/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.db import close_old_connections\nfrom channels.generic.websocket import WebsocketConsumer\nfrom apps.account.models import User\nfrom apps.setting.utils import AppSetting\nfrom libs.utils import get_request_real_ip\nfrom urllib.parse import parse_qs\nimport time\n\n\ndef get_real_ip(headers):\n    decode_headers = {k.decode(): v.decode() for k, v in headers}\n    return get_request_real_ip(decode_headers)\n\n\nclass BaseConsumer(WebsocketConsumer):\n    def __init__(self, *args, **kwargs):\n        super(BaseConsumer, self).__init__(*args, **kwargs)\n        self.user = None\n\n    def close_with_message(self, content):\n        self.send(text_data=f'\\r\\n\\x1b[31m{content}\\x1b[0m\\r\\n')\n        self.close()\n\n    def connect(self):\n        self.accept()\n        close_old_connections()\n        query_string = self.scope['query_string'].decode()\n        x_real_ip = get_real_ip(self.scope['headers'])\n        token = parse_qs(query_string).get('x-token', [''])[0]\n        if token and len(token) == 32:\n            user = User.objects.filter(access_token=token).first()\n            if user and user.token_expired >= time.time() and user.is_active:\n                if x_real_ip == user.last_ip or AppSetting.get_default('bind_ip') is False:\n                    self.user = user\n                    if hasattr(self, 'init'):\n                        self.init()\n                    return None\n                self.close_with_message('触发登录IP绑定安全策略，请在系统设置/安全设置中查看配置。')\n        self.close_with_message('用户身份验证失败，请重新登录或刷新页面。')\n"
  },
  {
    "path": "spug_api/libs/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom .parser import JsonParser, Argument\nfrom .decorators import *\nfrom .validators import *\nfrom .mixins import *\nfrom .utils import *\n"
  },
  {
    "path": "spug_api/libs/channel.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom channels.layers import get_channel_layer\nfrom asgiref.sync import async_to_sync\nimport uuid\n\nlayer = get_channel_layer()\n\n\nclass Channel:\n    @staticmethod\n    def get_token():\n        return uuid.uuid4().hex\n\n    @staticmethod\n    def send_notify(title, content):\n        message = {\n            'type': 'notify.message',\n            'title': title,\n            'content': content\n        }\n        async_to_sync(layer.group_send)('notify', message)\n"
  },
  {
    "path": "spug_api/libs/decorators.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom functools import wraps\nfrom .utils import json_response\n\n\ndef auth(perm_list):\n    def decorate(view_func):\n        codes = perm_list.split('|')\n\n        @wraps(view_func)\n        def wrapper(*args, **kwargs):\n            user = None\n            for item in args[:2]:\n                if hasattr(item, 'user'):\n                    user = item.user\n                    break\n            if user and user.has_perms(codes):\n                return view_func(*args, **kwargs)\n            return json_response(error='权限拒绝')\n\n        return wrapper\n\n    return decorate\n"
  },
  {
    "path": "spug_api/libs/gitlib.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom git import Repo, RemoteReference, TagReference, InvalidGitRepositoryError, GitCommandError\nfrom tempfile import NamedTemporaryFile\nfrom datetime import datetime\nimport shutil\nimport os\n\n\nclass Git:\n    def __init__(self, git_repo, repo_dir, pkey=None):\n        self.git_repo = git_repo\n        self.repo_dir = repo_dir\n        self.repo = None\n        self.pkey = pkey\n        self.fd = None\n        self.env = {}\n\n    def archive(self, filepath, commit):\n        with open(filepath, 'wb') as f:\n            self.repo.archive(f, commit)\n\n    def fetch_branches_tags(self):\n        self.fetch()\n        branches, tags = {}, {}\n        for ref in self.repo.references:\n            if isinstance(ref, RemoteReference):\n                if ref.remote_head != 'HEAD':\n                    branches[ref.remote_head] = self._get_commits(f'origin/{ref.remote_head}', 30)\n            elif isinstance(ref, TagReference):\n                tags[ref.name] = {\n                    'id': ref.tag.hexsha,\n                    'author': ref.tag.tagger.name,\n                    'date': self._format_date(ref.tag.tagged_date),\n                    'message': ref.tag.message.strip()\n                } if ref.tag else {\n                    'id': ref.commit.binsha.hex(),\n                    'author': ref.commit.author.name,\n                    'date': self._format_date(ref.commit.authored_date),\n                    'message': ref.commit.message.strip()\n                }\n        tags = sorted(tags.items(), key=lambda x: x[1]['date'], reverse=True)\n        return branches, dict(tags)\n\n    def fetch(self):\n        kwargs = dict(f=True, p=True)\n        if self.repo.git.version_info >= (2, 17, 0):\n            kwargs.update(P=True)\n        try:\n            self.repo.remotes.origin.fetch(**kwargs)\n        except GitCommandError as e:\n            if self.env:\n                self.repo.remotes.origin.fetch(env=self.env, **kwargs)\n            else:\n                raise e\n\n    def _get_repo(self):\n        if os.path.exists(self.repo_dir):\n            try:\n                return Repo(self.repo_dir)\n            except InvalidGitRepositoryError:\n                if os.path.isdir(self.repo_dir):\n                    shutil.rmtree(self.repo_dir)\n                else:\n                    os.remove(self.repo_dir)\n        try:\n            repo = Repo.clone_from(self.git_repo, self.repo_dir)\n        except GitCommandError as e:\n            if self.env:\n                repo = Repo.clone_from(self.git_repo, self.repo_dir, env=self.env)\n            else:\n                raise e\n        return repo\n\n    def _get_commits(self, branch, count=10):\n        commits = []\n        for commit in self.repo.iter_commits(branch):\n            if len(commits) == count:\n                break\n            commits.append({\n                'id': commit.hexsha,\n                'author': commit.author.name,\n                'date': self._format_date(commit.committed_date),\n                'message': commit.message.strip()\n            })\n        return commits\n\n    def _format_date(self, timestamp):\n        if isinstance(timestamp, int):\n            date = datetime.fromtimestamp(timestamp)\n            return date.strftime('%Y-%m-%d %H:%M')\n        return timestamp\n\n    def __enter__(self):\n        if self.pkey:\n            self.fd = NamedTemporaryFile()\n            self.fd.write(self.pkey.encode())\n            self.fd.flush()\n            self.env = {'GIT_SSH_COMMAND': f'ssh -o StrictHostKeyChecking=no -i {self.fd.name}'}\n        self.repo = self._get_repo()\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        if self.fd:\n            self.fd.close()\n"
  },
  {
    "path": "spug_api/libs/helper.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom urllib.parse import quote, urlencode\nfrom datetime import datetime\nfrom pytz import timezone\nimport requests\nimport hashlib\nimport base64\nimport random\nimport time\nimport hmac\nimport uuid\n\n\ndef _special_url_encode(value) -> str:\n    if isinstance(value, (str, bytes)):\n        rst = quote(value)\n    else:\n        rst = urlencode(value)\n    return rst.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')\n\n\ndef _make_ali_signature(key: str, params: dict) -> bytes:\n    sorted_str = _special_url_encode(dict(sorted(params.items())))\n    sign_str = 'GET&%2F&' + _special_url_encode(sorted_str)\n    sign_digest = hmac.new(key.encode(), sign_str.encode(), hashlib.sha1).digest()\n    return base64.encodebytes(sign_digest).strip()\n\n\ndef _make_tencent_signature(endpoint: str, key: str, params: dict) -> bytes:\n    sorted_str = '&'.join(f'{k}={v}' for k, v in sorted(params.items()))\n    sign_str = f'POST{endpoint}/?{sorted_str}'\n    sign_digest = hmac.new(key.encode(), sign_str.encode(), hashlib.sha1).digest()\n    return base64.encodebytes(sign_digest).strip()\n\n\ndef make_ali_request(ak, ac, endpoint, params):\n    params.update(\n        AccessKeyId=ak,\n        Format='JSON',\n        SignatureMethod='HMAC-SHA1',\n        SignatureNonce=uuid.uuid4().hex,\n        SignatureVersion='1.0',\n        Timestamp=datetime.now(tz=timezone('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ'),\n        Version='2014-05-26'\n    )\n    params['Signature'] = _make_ali_signature(ac + '&', params)\n    return requests.get(endpoint, params).json()\n\n\ndef make_tencent_request(ak, ac, endpoint, params):\n    params.update(\n        Nonce=int(random.random() * 10000),\n        SecretId=ak,\n        Timestamp=int(time.time()),\n        Version='2017-03-12'\n    )\n    params['Signature'] = _make_tencent_signature(endpoint, ac, params)\n    return requests.post(f'https://{endpoint}', data=params).json()\n"
  },
  {
    "path": "spug_api/libs/ldap.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nimport ldap\n\n\nclass LDAP:\n    def __init__(self, server, port, rules, admin_dn, password, base_dn):\n        self.server = server\n        self.port = port\n        self.rules = rules\n        self.admin_dn = admin_dn\n        self.password = password\n        self.base_dn = base_dn\n\n    def valid_user(self, username, password):\n        try:\n            conn = ldap.initialize(\"ldap://{0}:{1}\".format(self.server, self.port), bytes_mode=False)\n            conn.simple_bind_s(self.admin_dn, self.password)\n            search_filter = f'({self.rules}={username})'\n            ldap_result_id = conn.search(self.base_dn, ldap.SCOPE_SUBTREE, search_filter, None)\n            result_type, result_data = conn.result(ldap_result_id, 0)\n            if result_type == ldap.RES_SEARCH_ENTRY:\n                conn.simple_bind_s(result_data[0][0], password)\n                return True, None\n            else:\n                return False, None\n        except Exception as error:\n            args = error.args\n            return False, args[0].get('desc', '未知错误') if args else '%s' % error\n"
  },
  {
    "path": "spug_api/libs/mail.py",
    "content": "from email.header import Header\nfrom email.mime.text import MIMEText\nfrom email.utils import formataddr\nimport smtplib\n\n\nclass Mail:\n    def __init__(self, server, port, username, password, nickname=None):\n        self.host = server\n        self.port = int(port)\n        self.user = username\n        self.password = password\n        self.nickname = nickname\n\n    def get_server(self):\n        if self.port == 465:\n            server = smtplib.SMTP_SSL(self.host, self.port)\n        elif self.port == 587:\n            server = smtplib.SMTP(self.host, self.port)\n            server.ehlo()\n            server.starttls()\n        else:\n            server = smtplib.SMTP(self.host, self.port)\n        server.login(self.user, self.password)\n        return server\n\n    def send_text_mail(self, receivers, subject, body):\n        server = self.get_server()\n        msg = MIMEText(body, 'plain', 'utf-8')\n        msg['Subject'] = Header(subject, 'utf-8')\n        msg['From'] = formataddr((self.nickname, self.user)) if self.nickname else self.user\n        server.sendmail(self.user, receivers, msg.as_string())\n        server.quit()\n"
  },
  {
    "path": "spug_api/libs/middleware.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.utils.deprecation import MiddlewareMixin\nfrom django.conf import settings\nfrom .utils import json_response, get_request_real_ip\nfrom apps.account.models import User\nfrom apps.setting.utils import AppSetting\nimport traceback\nimport time\n\n\nclass HandleExceptionMiddleware(MiddlewareMixin):\n    \"\"\"\n    处理试图函数异常\n    \"\"\"\n\n    def process_exception(self, request, exception):\n        traceback.print_exc()\n        return json_response(error='Exception: %s' % exception)\n\n\nclass AuthenticationMiddleware(MiddlewareMixin):\n    \"\"\"\n    登录验证\n    \"\"\"\n\n    def process_request(self, request):\n        if request.path in settings.AUTHENTICATION_EXCLUDES:\n            return None\n        if any(x.match(request.path) for x in settings.AUTHENTICATION_EXCLUDES if hasattr(x, 'match')):\n            return None\n        access_token = request.headers.get('x-token') or request.GET.get('x-token')\n        if access_token and len(access_token) == 32:\n            x_real_ip = get_request_real_ip(request.headers)\n            user = User.objects.filter(access_token=access_token).first()\n            if user and user.token_expired >= time.time() and user.is_active:\n                if x_real_ip == user.last_ip or AppSetting.get_default('bind_ip') is False:\n                    request.user = user\n                    user.token_expired = time.time() + settings.TOKEN_TTL\n                    user.save()\n                    return None\n        response = json_response(error=\"验证失败，请重新登录\")\n        response.status_code = 401\n        return response\n"
  },
  {
    "path": "spug_api/libs/mixins.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.views.generic import View\nfrom .utils import json_response\n\n\n# 混入类，提供Model实例to_dict方法\nclass ModelMixin(object):\n    __slots__ = ()\n\n    def to_dict(self, excludes: tuple = None, selects: tuple = None) -> dict:\n        if not hasattr(self, '_meta'):\n            raise TypeError('<%r> does not a django.db.models.Model object.' % self)\n        elif selects:\n            return {f: getattr(self, f) for f in selects}\n        elif excludes:\n            return {f.attname: getattr(self, f.attname) for f in self._meta.fields if f.attname not in excludes}\n        else:\n            return {f.attname: getattr(self, f.attname) for f in self._meta.fields}\n\n    def update_by_dict(self, data):\n        for key, value in data.items():\n            setattr(self, key, value)\n        self.save()\n\n\nclass AdminView(View):\n    def dispatch(self, request, *args, **kwargs):\n        if hasattr(request, 'user') and request.user.is_supper:\n            return super().dispatch(request, *args, **kwargs)\n        else:\n            return json_response(error='权限拒绝')\n"
  },
  {
    "path": "spug_api/libs/parser.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nimport json\n\nfrom .utils import AttrDict\n\n\n# 自定义的解析异常\nclass ParseError(BaseException):\n    def __init__(self, message):\n        self.message = message\n\n\n# 需要校验的参数对象\nclass Argument(object):\n    \"\"\"\n    :param name: name of option\n    :param default: default value if the argument if absent\n    :param bool required: is required\n    \"\"\"\n\n    def __init__(self, name, default=None, handler=None, required=True, type=str, filter=None, help=None):\n        self.name = name\n        self.default = default\n        self.type = type\n        self.required = required\n        self.filter = filter\n        self.help = help\n        self.handler = handler\n        if not isinstance(self.name, str):\n            raise TypeError('Argument name must be string')\n        if filter and not callable(self.filter):\n            raise TypeError('Argument filter is not callable')\n\n    def parse(self, has_key, value):\n        if not has_key:\n            if self.required and self.default is None:\n                raise ParseError(\n                    self.help or 'Required Error: %s is required' % self.name)\n            else:\n                return self.default\n        elif value in [u'', '', None]:\n            if self.default is not None:\n                return self.default\n            elif self.required:\n                raise ParseError(self.help or 'Value Error: %s must not be null' % self.name)\n            elif self.help:\n                raise ParseError(self.help)\n            else:\n                return value\n        try:\n            if self.type:\n                if self.type in (list, dict) and isinstance(value, str):\n                    value = json.loads(value)\n                    assert isinstance(value, self.type)\n                elif self.type == bool and isinstance(value, str):\n                    assert value.lower() in ['true', 'false']\n                    value = value.lower() == 'true'\n                elif not isinstance(value, self.type):\n                    value = self.type(value)\n        except (TypeError, ValueError, AssertionError):\n            raise ParseError(self.help or 'Type Error: %s type must be %s' % (\n                self.name, self.type))\n\n        if self.filter:\n            if not self.filter(value):\n                raise ParseError(\n                    self.help or 'Value Error: %s filter check failed' % self.name)\n        if self.handler:\n            value = self.handler(value)\n        return value\n\n\n# 解析器基类\nclass BaseParser(object):\n    def __init__(self, *args):\n        self.args = []\n        for e in args:\n            if isinstance(e, str):\n                e = Argument(e)\n            elif not isinstance(e, Argument):\n                raise TypeError('%r is not instance of Argument' % e)\n            self.args.append(e)\n\n    def _get(self, key):\n        raise NotImplementedError\n\n    def _init(self, data):\n        raise NotImplementedError\n\n    def add_argument(self, **kwargs):\n        self.args.append(Argument(**kwargs))\n\n    def parse(self, data=None, clear=False):\n        rst = AttrDict()\n        try:\n            self._init(data)\n            for e in self.args:\n                has_key, value = self._get(e.name)\n                if clear and has_key is False and e.required is False:\n                    continue\n                rst[e.name] = e.parse(has_key, value)\n        except ParseError as err:\n            return None, err.message\n        return rst, None\n\n\n# Json解析器\nclass JsonParser(BaseParser):\n    def __init__(self, *args):\n        self.__data = None\n        super(JsonParser, self).__init__(*args)\n\n    def _get(self, key):\n        return key in self.__data, self.__data.get(key)\n\n    def _init(self, data):\n        try:\n            if isinstance(data, (str, bytes)):\n                self.__data = json.loads(data) if data else {}\n            else:\n                assert hasattr(data, '__contains__')\n                assert hasattr(data, 'get')\n                assert callable(data.get)\n                self.__data = data\n        except (ValueError, AssertionError):\n            raise ParseError('Invalid data type for parse')\n"
  },
  {
    "path": "spug_api/libs/push.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apps.setting.utils import AppSetting\nimport requests\n\npush_server = 'https://push.spug.cc'\n\n\ndef get_balance(token):\n    res = requests.get(f'{push_server}/spug/balance/', json={'token': token})\n    if res.status_code != 200:\n        raise Exception(f'status code: {res.status_code}')\n    res = res.json()\n    if res.get('error'):\n        raise Exception(res['error'])\n    return res['data']\n\n\ndef get_contacts(token):\n    try:\n        res = requests.post(f'{push_server}/spug/contacts/', json={'token': token})\n        res = res.json()\n        if res['data']:\n            return res['data']\n    except Exception:\n        return []\n\n\ndef send_login_code(token, user, code):\n    url = f'{push_server}/spug/message/'\n    data = {\n        'token': token,\n        'targets': [user],\n        'source': 'mfa',\n        'dataset': {\n            'code': code\n        }\n    }\n    res = requests.post(url, json=data, timeout=15)\n    if res.status_code != 200:\n        raise Exception(f'status code: {res.status_code}')\n    res = res.json()\n    if res.get('error'):\n        raise Exception(res['error'])\n"
  },
  {
    "path": "spug_api/libs/spug.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom apps.alarm.models import Group, Contact\nfrom apps.setting.utils import AppSetting\nfrom apps.notify.models import Notify\nfrom libs.mail import Mail\nfrom libs.utils import human_datetime\nfrom libs.push import push_server\nimport requests\nimport json\nimport time\nimport hmac\nimport hashlib\nimport base64\nfrom urllib.parse import urlencode\n\n\ndef _gen_dd_sign(secret):\n    timestamp = str(int(time.time() * 1000))\n    string_to_sign = f'{timestamp}\\n{secret}'\n    hmac_code = hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest()\n    sign = base64.b64encode(hmac_code).decode('utf-8')\n    return timestamp, sign\n\n\ndef _gen_fs_sign(secret):\n    timestamp = str(int(time.time()))\n    string_to_sign = f'{timestamp}\\n{secret}'\n    hmac_code = hmac.new(string_to_sign.encode('utf-8'), b'', digestmod=hashlib.sha256).digest()\n    sign = base64.b64encode(hmac_code).decode('utf-8')\n    return timestamp, sign\n\n\nclass Notification:\n    def __init__(self, grp, event, target, title, message, duration):\n        self.grp = grp\n        self.event = event\n        self.title = title\n        self.target = target\n        self.message = message\n        self.duration = duration\n        self.spug_push_key = AppSetting.get_default('spug_push_key')\n\n    @staticmethod\n    def handle_request(url, data, mode=None):\n        try:\n            res = requests.post(url, json=data, timeout=15)\n        except Exception as e:\n            return Notify.make_system_notify('通知发送失败', f'接口调用异常: {e}')\n        if res.status_code != 200:\n            return Notify.make_system_notify('通知发送失败', f'返回状态码：{res.status_code}, 请求URL：{res.url}')\n\n        if mode in ['dd', 'wx']:\n            res = res.json()\n            if res.get('errcode') == 0:\n                return\n        elif mode == 'spug':\n            res = res.json()\n            if not res.get('error'):\n                return\n        elif mode == 'fs':\n            res = res.json()\n            if res.get('StatusCode') == 0:\n                return\n        else:\n            raise NotImplementedError\n        Notify.make_system_notify('通知发送失败', f'返回数据：{res}')\n\n    def monitor_by_email(self, users):\n        mail_service = AppSetting.get_default('mail_service', {})\n        body = [\n            f'告警名称：{self.title}',\n            f'告警对象：{self.target}',\n            f'{\"告警\" if self.event == \"1\" else \"恢复\"}时间：{human_datetime()}',\n            f'告警描述：{self.message}'\n        ]\n        if self.event == '2':\n            body.append('故障持续：' + self.duration)\n        if mail_service.get('server'):\n            event_map = {'1': '监控告警通知', '2': '告警恢复通知'}\n            subject = f'{event_map[self.event]}-{self.title}'\n            mail = Mail(**mail_service)\n            mail.send_text_mail(users, subject, '\\r\\n'.join(body) + '\\r\\n\\r\\n自动发送，请勿回复。')\n        else:\n            Notify.make_monitor_notify(\n                '发送报警信息失败',\n                '未配置报警服务，请在系统管理/系统设置/报警服务设置中配置邮件服务。'\n            )\n\n    def monitor_by_dd(self, users):\n        texts = [\n            '## %s ## ' % ('监控告警通知' if self.event == '1' else '告警恢复通知'),\n            f'**告警名称：** <font color=\"#{\"f90202\" if self.event == \"1\" else \"008000\"}\">{self.title}</font> ',\n            f'**告警对象：** {self.target} ',\n            f'**{\"告警\" if self.event == \"1\" else \"恢复\"}时间：** {human_datetime()} ',\n            f'**告警描述：** {self.message} ',\n        ]\n        if self.event == '2':\n            texts.append(f'**持续时间：** {self.duration} ')\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'title': '监控告警通知',\n                'text': '\\n\\n'.join(texts) + '\\n\\n> ###### 来自 Spug运维平台'\n            },\n            'at': {\n                'isAtAll': True\n            }\n        }\n        for url, secret in users:\n            if secret:\n                timestamp, sign = _gen_dd_sign(secret)\n                url = f'{url}&{urlencode({\"timestamp\": timestamp, \"sign\": sign})}'\n            self.handle_request(url, data, 'dd')\n\n    def monitor_by_fs(self, users):\n        title = '监控告警通知' if self.event == '1' else '告警恢复通知'\n        content = [\n            [{'tag': 'text', 'text': f'告警名称：{self.title}'}],\n            [{'tag': 'text', 'text': f'告警对象：{self.target}'}],\n            [{'tag': 'text', 'text': f'{\"告警\" if self.event == \"1\" else \"恢复\"}时间：{human_datetime()}'}],\n            [{'tag': 'text', 'text': f'告警描述：{self.message}'}],\n        ]\n        if self.event == '2':\n            content.append([{'tag': 'text', 'text': f'持续时间：{self.duration}'}])\n        content.append([{'tag': 'text', 'text': '来自 Spug运维平台'}])\n        for url, secret in users:\n            data = {\n                'msg_type': 'post',\n                'content': {\n                    'post': {\n                        'zh_cn': {\n                            'title': title,\n                            'content': content\n                        }\n                    }\n                }\n            }\n            if secret:\n                timestamp, sign = _gen_fs_sign(secret)\n                data['timestamp'] = timestamp\n                data['sign'] = sign\n            self.handle_request(url, data, 'fs')\n\n    def monitor_by_qy_wx(self, users):\n        color, title = ('warning', '监控告警通知') if self.event == '1' else ('info', '告警恢复通知')\n        texts = [\n            f'## {title}',\n            f'**告警名称：** <font color=\"{color}\">{self.title}</font> ',\n            f'**告警对象：** {self.target}',\n            f'**{\"告警\" if self.event == \"1\" else \"恢复\"}时间：** {human_datetime()} ',\n            f'**告警描述：** {self.message} ',\n        ]\n        if self.event == '2':\n            texts.append(f'**持续时间：** {self.duration} ')\n        data = {\n            'msgtype': 'markdown',\n            'markdown': {\n                'content': '\\n'.join(texts) + '\\n> 来自 Spug运维平台'\n            }\n        }\n        for url in users:\n            self.handle_request(url, data, 'wx')\n\n    def monitor_by_spug_push(self, targets):\n        if not self.spug_push_key:\n            Notify.make_monitor_notify(\n                '发送报警信息失败',\n                '未绑定推送服务，请在系统管理/系统设置/推送服务设置中绑定推送助手账户。'\n            )\n            return\n        data = {\n            'source': 'monitor',\n            'token': self.spug_push_key,\n            'targets': list(targets),\n            'dataset': {\n                'title': self.title,\n                'target': self.target,\n                'message': self.message,\n                'duration': self.duration,\n                'event': self.event\n            }\n        }\n        self.handle_request(f'{push_server}/spug/message/', data, 'spug')\n\n    def dispatch_monitor(self, modes):\n        u_ids, push_ids = [], []\n        for item in Group.objects.filter(id__in=self.grp):\n            for x in json.loads(item.contacts):\n                if isinstance(x, str) and '_' in x:\n                    push_ids.append(x)\n                else:\n                    u_ids.append(x)\n\n        targets = set()\n        for mode in modes:\n            if mode == '1':\n                wx_mp_ids = set(x for x in push_ids if x.startswith('wx_mp_'))\n                targets.update(wx_mp_ids)\n            elif mode == '2':\n                sms_ids = set(x for x in push_ids if x.startswith('sms_'))\n                targets.update(sms_ids)\n            elif mode == '3':\n                contacts = Contact.objects.filter(id__in=u_ids, ding__isnull=False)\n                users = []\n                for c in contacts:\n                    sec = None\n                    if c.secret:\n                        sec = json.loads(c.secret).get('ding')\n                    users.append((c.ding, sec))\n                if not users:\n                    Notify.make_monitor_notify(\n                        '发送报警信息失败',\n                        '未找到可用的通知对象，请确保设置了相关报警联系人的钉钉。'\n                    )\n                    continue\n                self.monitor_by_dd(users)\n            elif mode == '4':\n                mail_ids = set(x for x in push_ids if x.startswith('mail_'))\n                targets.update(mail_ids)\n                users = set(x.email for x in Contact.objects.filter(id__in=u_ids, email__isnull=False))\n                if not users:\n                    if not mail_ids:\n                        Notify.make_monitor_notify(\n                            '发送报警信息失败',\n                            '未找到可用的通知对象，请确保设置了相关报警联系人的邮件地址。'\n                        )\n                    continue\n                self.monitor_by_email(users)\n            elif mode == '5':\n                users = set(x.qy_wx for x in Contact.objects.filter(id__in=u_ids, qy_wx__isnull=False))\n                if not users:\n                    Notify.make_monitor_notify(\n                        '发送报警信息失败',\n                        '未找到可用的通知对象，请确保设置了相关报警联系人的企业微信。'\n                    )\n                    continue\n                self.monitor_by_qy_wx(users)\n            elif mode == '6':\n                voice_ids = set(x for x in push_ids if x.startswith('voice_'))\n                targets.update(voice_ids)\n            elif mode == '7':\n                contacts = Contact.objects.filter(id__in=u_ids, feishu__isnull=False)\n                users = []\n                for c in contacts:\n                    sec = None\n                    if c.secret:\n                        sec = json.loads(c.secret).get('feishu')\n                    users.append((c.feishu, sec))\n                if not users:\n                    Notify.make_monitor_notify(\n                        '发送报警信息失败',\n                        '未找到可用的通知对象，请确保设置了相关报警联系人的飞书。'\n                    )\n                    continue\n                self.monitor_by_fs(users)\n\n        if targets:\n            self.monitor_by_spug_push(targets)\n"
  },
  {
    "path": "spug_api/libs/ssh.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom paramiko.client import SSHClient, AutoAddPolicy\nfrom paramiko.rsakey import RSAKey\nfrom paramiko.auth_handler import AuthHandler\nfrom paramiko.ssh_exception import AuthenticationException, SSHException\nfrom paramiko.py3compat import b, u\nfrom io import StringIO\nfrom uuid import uuid4\nimport time\nimport re\n\n\ndef _finalize_pubkey_algorithm(self, key_type):\n    if \"rsa\" not in key_type:\n        return key_type\n    if re.search(r\"-OpenSSH_(?:[1-6]|7\\.[0-7])\", self.transport.remote_version):\n        pubkey_algo = \"ssh-rsa\"\n        if key_type.endswith(\"-cert-v01@openssh.com\"):\n            pubkey_algo += \"-cert-v01@openssh.com\"\n\n        self.transport._agreed_pubkey_algorithm = pubkey_algo\n        return pubkey_algo\n    my_algos = [x for x in self.transport.preferred_pubkeys if \"rsa\" in x]\n    if not my_algos:\n        raise SSHException(\n            \"An RSA key was specified, but no RSA pubkey algorithms are configured!\"  # noqa\n        )\n    server_algo_str = u(\n        self.transport.server_extensions.get(\"server-sig-algs\", b(\"\"))\n    )\n    if server_algo_str:\n        server_algos = server_algo_str.split(\",\")\n        agreement = list(filter(server_algos.__contains__, my_algos))\n        if agreement:\n            pubkey_algo = agreement[0]\n        else:\n            err = \"Unable to agree on a pubkey algorithm for signing a {!r} key!\"  # noqa\n            raise AuthenticationException(err.format(key_type))\n    else:\n        pubkey_algo = \"ssh-rsa\"\n    if key_type.endswith(\"-cert-v01@openssh.com\"):\n        pubkey_algo += \"-cert-v01@openssh.com\"\n    self.transport._agreed_pubkey_algorithm = pubkey_algo\n    return pubkey_algo\n\n\nAuthHandler._finalize_pubkey_algorithm = _finalize_pubkey_algorithm\n\n\nclass SSH:\n    def __init__(self, hostname, port=22, username='root', pkey=None, password=None, default_env=None,\n                 connect_timeout=10, term=None):\n        self.stdout = None\n        self.client = None\n        self.channel = None\n        self.sftp = None\n        self.exec_file = None\n        self.term = term or {}\n        self.eof = 'Spug EOF 2108111926'\n        self.default_env = default_env\n        self.regex = re.compile(r'Spug EOF 2108111926 (-?\\d+)[\\r\\n]?')\n        self.arguments = {\n            'hostname': hostname,\n            'port': port,\n            'username': username,\n            'password': password,\n            'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,\n            'timeout': connect_timeout,\n            'allow_agent': False,\n            'look_for_keys': False,\n            'banner_timeout': 30\n        }\n\n    @staticmethod\n    def generate_key():\n        key_obj = StringIO()\n        key = RSAKey.generate(2048)\n        key.write_private_key(key_obj)\n        return key_obj.getvalue(), 'ssh-rsa ' + key.get_base64()\n\n    def get_client(self):\n        if self.client is not None:\n            return self.client\n        self.client = SSHClient()\n        self.client.set_missing_host_key_policy(AutoAddPolicy)\n        self.client.connect(**self.arguments)\n        return self.client\n\n    def ping(self):\n        return True\n\n    def add_public_key(self, public_key):\n        command = f'mkdir -p -m 700 ~/.ssh && \\\n        echo {public_key!r} >> ~/.ssh/authorized_keys && \\\n        chmod 600 ~/.ssh/authorized_keys'\n        exit_code, out = self.exec_command_raw(command)\n        if exit_code != 0:\n            raise Exception(f'add public key error: {out}')\n\n    def exec_command_raw(self, command, environment=None):\n        channel = self.client.get_transport().open_session()\n        if environment:\n            channel.update_environment(environment)\n        channel.set_combine_stderr(True)\n        channel.exec_command(command)\n        code, output = channel.recv_exit_status(), channel.recv(-1)\n        return code, self._decode(output)\n\n    def exec_command(self, command, environment=None):\n        channel = self._get_channel()\n        command = self._handle_command(command, environment)\n        channel.sendall(command)\n        out, exit_code = '', -1\n        for line in self.stdout:\n            match = self.regex.search(line)\n            if match:\n                exit_code = int(match.group(1))\n                line = line[:match.start()]\n                out += line\n                break\n            out += line\n        return exit_code, out\n\n    def _win_exec_command_with_stream(self, command, environment=None):\n        channel = self.client.get_transport().open_session()\n        if environment:\n            channel.update_environment(environment)\n        channel.set_combine_stderr(True)\n        channel.get_pty(width=102)\n        channel.exec_command(command)\n        stdout = channel.makefile(\"rb\", -1)\n        out = stdout.readline()\n        while out:\n            yield channel.exit_status, self._decode(out)\n            out = stdout.readline()\n        yield channel.recv_exit_status(), self._decode(out)\n\n    def exec_command_with_stream(self, command, environment=None):\n        channel = self._get_channel()\n        command = self._handle_command(command, environment)\n        channel.sendall(command)\n        exit_code, line = -1, ''\n        while True:\n            line = self._decode(channel.recv(8196))\n            if not line:\n                break\n            match = self.regex.search(line)\n            if match:\n                exit_code = int(match.group(1))\n                line = line[:match.start()]\n                break\n            yield exit_code, line\n        yield exit_code, line\n\n    def put_file(self, local_path, remote_path, callback=None):\n        sftp = self._get_sftp()\n        sftp.put(local_path, remote_path, callback=callback, confirm=False)\n\n    def put_file_by_fl(self, fl, remote_path, callback=None):\n        sftp = self._get_sftp()\n        sftp.putfo(fl, remote_path, callback=callback, confirm=False)\n\n    def list_dir_attr(self, path):\n        sftp = self._get_sftp()\n        return sftp.listdir_attr(path)\n\n    def sftp_stat(self, path):\n        sftp = self._get_sftp()\n        return sftp.stat(path)\n\n    def remove_file(self, path):\n        sftp = self._get_sftp()\n        sftp.remove(path)\n\n    def _get_channel(self):\n        if self.channel:\n            return self.channel\n\n        counter = 0\n        self.channel = self.client.invoke_shell(**self.term)\n        command = '[ -n \"$BASH_VERSION\" ] && set +o history\\n'\n        command += '[ -n \"$ZSH_VERSION\" ] && set +o zle && set -o no_nomatch\\n'\n        command += 'export PS1= && stty -echo\\n'\n        command = self._handle_command(command, self.default_env)\n        self.channel.sendall(command)\n        out = ''\n        while True:\n            if self.channel.recv_ready():\n                out += self._decode(self.channel.recv(8196))\n                if self.regex.search(out):\n                    self.stdout = self.channel.makefile('r')\n                    break\n            elif counter >= 100:\n                self.client.close()\n                raise Exception('Wait spug response timeout')\n            else:\n                counter += 1\n                time.sleep(0.1)\n        return self.channel\n\n    def _get_sftp(self):\n        if self.sftp:\n            return self.sftp\n\n        self.sftp = self.client.open_sftp()\n        return self.sftp\n\n    def _make_env_command(self, environment):\n        if not environment:\n            return None\n        str_envs = []\n        for k, v in environment.items():\n            k = k.replace('-', '_')\n            if isinstance(v, str):\n                v = v.replace(\"'\", \"'\\\"'\\\"'\")\n            str_envs.append(f\"{k}='{v}'\")\n        str_envs = ' '.join(str_envs)\n        return f'export {str_envs}'\n\n    def _handle_command(self, command, environment):\n        new_command = commands = ''\n        if not self.exec_file:\n            self.exec_file = f'/tmp/spug.{uuid4().hex}'\n            commands += f'trap \\'rm -f {self.exec_file}\\' EXIT\\n'\n\n        env_command = self._make_env_command(environment)\n        if env_command:\n            new_command += f'{env_command}\\n'\n        new_command += command\n        new_command += f'\\necho {self.eof} $?\\n'\n        self.put_file_by_fl(StringIO(new_command), self.exec_file)\n        commands += f'. {self.exec_file}\\n'\n        return commands\n\n    def _decode(self, content):\n        try:\n            content = content.decode()\n        except UnicodeDecodeError:\n            content = content.decode(encoding='GBK', errors='ignore')\n        return content\n\n    def __enter__(self):\n        self.get_client()\n        transport = self.client.get_transport()\n        if 'windows' in transport.remote_version.lower():\n            self.exec_command = self.exec_command_raw\n            self.exec_command_with_stream = self._win_exec_command_with_stream\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        self.client.close()\n        self.client = None\n"
  },
  {
    "path": "spug_api/libs/utils.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom django.http.response import HttpResponse\nfrom django.db.models import QuerySet\nfrom datetime import datetime, date as datetime_date\nfrom decimal import Decimal\nfrom string import Template\nimport string\nimport random\nimport json\n\n\n# 转换时间格式到字符串\ndef human_datetime(date=None):\n    if date:\n        assert isinstance(date, datetime)\n    else:\n        date = datetime.now()\n    return date.strftime('%Y-%m-%d %H:%M:%S')\n\n\n# 转换时间格式到字符串(天)\ndef human_date(date=None):\n    if date:\n        assert isinstance(date, datetime)\n    else:\n        date = datetime.now()\n    return date.strftime('%Y-%m-%d')\n\n\ndef human_time(date=None):\n    if date:\n        assert isinstance(date, datetime)\n    else:\n        date = datetime.now()\n    return date.strftime('%H:%M:%S')\n\n\ndef str_decode(data):\n    try:\n        data = data.decode()\n    except UnicodeDecodeError:\n        try:\n            data = data.decode(encoding='GBK')\n        except UnicodeDecodeError:\n            data = data.decode(errors='ignore')\n    return data\n\n\n# 解析时间类型的数据\ndef parse_time(value):\n    if isinstance(value, datetime):\n        return value\n    if isinstance(value, str):\n        if len(value) == 10:\n            return datetime.strptime(value, '%Y-%m-%d')\n        elif len(value) == 19:\n            return datetime.strptime(value, '%Y-%m-%d %H:%M:%S')\n    raise TypeError('Expect a datetime.datetime value')\n\n\n# 传两个时间得到一个时间差\ndef human_seconds_time(seconds):\n    text = ''\n    if seconds >= 3600:\n        text += '%d小时' % (seconds / 3600)\n        seconds = seconds % 3600\n    if seconds >= 60:\n        text += '%d分' % (seconds / 60)\n        seconds = seconds % 60\n    if seconds > 0:\n        if text or isinstance(seconds, int):\n            text += '%.d秒' % seconds\n        else:\n            text += '%.1f秒' % seconds\n    return text\n\n\n# 字符串模版渲染\ndef render_str(template, datasheet):\n    return Template(template).safe_substitute(datasheet)\n\n\ndef json_response(data='', error=''):\n    content = AttrDict(data=data, error=error)\n    if error:\n        content.data = ''\n    elif hasattr(data, 'to_dict'):\n        content.data = data.to_dict()\n    elif isinstance(data, (list, QuerySet)) and all([hasattr(item, 'to_dict') for item in data]):\n        content.data = [item.to_dict() for item in data]\n    return HttpResponse(json.dumps(content, cls=DateTimeEncoder), content_type='application/json')\n\n\n# 继承自dict，实现可以通过.来操作元素\nclass AttrDict(dict):\n    def __setattr__(self, key, value):\n        self.__setitem__(key, value)\n\n    def __getattr__(self, item):\n        try:\n            return self.__getitem__(item)\n        except KeyError:\n            raise AttributeError(item)\n\n    def __delattr__(self, item):\n        self.__delitem__(item)\n\n\n# 日期json序列化\nclass DateTimeEncoder(json.JSONEncoder):\n    def default(self, o):\n        if isinstance(o, datetime):\n            return o.strftime('%Y-%m-%d %H:%M:%S')\n        elif isinstance(o, datetime_date):\n            return o.strftime('%Y-%m-%d')\n        elif isinstance(o, Decimal):\n            return float(o)\n\n        return json.JSONEncoder.default(self, o)\n\n\n# 生成指定长度的随机数\ndef generate_random_str(length: int = 4, is_digits: bool = True) -> str:\n    words = string.digits if is_digits else string.ascii_letters + string.digits\n    return ''.join(random.sample(words, length))\n\n\ndef get_request_real_ip(headers: dict):\n    x_real_ip = headers.get('x-forwarded-for')\n    if not x_real_ip:\n        x_real_ip = headers.get('x-real-ip', '')\n    return x_real_ip.split(',')[0]\n"
  },
  {
    "path": "spug_api/libs/validators.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nimport ipaddress\nfrom datetime import datetime\n\n\n# 判断是否是ip地址\ndef ip_validator(value):\n    try:\n        ipaddress.ip_address(value)\n        return True\n    except ValueError:\n        return False\n\n\n# 判断是否是日期字符串，支持 2018-04-11 或 2018-04-11 14:55:30\ndef date_validator(value: str) -> bool:\n    value = value.strip()\n    try:\n        if len(value) == 10:\n            datetime.strptime(value, '%Y-%m-%d')\n            return True\n        elif len(value) == 19:\n            datetime.strptime(value, '%Y-%m-%d %H:%M:%S')\n            return True\n    except ValueError:\n        pass\n    return False\n"
  },
  {
    "path": "spug_api/logs/.gitkeep",
    "content": ""
  },
  {
    "path": "spug_api/manage.py",
    "content": "#!/usr/bin/env python\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n\"\"\"Django's command-line utility for administrative tasks.\"\"\"\nimport os\nimport sys\n\n\ndef main():\n    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'spug.settings')\n    try:\n        from django.core.management import execute_from_command_line\n    except ImportError as exc:\n        raise ImportError(\n            \"Couldn't import Django. Are you sure it's installed and \"\n            \"available on your PYTHONPATH environment variable? Did you \"\n            \"forget to activate a virtual environment?\"\n        ) from exc\n    execute_from_command_line(sys.argv)\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "spug_api/repos/.gitkeep",
    "content": ""
  },
  {
    "path": "spug_api/repos/build/.gitkeep",
    "content": ""
  },
  {
    "path": "spug_api/requirements.txt",
    "content": "apscheduler==3.7.0\nDjango==2.2.28\nasgiref==3.2.10\nchannels==2.3.1\nchannels_redis==2.4.1\nparamiko==2.11.0\ndjango-redis==4.10.0\nrequests==2.32.0\nGitPython==3.1.41\npython-ldap==3.4.0\nopenpyxl==3.0.3\nuser_agents==2.2.0"
  },
  {
    "path": "spug_api/spug/__init__.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n"
  },
  {
    "path": "spug_api/spug/asgi.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n\"\"\"\nASGI entrypoint. Configures Django and then runs the application\ndefined in the ASGI_APPLICATION setting.\n\"\"\"\n\n\nimport os\nimport django\nfrom channels.routing import get_default_application\n\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'spug.settings')\ndjango.setup()\napplication = get_default_application()\n"
  },
  {
    "path": "spug_api/spug/routing.py",
    "content": "# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nfrom channels.routing import ProtocolTypeRouter\nfrom consumer import routing\n\napplication = ProtocolTypeRouter({\n    'websocket': routing.ws_router\n})\n"
  },
  {
    "path": "spug_api/spug/settings.py",
    "content": "\"\"\"\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n\nDjango settings for spug project.\n\nGenerated by 'django-admin startproject' using Django 2.2.7.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/2.2/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/2.2/ref/settings/\n\"\"\"\n\nimport os\nimport re\n\n# Build paths inside the project like this: os.path.join(BASE_DIR, ...)\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = 'vk0do47)egwzz!uk49%(y3s(fpx4+ha@ugt-hcv&%&d@hwr&p7'\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = True\n\nALLOWED_HOSTS = ['127.0.0.1']\n\n# Application definition\n\nINSTALLED_APPS = [\n    'apps.account',\n    'apps.host',\n    'apps.setting',\n    'apps.exec',\n    'apps.schedule',\n    'apps.monitor',\n    'apps.alarm',\n    'apps.config',\n    'apps.app',\n    'apps.deploy',\n    'apps.notify',\n    'apps.repository',\n    'apps.home',\n    'channels',\n]\n\nMIDDLEWARE = [\n    'django.middleware.security.SecurityMiddleware',\n    'django.middleware.common.CommonMiddleware',\n    'libs.middleware.AuthenticationMiddleware',\n    'libs.middleware.HandleExceptionMiddleware',\n]\n\nROOT_URLCONF = 'spug.urls'\n\nWSGI_APPLICATION = 'spug.wsgi.application'\nASGI_APPLICATION = 'spug.routing.application'\n\n# Database\n# https://docs.djangoproject.com/en/2.2/ref/settings/#databases\n\nDATABASES = {\n    'default': {\n        'ATOMIC_REQUESTS': True,\n        'ENGINE': 'django.db.backends.sqlite3',\n        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),\n    }\n}\n\nCACHES = {\n    \"default\": {\n        \"BACKEND\": \"django_redis.cache.RedisCache\",\n        \"LOCATION\": \"redis://127.0.0.1:6379/1\",\n        \"OPTIONS\": {\n            \"CLIENT_CLASS\": \"django_redis.client.DefaultClient\",\n        }\n    }\n}\n\nCHANNEL_LAYERS = {\n    \"default\": {\n        \"BACKEND\": \"channels_redis.core.RedisChannelLayer\",\n        \"CONFIG\": {\n            \"hosts\": [(\"127.0.0.1\", 6379)],\n            \"capacity\": 1000,\n            \"expiry\": 120,\n        },\n    },\n}\n\nTEMPLATES = [\n    {\n        'BACKEND': 'django.template.backends.django.DjangoTemplates',\n        'DIRS': [],\n        'APP_DIRS': False,\n    },\n]\n\nTOKEN_TTL = 8 * 3600\nSCHEDULE_KEY = 'spug:schedule'\nSCHEDULE_WORKER_KEY = 'spug:schedule:worker'\nMONITOR_KEY = 'spug:monitor'\nMONITOR_WORKER_KEY = 'spug:monitor:worker'\nEXEC_WORKER_KEY = 'spug:exec:worker'\nREQUEST_KEY = 'spug:request'\nBUILD_KEY = 'spug:build'\nREPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos')\nBUILD_DIR = os.path.join(REPOS_DIR, 'build')\nTRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer')\n\n# Internationalization\n# https://docs.djangoproject.com/en/2.2/topics/i18n/\n\nLANGUAGE_CODE = 'en-us'\n\nTIME_ZONE = 'Asia/Shanghai'\n\nUSE_I18N = True\n\nUSE_L10N = True\n\nUSE_TZ = False\n\nAUTHENTICATION_EXCLUDES = (\n    '/account/login/',\n    '/setting/basic/',\n    re.compile('/apis/.*'),\n)\n\nSPUG_VERSION = 'v3.3.3'\n\n# override default config\ntry:\n    from spug.overrides import *\nexcept ImportError:\n    pass\n"
  },
  {
    "path": "spug_api/spug/urls.py",
    "content": "\"\"\"spug URL Configuration\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n\nThe `urlpatterns` list routes URLs to views. For more information please see:\n    https://docs.djangoproject.com/en/2.2/topics/http/urls/\nExamples:\nFunction views\n    1. Add an import:  from my_app import views\n    2. Add a URL to urlpatterns:  path('', views.home, name='home')\nClass-based views\n    1. Add an import:  from other_app.views import Home\n    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')\nIncluding another URLconf\n    1. Import the include() function: from django.urls import include, path\n    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))\n\"\"\"\nfrom django.urls import path, include\n\nurlpatterns = [\n    path('account/', include('apps.account.urls')),\n    path('host/', include('apps.host.urls')),\n    path('exec/', include('apps.exec.urls')),\n    path('schedule/', include('apps.schedule.urls')),\n    path('monitor/', include('apps.monitor.urls')),\n    path('alarm/', include('apps.alarm.urls')),\n    path('setting/', include('apps.setting.urls')),\n    path('config/', include('apps.config.urls')),\n    path('app/', include('apps.app.urls')),\n    path('deploy/', include('apps.deploy.urls')),\n    path('repository/', include('apps.repository.urls')),\n    path('home/', include('apps.home.urls')),\n    path('notify/', include('apps.notify.urls')),\n    path('file/', include('apps.file.urls')),\n    path('apis/', include('apps.apis.urls')),\n]\n"
  },
  {
    "path": "spug_api/spug/wsgi.py",
    "content": "\"\"\"\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\nWSGI config for spug project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/\n\"\"\"\n\nimport os\n\nfrom django.core.wsgi import get_wsgi_application\n\n\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'spug.settings')\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "spug_api/storage/transfer/.gitkeep",
    "content": ""
  },
  {
    "path": "spug_api/tools/migrate.py",
    "content": "import django\nimport sys\nimport os\n\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nsys.path.append(BASE_DIR)\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"spug.settings\")\ndjango.setup()\n\nfrom django.conf import settings\nimport subprocess\nimport shutil\nimport sys\nimport os\nimport re\n\n\nclass Version:\n    def __init__(self, version):\n        self.version = re.sub('[^0-9.]', '', version).split('.')\n\n    def __gt__(self, other):\n        if not isinstance(other, Version):\n            raise TypeError('required type Version')\n        for v1, v2 in zip(self.version, other.version):\n            if int(v1) == int(v2):\n                continue\n            elif int(v1) > int(v2):\n                return True\n            else:\n                return False\n        return False\n\n\nif __name__ == '__main__':\n    old_version = Version(sys.argv[1])\n    now_version = Version(settings.SPUG_VERSION)\n    if old_version < Version('v3.0.2'):\n        old_path = os.path.join(settings.BASE_DIR, 'repos')\n        new_path = os.path.join(settings.REPOS_DIR)\n        if not os.path.exists(new_path):\n            print('执行 v3.0.1-beta.8 repos目录迁移')\n            shutil.move(old_path, new_path)\n            task = subprocess.Popen(f'cd {settings.BASE_DIR} && git checkout -- repos', shell=True)\n            if task.wait() != 0:\n                print('repos目录迁移失败，请联系官方人员')\n"
  },
  {
    "path": "spug_api/tools/start-api.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# start api service\n\ncd $(dirname $(dirname $0))\nif [ -f ./venv/bin/activate ]; then\n  source ./venv/bin/activate\nfi\nexec gunicorn -b 127.0.0.1:9001 -w 2 --threads 8 --access-logfile - spug.wsgi\n"
  },
  {
    "path": "spug_api/tools/start-monitor.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# start monitor service\n\ncd $(dirname $(dirname $0))\nif [ -f ./venv/bin/activate ]; then\n  source ./venv/bin/activate\nfi\n\nif command -v python3 &> /dev/null; then\n  PYTHON=python3\nelse\n  PYTHON=python\nfi\n\nexec $PYTHON manage.py runmonitor\n"
  },
  {
    "path": "spug_api/tools/start-scheduler.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# start schedule service\n\ncd $(dirname $(dirname $0))\nif [ -f ./venv/bin/activate ]; then\n  source ./venv/bin/activate\nfi\n\nif command -v python3 &> /dev/null; then\n  PYTHON=python3\nelse\n  PYTHON=python\nfi\n\nexec $PYTHON manage.py runscheduler\n"
  },
  {
    "path": "spug_api/tools/start-worker.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# start worker service\n\ncd $(dirname $(dirname $0))\nif [ -f ./venv/bin/activate ]; then\n  source ./venv/bin/activate\nfi\n\nif command -v python3 &> /dev/null; then\n  PYTHON=python3\nelse\n  PYTHON=python\nfi\n\nexec $PYTHON manage.py runworker\n"
  },
  {
    "path": "spug_api/tools/start-ws.sh",
    "content": "#!/bin/bash\n# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug\n# Copyright: (c) <spug.dev@gmail.com>\n# Released under the AGPL-3.0 License.\n# start websocket service\n\ncd $(dirname $(dirname $0))\nif [ -f ./venv/bin/activate ]; then\n  source ./venv/bin/activate\nfi\nexec daphne -p 9002 spug.asgi:application\n"
  },
  {
    "path": "spug_api/tools/supervisor-spug.ini",
    "content": "[program:spug-api]\ncommand = bash /data/spug/spug_api/tools/start-api.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/api.log\nredirect_stderr = true\n\n[program:spug-ws]\ncommand = bash /data/spug/spug_api/tools/start-ws.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/ws.log\nredirect_stderr = true\n\n[program:spug-worker]\ncommand = bash /data/spug/spug_api/tools/start-worker.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/worker.log\nredirect_stderr = true\n\n[program:spug-monitor]\ncommand = bash /data/spug/spug_api/tools/start-monitor.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/monitor.log\nredirect_stderr = true\n\n[program:spug-scheduler]\ncommand = bash /data/spug/spug_api/tools/start-scheduler.sh\nautostart = true\nstdout_logfile = /data/spug/spug_api/logs/scheduler.log\nredirect_stderr = true"
  },
  {
    "path": "spug_web/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n/.idea/\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "spug_web/README.md",
    "content": "spug web"
  },
  {
    "path": "spug_web/config-overrides.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nconst {override, addDecoratorsLegacy, addLessLoader} = require('customize-cra');\n\nmodule.exports = override(\n  addDecoratorsLegacy(),\n  addLessLoader({\n    lessOptions: {\n      javascriptEnabled: true,\n      modifyVars: {\n        '@primary-color': '#2563fc'\n      }\n    }\n  }),\n);\n"
  },
  {
    "path": "spug_web/jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \"src\"\n  },\n  \"include\": [\n    \"src\"\n  ]\n}\n"
  },
  {
    "path": "spug_web/package.json",
    "content": "{\n  \"name\": \"spug_web\",\n  \"version\": \"3.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@ant-design/icons\": \"^4.3.0\",\n    \"ace-builds\": \"^1.4.13\",\n    \"antd\": \"4.21.5\",\n    \"axios\": \"^0.21.0\",\n    \"bizcharts\": \"^3.5.9\",\n    \"history\": \"^4.10.1\",\n    \"lodash\": \"^4.17.19\",\n    \"mobx\": \"^5.15.6\",\n    \"mobx-react\": \"^6.3.0\",\n    \"moment\": \"^2.24.0\",\n    \"react\": \"^16.13.1\",\n    \"react-ace\": \"^9.5.0\",\n    \"react-dom\": \"^16.13.1\",\n    \"react-router-dom\": \"^5.2.0\",\n    \"react-scripts\": \"3.4.3\",\n    \"xterm\": \"^4.6.0\",\n    \"xterm-addon-fit\": \"^0.5.0\"\n  },\n  \"scripts\": {\n    \"start\": \"react-app-rewired start\",\n    \"build\": \"GENERATE_SOURCEMAP=false react-app-rewired build\",\n    \"test\": \"react-app-rewired test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": \"react-app\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@babel/plugin-proposal-decorators\": \"^7.10.5\",\n    \"customize-cra\": \"^1.0.0\",\n    \"http-proxy-middleware\": \"0.19.2\",\n    \"less\": \"^3.12.2\",\n    \"less-loader\": \"^7.1.0\",\n    \"react-app-rewired\": \"^2.1.6\"\n  }\n}\n"
  },
  {
    "path": "spug_web/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%PUBLIC_URL%/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"theme-color\" content=\"#000000\" />\n    <meta\n      name=\"description\"\n      content=\"Web site created using create-react-app\"\n    />\n    <link rel=\"apple-touch-icon\" href=\"logo.png\" />\n    <!--\n      manifest.json provides metadata used when your web app is installed on a\n      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/\n    -->\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/manifest.json\" />\n    <!--\n      Notice the use of %PUBLIC_URL% in the tags above.\n      It will be replaced with the URL of the `public` folder during the build.\n      Only files inside the `public` folder can be referenced from the HTML.\n\n      Unlike \"/favicon.ico\" or \"favicon.ico\", \"%PUBLIC_URL%/favicon.ico\" will\n      work correctly both with client-side routing and a non-root public URL.\n      Learn how to configure a non-root public URL by running `npm run build`.\n    -->\n    <title>Spug</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n    <!--\n      This HTML file is a template.\n      If you open it directly in the browser, you will see an empty page.\n\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n\n      To begin the development, run `npm start` or `yarn start`.\n      To create a production bundle, use `npm run build` or `yarn build`.\n    -->\n  </body>\n</html>\n"
  },
  {
    "path": "spug_web/public/manifest.json",
    "content": "{\n  \"short_name\": \"Spug\",\n  \"name\": \"Spug\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      \"type\": \"image/x-icon\"\n    },\n    {\n      \"src\": \"logo.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\"\n    },\n    {\n      \"src\": \"logo.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#000000\",\n  \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "spug_web/public/robots.txt",
    "content": "User-agent: *\nDisallow: /"
  },
  {
    "path": "spug_web/src/App.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { Component } from 'react';\nimport {Switch, Route} from 'react-router-dom';\nimport Login from './pages/login';\nimport WebSSH from './pages/ssh';\nimport Layout from './layout';\n\nclass App extends Component {\n  render() {\n    return (\n      <Switch>\n        <Route path=\"/\" exact component={Login} />\n        <Route path=\"/ssh\" exact component={WebSSH} />\n        <Route component={Layout} />\n      </Switch>\n    );\n  }\n}\n\nexport default App;\n"
  },
  {
    "path": "spug_web/src/components/ACEditor.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport Editor from 'react-ace';\nimport 'ace-builds/src-noconflict/mode-sh';\nimport 'ace-builds/src-noconflict/mode-text';\nimport 'ace-builds/src-noconflict/mode-json';\nimport 'ace-builds/src-noconflict/mode-space';\nimport 'ace-builds/src-noconflict/mode-python';\nimport 'ace-builds/src-noconflict/theme-tomorrow';\n\nexport default function (props) {\n  const style = {fontFamily: 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei', ...props.style}\n  return (\n    <Editor\n      theme=\"tomorrow\"\n      fontSize={13}\n      tabSize={2}\n      {...props}\n      style={style}\n    />\n  )\n}\n"
  },
  {
    "path": "spug_web/src/components/Action.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Link as ALink } from 'react-router-dom';\nimport { Divider, Button as AButton } from 'antd';\nimport { hasPermission } from 'libs';\n\nfunction canVisible(auth) {\n  return !auth || hasPermission(auth)\n}\n\nclass Action extends React.Component {\n  static Link(props) {\n    return <ALink {...props}/>\n  }\n\n  static Button(props) {\n    return <AButton type=\"link\" {...props} style={{padding: 0}}/>\n  }\n\n  _handle = (data, el) => {\n    const length = data.length;\n    if (el && canVisible(el.props.auth)) {\n      if (length !== 0) data.push(<Divider key={length} type=\"vertical\"/>)\n      data.push(el)\n    }\n  }\n\n  render() {\n    const children = [];\n    if (Array.isArray(this.props.children)) {\n      this.props.children.forEach(el => this._handle(children, el))\n    } else {\n      this._handle(children, this.props.children)\n    }\n\n    return <span>\n      {children}\n    </span>\n  }\n}\n\nexport default Action\n"
  },
  {
    "path": "spug_web/src/components/AppSelector.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { observer } from 'mobx-react';\nimport { Modal, Menu, Spin, Input } from 'antd';\nimport { OrderedListOutlined, BuildOutlined, SearchOutlined } from '@ant-design/icons';\nimport { includes, http } from 'libs';\nimport styles from './index.module.less';\nimport envStore from 'pages/config/environment/store';\nimport lds from 'lodash';\n\nexport default observer(function AppSelector(props) {\n  const [fetching, setFetching] = useState(false);\n  const [env_id, setEnvId] = useState();\n  const [search, setSearch] = useState();\n  const [deploys, setDeploys] = useState([]);\n\n  useEffect(() => {\n    setFetching(true);\n    http.get('/api/app/deploy/')\n      .then(res => setDeploys(res))\n      .finally(() => setFetching(false))\n    if (!envStore.records.length) {\n      envStore.fetchRecords().then(_initEnv)\n    } else {\n      _initEnv()\n    }\n  }, [])\n\n  function _initEnv() {\n    if (envStore.records.length) {\n      setEnvId(envStore.records[0].id)\n    }\n  }\n\n  let records = deploys.filter(x => x.env_id === Number(env_id));\n  if (search) records = records.filter(x => includes(x['app_name'], search) || includes(x['app_key'], search));\n  if (props.filter) records = records.filter(x => props.filter(x));\n  return (\n    <Modal\n      visible={props.visible}\n      width={800}\n      maskClosable={false}\n      title=\"选择应用\"\n      bodyStyle={{padding: 0}}\n      onCancel={props.onCancel}\n      footer={null}>\n      <div className={styles.appSelector}>\n        <div className={styles.left}>\n          <Spin spinning={envStore.isFetching}>\n            <Menu\n              mode=\"inline\"\n              selectedKeys={[String(env_id)]}\n              style={{border: 'none'}}\n              items={envStore.records.map(x => ({key: x.id, label: x.name, title: x.name}))}\n              onSelect={({selectedKeys}) => setEnvId(selectedKeys[0])}/>\n          </Spin>\n        </div>\n\n        <div className={styles.right}>\n          <Spin spinning={fetching}>\n            <div className={styles.title}>\n              <div className={styles.text}>{lds.get(envStore.idMap, `${env_id}.name`)}</div>\n              <Input\n                allowClear\n                style={{width: 200}}\n                placeholder=\"请输入快速检索应用\"\n                prefix={<SearchOutlined style={{color: 'rgba(0, 0, 0, 0.25)'}}/>}\n                onChange={e => setSearch(e.target.value)}/>\n            </div>\n            <div style={{height: 540, overflow: 'auto'}}>\n              {records.map(item => (\n                <div key={item.id} className={styles.appItem} onClick={() => props.onSelect(item)}>\n                  {item.extend === '1' ? <OrderedListOutlined/> : <BuildOutlined/>}\n                  <div className={styles.body}>{item.app_name}</div>\n                  <div style={{color: '#999'}}>{item.app_key}</div>\n                </div>\n              ))}\n              {records.length === 0 &&\n              <div className={styles.tips}>该环境下还没有可发布或构建的应用哦，快去<Link to=\"/deploy/app\">应用管理</Link>创建应用发布配置吧。</div>}\n            </div>\n          </Spin>\n        </div>\n      </div>\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/components/AuthButton.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Button } from 'antd';\nimport { hasPermission } from 'libs';\n\n\nexport default function AuthButton(props) {\n  let disabled = props.disabled;\n  if (props.auth && !hasPermission(props.auth)) {\n    disabled = true;\n  }\n  return disabled ? null : <Button {...props}>{props.children}</Button>\n}\n"
  },
  {
    "path": "spug_web/src/components/AuthCard.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport {Card} from 'antd';\nimport { hasPermission } from 'libs';\n\n\nexport default function AuthCard(props) {\n  let disabled = props.disabled === undefined ? false : props.disabled;\n  if (props.auth && !hasPermission(props.auth)) {\n    disabled = true;\n  }\n  return disabled ? null : <Card {...props}>{props.children}</Card>\n}\n"
  },
  {
    "path": "spug_web/src/components/AuthDiv.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { hasPermission } from 'libs';\n\n\nexport default function AuthDiv(props) {\n  let disabled = props.disabled === undefined ? false : props.disabled;\n  if (props.auth && !hasPermission(props.auth)) {\n    disabled = true;\n  }\n  return disabled ? null : <div {...props}>{props.children}</div>\n}\n"
  },
  {
    "path": "spug_web/src/components/AuthFragment.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { hasPermission } from 'libs';\n\n\nexport default function AuthFragment(props) {\n  return hasPermission(props.auth) ? <React.Fragment>{props.children}</React.Fragment> : null\n}\n"
  },
  {
    "path": "spug_web/src/components/Breadcrumb.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Breadcrumb } from 'antd';\nimport styles from './index.module.less';\n\n\nexport default class extends React.Component {\n  static Item = Breadcrumb.Item\n\n  render() {\n    let title = this.props.title;\n    if (!title) {\n      const rawChildren = this.props.children;\n      if (Array.isArray(rawChildren)) {\n        title = rawChildren[rawChildren.length - 1].props.children\n      } else {\n        title = rawChildren.props.children\n      }\n    }\n\n    return (\n      <div className={styles.breadcrumb}>\n        <Breadcrumb>\n          {this.props.children}\n        </Breadcrumb>\n        {this.props.extra ? (\n          <div className={styles.title}>\n            <span>{title}</span>\n            {this.props.extra}\n          </div>\n        ) : null}\n      </div>\n    )\n  }\n}"
  },
  {
    "path": "spug_web/src/components/Link.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react'\n\n\nfunction Link(props) {\n  return (\n    <a\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      href={props.href}\n      className={props.className}>\n      {props.title}</a>\n  )\n}\n\nexport default Link"
  },
  {
    "path": "spug_web/src/components/LinkButton.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Button } from 'antd';\nimport { hasPermission } from 'libs';\n\n\nexport default function LinkButton(props) {\n  let disabled = props.disabled;\n  if (props.auth && !hasPermission(props.auth)) {\n    disabled = true;\n  }\n  return <Button {...props} type=\"link\" style={{padding: 0}} disabled={disabled}>\n    {props.children}\n  </Button>\n}\n"
  },
  {
    "path": "spug_web/src/components/NotFound.js",
    "content": "import React from 'react';\nimport styles from './index.module.less';\n\nexport default function NotFound() {\n  return (\n    <div className={styles.notFound}>\n      <div className={styles.imgBlock}>\n        <div className={styles.img}/>\n      </div>\n      <div>\n        <h1 className={styles.title}>404</h1>\n        <div className={styles.desc}>抱歉，你访问的页面不存在</div>\n      </div>\n    </div>\n  )\n}"
  },
  {
    "path": "spug_web/src/components/SearchForm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Row, Col, Form } from 'antd';\nimport styles from './index.module.less';\n\nexport default class extends React.Component {\n  static Item(props) {\n    return (\n      <Col span={props.span} offset={props.offset} style={props.style}>\n        <Form.Item label={props.title}>\n          {props.children}\n        </Form.Item>\n      </Col>\n    )\n  }\n\n  render() {\n    return (\n      <div className={styles.searchForm} style={this.props.style}>\n        <Form style={this.props.style}>\n          <Row gutter={{md: 8, lg: 24, xl: 48}}>\n            {this.props.children}\n          </Row>\n        </Form>\n      </div>\n    )\n  }\n}\n"
  },
  {
    "path": "spug_web/src/components/StatisticsCard.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Card, Col, Row } from 'antd';\nimport lodash from 'lodash';\nimport styles from './index.module.less';\n\n\nclass StatisticsCard extends React.Component {\n  static Item = (props) => {\n    return (\n      <div className={styles.statisticsCard}>\n        <span>{props.title}</span>\n        <p>{props.value}</p>\n        {props.bordered !== false && <em/>}\n      </div>\n    )\n  };\n\n  render() {\n    let items = lodash.get(this.props, 'children', []);\n    if (!lodash.isArray(items)) items = [items];\n    const span = Math.ceil(24 / (items.length || 1));\n    return (\n      <Card bordered={false} style={{marginBottom: '24px'}}>\n        <Row>\n          {items.map((item, index) => (\n            <Col key={index} sm={span} xs={24}>\n              {item}\n            </Col>\n          ))}\n        </Row>\n      </Card>\n    )\n  }\n}\n\nexport default StatisticsCard\n"
  },
  {
    "path": "spug_web/src/components/TableCard.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect, useRef } from 'react';\nimport { Table, Space, Divider, Popover, Checkbox, Button, Input, Select } from 'antd';\nimport { ReloadOutlined, SettingOutlined, FullscreenOutlined, SearchOutlined } from '@ant-design/icons';\nimport styles from './index.module.less';\n\nlet TableFields = localStorage.getItem('TableFields')\nTableFields = TableFields ? JSON.parse(TableFields) : {}\n\nfunction Search(props) {\n  let keys = props.keys || [''];\n  keys = keys.map(x => x.split('/'));\n  const [key, setKey] = useState(keys[0][0]);\n  return (\n    <Input\n      allowClear\n      style={{width: '280px'}}\n      placeholder=\"输入检索\"\n      prefix={<SearchOutlined style={{color: '#c0c0c0'}}/>}\n      onChange={e => props.onChange(key, e.target.value)}\n      addonBefore={(\n        <Select value={key} onChange={setKey}>\n          {keys.map(item => (\n            <Select.Option key={item[0]} value={item[0]}>{item[1]}</Select.Option>\n          ))}\n        </Select>\n      )}/>\n  )\n}\n\nfunction Footer(props) {\n  const actions = props.actions || [];\n  const length = props.selected.length;\n  return length > 0 ? (\n    <div className={styles.tableFooter}>\n      <div className={styles.left}>已选择 <span>{length}</span> 项</div>\n      <Space size=\"middle\">\n        {actions.map((item, index) => (\n          <React.Fragment key={index}>{item}</React.Fragment>\n        ))}\n      </Space>\n    </div>\n  ) : null\n}\n\nfunction Header(props) {\n  const columns = props.columns || [];\n  const actions = props.actions || [];\n  const fields = props.fields || [];\n  const onFieldsChange = props.onFieldsChange;\n\n  const Fields = () => {\n    return (\n      <Checkbox.Group value={fields} onChange={onFieldsChange}>\n        {columns.map((item, index) => (\n          <Checkbox value={index} key={index}>{item.title}</Checkbox>\n        ))}\n      </Checkbox.Group>\n    )\n  }\n\n  function handleCheckAll(e) {\n    if (e.target.checked) {\n      onFieldsChange(columns.map((_, index) => index))\n    } else {\n      onFieldsChange([])\n    }\n  }\n\n  function handleFullscreen() {\n    if (props.rootRef.current && document.fullscreenEnabled) {\n      if (document.fullscreenElement) {\n        document.exitFullscreen()\n      } else {\n        props.rootRef.current.requestFullscreen()\n      }\n    }\n  }\n\n  return (\n    <div className={styles.toolbar}>\n      <div className={styles.title}>{props.title}</div>\n      <div className={styles.option}>\n        <Space size=\"middle\" style={{marginRight: 10}}>\n          {actions.map((item, index) => (\n            <React.Fragment key={index}>{item}</React.Fragment>\n          ))}\n        </Space>\n        {actions.length ? <Divider type=\"vertical\"/> : null}\n        <Space className={styles.icons}>\n          <ReloadOutlined onClick={props.onReload}/>\n          <Popover\n            arrowPointAtCenter\n            destroyTooltipOnHide={{keepParent: false}}\n            title={[\n              <Checkbox\n                key=\"1\"\n                checked={fields.length === columns.length}\n                indeterminate={![0, columns.length].includes(fields.length)}\n                onChange={handleCheckAll}>列展示</Checkbox>,\n              <Button\n                key=\"2\"\n                type=\"link\"\n                style={{padding: 0}}\n                onClick={() => onFieldsChange(props.defaultFields)}>重置</Button>\n            ]}\n            overlayClassName={styles.tableFields}\n            trigger=\"click\"\n            placement=\"bottomRight\"\n            content={<Fields/>}>\n            <SettingOutlined/>\n          </Popover>\n          <FullscreenOutlined onClick={handleFullscreen}/>\n        </Space>\n      </div>\n    </div>\n  )\n}\n\nfunction TableCard(props) {\n  const rootRef = useRef();\n  const batchActions = props.batchActions || [];\n  const selected = props.selected || [];\n  const [fields, setFields] = useState([]);\n  const [defaultFields, setDefaultFields] = useState([]);\n  const [columns, setColumns] = useState([]);\n\n  useEffect(() => {\n    let [_columns, _fields] = [props.columns, []];\n    if (props.children) {\n      if (Array.isArray(props.children)) {\n        _columns = props.children.filter(x => x.props).map(x => x.props)\n      } else {\n        _columns = [props.children.props]\n      }\n    }\n    let hideFields = _columns.filter(x => x.hide).map(x => x.title)\n    if (props.tKey) {\n      if (TableFields[props.tKey]) {\n        hideFields = TableFields[props.tKey]\n      } else {\n        TableFields[props.tKey] = hideFields\n        localStorage.setItem('TableFields', JSON.stringify(TableFields))\n      }\n    }\n    for (let [index, item] of _columns.entries()) {\n      if (!hideFields.includes(item.title)) _fields.push(index)\n    }\n    setFields(_fields);\n    setColumns(_columns);\n    setDefaultFields(_fields);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleFieldsChange(fields) {\n    setFields(fields)\n    if (props.tKey) {\n      TableFields[props.tKey] = columns.filter((_, index) => !fields.includes(index)).map(x => x.title)\n      localStorage.setItem('TableFields', JSON.stringify(TableFields))\n    }\n  }\n\n  return (\n    <div ref={rootRef} className={styles.tableCard}>\n      <Header\n        title={props.title}\n        columns={columns}\n        actions={props.actions}\n        fields={fields}\n        rootRef={rootRef}\n        defaultFields={defaultFields}\n        onFieldsChange={handleFieldsChange}\n        onReload={props.onReload}/>\n      <Table\n        tableLayout={props.tableLayout}\n        scroll={props.scroll}\n        rowKey={props.rowKey}\n        loading={props.loading}\n        columns={columns.filter((_, index) => fields.includes(index))}\n        dataSource={props.dataSource}\n        rowSelection={props.rowSelection}\n        expandable={props.expandable}\n        pagination={props.pagination}/>\n      {selected.length ? <Footer selected={selected} actions={batchActions}/> : null}\n    </div>\n  )\n}\n\nTableCard.Search = Search;\nexport default TableCard"
  },
  {
    "path": "spug_web/src/components/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport StatisticsCard from './StatisticsCard';\nimport SearchForm from './SearchForm';\nimport LinkButton from './LinkButton';\nimport AuthButton from './AuthButton';\nimport AuthFragment from './AuthFragment';\nimport AuthCard from './AuthCard';\nimport AuthDiv from './AuthDiv';\nimport ACEditor from './ACEditor';\nimport Action from './Action';\nimport TableCard from './TableCard';\nimport Breadcrumb from './Breadcrumb';\nimport AppSelector from './AppSelector';\nimport NotFound from './NotFound';\nimport Link from './Link';\n\nexport {\n  StatisticsCard,\n  AuthFragment,\n  SearchForm,\n  LinkButton,\n  AuthButton,\n  AuthCard,\n  AuthDiv,\n  ACEditor,\n  Action,\n  TableCard,\n  Breadcrumb,\n  AppSelector,\n  NotFound,\n  Link,\n}\n"
  },
  {
    "path": "spug_web/src/components/index.module.less",
    "content": ".searchForm {\n  padding: 24px 24px 0 24px;\n  background-color: #fff;\n  border-radius: 2px;\n  margin-bottom: 16px;\n\n  :global(.ant-form-item) {\n    display: flex;\n  }\n\n  :global(.ant-form-item-control-wrapper) {\n    flex: 1;\n  }\n\n  :global(.ant-form-item-label) {\n    padding-right: 8px;\n  }\n\n  :global(.ant-form-item-control) {\n    overflow: hidden;\n  }\n}\n\n.statisticsCard {\n  position: relative;\n  text-align: center;\n\n  span {\n    color: rgba(0, 0, 0, .45);\n    display: inline-block;\n    line-height: 22px;\n    margin-bottom: 4px;\n  }\n\n  p {\n    font-size: 32px;\n    line-height: 32px;\n    margin: 0;\n  }\n\n  em {\n    background-color: #e8e8e8;\n    position: absolute;\n    height: 56px;\n    width: 1px;\n    top: 0;\n    right: 0;\n  }\n}\n\n.tableCard {\n  border: 1px solid #f0f0f0;\n  background: #fff;\n  border-radius: 2px;\n  padding: 24px;\n\n  .toolbar {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    margin-bottom: 20px;\n\n    .title {\n      flex: 1;\n      font-weight: 500;\n      font-size: 16px;\n      opacity: 0.8;\n      margin-right: 24px;\n    }\n\n    .option {\n      display: flex;\n      align-items: center;\n      justify-content: flex-end;\n\n      .icons {\n        :global(.anticon) {\n          font-size: 16px;\n          margin-left: 8px;\n        }\n      }\n    }\n  }\n}\n\n.tableFields {\n  :global(.ant-popover-title) {\n    padding: 10px 16px;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n  }\n\n  :global(.ant-popover-inner-content) {\n    padding: 8px 0;\n\n    :global(.ant-checkbox-group) {\n      display: flex;\n      flex-direction: column;\n    }\n\n    :global(.ant-checkbox-wrapper) {\n      height: 30px;\n      line-height: 30px;\n      margin: 0;\n      padding: 0 16px;\n    }\n\n    :global(.ant-checkbox-wrapper):hover {\n      background: rgba(0, 0, 0, 0.025)\n    }\n  }\n}\n\n.tableFooter {\n  position: fixed;\n  right: 0;\n  bottom: 0;\n  display: flex;\n  align-items: center;\n  height: 48px;\n  width: calc(100% - 208px);\n  padding: 0 24px;\n  background: #fff;\n\n  .left {\n    flex: 1;\n\n    span {\n      color: #1890ff;\n      font-weight: 600;\n    }\n  }\n}\n\n.breadcrumb {\n  margin: -24px -24px 24px -24px;\n  padding: 16px 24px;\n  background: #fff;\n  border-bottom: 1px solid #e8e8e8;\n\n  .title {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    font-size: 20px;\n    margin-top: 8px;\n  }\n}\n\n.appSelector {\n  display: flex;\n  background-color: #fff;\n  padding: 16px 0;\n  min-height: 500px;\n\n  .left {\n    flex: 220px;\n    border-right: 1px solid #e8e8e8;\n    overflow: hidden;\n  }\n\n  .right {\n    flex: 580px;\n    padding: 8px 40px;\n  }\n\n  .title {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 24px;\n    color: rgba(0, 0, 0, .85);\n    font-weight: 500;\n    font-size: 20px;\n    line-height: 28px;\n\n    .text {\n      padding-right: 12px;\n      width: 300px;\n      overflow: hidden;\n      white-space: nowrap;\n      text-overflow: ellipsis;\n    }\n  }\n\n  .appItem {\n    display: flex;\n    align-items: center;\n    padding: 12px 16px;\n    margin-bottom: 8px;\n    background: #fafafa;\n    border-radius: 2px;\n\n    :global(.anticon) {\n      color: #1890ff;\n      margin-right: 16px;\n    }\n\n    .body {\n      flex: 1;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n  }\n\n  .appItem:hover {\n    cursor: pointer;\n    background: #e6f7ff;\n  }\n\n  .tips {\n    margin-top: 32px;\n    color: #888;\n  }\n}\n\n\n.notFound {\n  display: flex;\n  height: 80%;\n  align-items: center;\n\n  .imgBlock {\n    flex: 0 0 62.5%;\n    width: 62.5%;\n    zoom: 1;\n    padding-right: 88px;\n\n    .img {\n      float: right;\n      height: 360px;\n      width: 100%;\n      max-width: 430px;\n      background-size: contain;\n      background: url('./404.svg') no-repeat 50% 50%;\n    }\n  }\n\n  .title {\n    color: #434e59;\n    font-size: 72px;\n    font-weight: 600;\n    line-height: 72px;\n    margin-bottom: 24px;\n  }\n\n  .desc {\n    color: rgba(0, 0, 0, .45);\n    font-size: 20px;\n    line-height: 28px;\n    margin-bottom: 16px;\n  }\n}"
  },
  {
    "path": "spug_web/src/gStore.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from 'mobx';\nimport http from 'libs/http';\nimport themes from 'pages/ssh/themes';\n\nclass Store {\n  isReady = false;\n  @observable terminal = {\n    fontSize: 16,\n    fontFamily: 'Courier',\n    theme: 'dark',\n    styles: themes['dark']\n  };\n\n  _handleSettings = (res) => {\n    if (res.terminal) {\n      const terminal = JSON.parse(res.terminal)\n      const styles = themes[terminal.theme]\n      if (styles) {\n        terminal.styles = styles\n      } else {\n        terminal.styles = themes['dark']\n        terminal.theme = 'dark'\n      }\n      this.terminal = terminal\n    }\n  }\n\n  fetchUserSettings = () => {\n    if (this.isReady) return\n    http.get('/api/setting/user/')\n      .then(res => {\n        this.isReady = true\n        this._handleSettings(res)\n      })\n  };\n\n  updateUserSettings = (key, value) => {\n    return http.post('/api/setting/user/', {key, value})\n      .then(res => {\n        this.isReady = true\n        this._handleSettings(res)\n      })\n  }\n}\n\nexport default new Store()"
  },
  {
    "path": "spug_web/src/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport { Router } from 'react-router-dom';\nimport { ConfigProvider } from 'antd';\nimport zhCN from 'antd/es/locale/zh_CN';\nimport './index.less';\nimport App from './App';\nimport moment from 'moment';\nimport 'moment/locale/zh-cn';\nimport * as serviceWorker from './serviceWorker';\nimport { history, updatePermissions } from 'libs';\n\nmoment.locale('zh-cn');\nupdatePermissions();\n\nReactDOM.render(\n  <Router history={history}>\n    <ConfigProvider locale={zhCN} getPopupContainer={() => document.fullscreenElement || document.body}>\n      <App/>\n    </ConfigProvider>\n  </Router>,\n  document.getElementById('root')\n);\n\n// If you want your app to work offline and load faster, you can change\n// unregister() to register() below. Note this comes with some pitfalls.\n// Learn more about service workers: https://bit.ly/CRA-PWA\nserviceWorker.unregister();\n"
  },
  {
    "path": "spug_web/src/index.less",
    "content": "@import '~antd/dist/antd.less';\n\nbody {\n  margin: 0;\n  font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  overflow: hidden;\n}\n\ncode {\n  font-family: Source Code Pro, Menlo, Monaco, Consolas, Courier New, monospace, Courier, PingFang SC, Microsoft YaHei;\n}\n\n.ant-form-item-extra {\n  font-size: 13px;\n  padding-top: 6px;\n}\n\n/* Common CSS style */\n.none {\n  display: none;\n}\n\n.btn {\n  color: #2563fc;\n  cursor: pointer;\n}"
  },
  {
    "path": "spug_web/src/layout/Footer.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Layout } from 'antd';\nimport { CopyrightOutlined, GithubOutlined } from '@ant-design/icons';\nimport styles from './layout.module.less';\n\n\nexport default function () {\n  return (\n    <Layout.Footer style={{padding: 0}}>\n      <div className={styles.footer}>\n        <div className={styles.links}>\n          <a className={styles.item} title=\"官网\" href=\"https://spug.cc\" target=\"_blank\"\n             rel=\"noopener noreferrer\">官网</a>\n          <a className={styles.item} title=\"Github\" href=\"https://github.com/openspug/spug\" target=\"_blank\"\n             rel=\"noopener noreferrer\"><GithubOutlined/></a>\n          <a title=\"文档\" href=\"https://ops.spug.cc/docs/about-spug/\" target=\"_blank\"\n             rel=\"noopener noreferrer\">文档</a>\n        </div>\n        <div style={{color: 'rgba(0, 0, 0, .45)'}}>\n          Copyright <CopyrightOutlined/> {new Date().getFullYear()} By OpenSpug\n        </div>\n      </div>\n    </Layout.Footer>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/layout/Header.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Link } from 'react-router-dom';\nimport { Layout, Dropdown, Menu, Avatar, Divider } from 'antd';\nimport { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, LogoutOutlined, CodeOutlined, DownOutlined } from '@ant-design/icons';\nimport { AuthDiv } from 'components';\nimport Notification from './Notification';\nimport styles from './layout.module.less';\nimport http from '../libs/http';\nimport history from '../libs/history';\nimport avatar from './avatar.png';\n\nexport default function (props) {\n\n  function handleLogout() {\n    history.push('/');\n    http.get('/api/account/logout/')\n  }\n\n  function openTerminal() {\n    window.open('/ssh')\n  }\n\n  const UserMenu = (\n    <Menu>\n      <Menu.Item>\n        <Link to=\"/welcome/info\">\n          <UserOutlined style={{marginRight: 10}}/>个人中心\n        </Link>\n      </Menu.Item>\n      <Menu.Divider/>\n      <Menu.Item onClick={handleLogout}>\n        <LogoutOutlined style={{marginRight: 10}}/>退出登录\n      </Menu.Item>\n    </Menu>\n  );\n\n  const ToolsMenu = (\n    <Menu>\n      <Menu.Item onClick={() => window.open('https://ssl.spug.cc')}>\n        免费证书\n      </Menu.Item>\n      <Menu.Item onClick={() => window.open('https://up.spug.cc')}>\n        免费监控\n      </Menu.Item>\n      <Menu.Item onClick={() => window.open('https://push.spug.cc')}>\n        推送助手\n      </Menu.Item>\n    </Menu>\n  );\n\n  return (\n    <Layout.Header className={styles.header}>\n      <div className={styles.trigger} onClick={props.toggle}>\n        {props.collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}\n      </div>\n      <div className={styles.right}>\n        <div className={styles.link} onClick={() => window.open('https://spug.cc/')}>官网</div>\n        <div className={styles.link} onClick={() => window.open('https://ops.spug.cc/docs/about-spug/')}>文档</div>\n        <Dropdown overlay={ToolsMenu} placement=\"bottomCenter\">\n          <span className={styles.link}>\n            工具服务 <DownOutlined style={{fontSize: 12}}/>\n          </span>\n        </Dropdown>\n        <Divider type=\"vertical\"/>\n        <Notification/>\n        <AuthDiv className={styles.terminal} auth=\"host.console.view|host.console.list\" onClick={openTerminal}>\n          <CodeOutlined style={{fontSize: 16}}/>\n        </AuthDiv>\n        <div className={styles.user}>\n          <Dropdown overlay={UserMenu} style={{background: '#000'}}>\n            <span className={styles.action}>\n              <Avatar size=\"small\" src={avatar} style={{marginRight: 8}}/>\n              {localStorage.getItem('nickname')}\n            </span>\n          </Dropdown>\n        </div>\n      </div>\n    </Layout.Header>\n  )\n}"
  },
  {
    "path": "spug_web/src/layout/Notification.js",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Menu, List, Dropdown, Badge, Button, notification } from 'antd';\nimport {\n  NotificationOutlined,\n  MonitorOutlined,\n  FlagOutlined,\n  ScheduleOutlined,\n  AlertOutlined\n} from '@ant-design/icons';\nimport { http, X_TOKEN } from 'libs';\nimport moment from 'moment';\nimport styles from './layout.module.less';\n\nlet ws = {readyState: 3};\nlet timer;\n\n\nfunction Icon(props) {\n  switch (props.type) {\n    case 'monitor':\n      return <MonitorOutlined style={{fontSize: 24, color: '#1890ff'}}/>\n    case 'schedule':\n      return <ScheduleOutlined style={{fontSize: 24, color: '#1890ff'}}/>\n    case 'flag':\n      return <FlagOutlined style={{fontSize: 24, color: '#1890ff'}}/>\n    case 'alert':\n      return <AlertOutlined style={{fontSize: 24, color: '#ff4d4f'}}/>\n    default:\n      return null\n  }\n}\n\nexport default function () {\n  const [loading, setLoading] = useState(false);\n  const [notifies, setNotifies] = useState([]);\n  const [reads, setReads] = useState([]);\n\n  useEffect(() => {\n    fetch();\n    listen();\n    timer = setInterval(() => {\n      if (ws.readyState === 1) {\n        ws.send('ping')\n      } else if (ws.readyState === 3) {\n        listen()\n      }\n    }, 10000)\n    return () => {\n      if (timer) clearInterval(timer);\n      if (ws.close) ws.close()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function fetch() {\n    setLoading(true);\n    http.get('/api/notify/')\n      .then(res => {\n        setReads(res.filter(x => !x.unread).map(x => x.id))\n        setNotifies(res);\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function listen() {\n    if (!X_TOKEN) return;\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    ws = new WebSocket(`${protocol}//${window.location.host}/api/ws/notify/?x-token=${X_TOKEN}`);\n    ws.onopen = () => ws.send('ok');\n    ws.onmessage = e => {\n      if (e.data !== 'pong') {\n        fetch();\n        try {\n          const {title, content} = JSON.parse(e.data);\n          const key = `open${Date.now()}`;\n          const description = <div style={{whiteSpace: 'pre-wrap'}}>{content}</div>;\n          const btn = <Button type=\"primary\" size=\"small\" onClick={() => notification.close(key)}>知道了</Button>;\n          notification.warning({message: title, description, btn, key, top: 64, duration: null})\n        } catch (e) {\n\n        }\n      }\n    }\n  }\n\n  function handleVisible(visible) {\n    if (visible) {\n      fetch()\n    }\n  }\n\n  function handleRead(e, item) {\n    e.stopPropagation();\n    if (reads.indexOf(item.id) === -1) {\n      reads.push(item.id);\n      setReads([...reads])\n      http.patch('/api/notify/', {ids: [item.id]})\n    }\n  }\n\n  function handleReadAll() {\n    const ids = notifies.map(x => x.id);\n    setReads(ids);\n    http.patch('/api/notify/', {ids})\n  }\n\n  const count = notifies.length - reads.length;\n  return (\n    <div className={styles.notification}>\n      <Dropdown trigger={['click']} onVisibleChange={handleVisible} overlay={(\n        <Menu className={styles.notify}>\n          <Menu.Item style={{padding: 0, whiteSpace: 'unset'}}>\n            <List\n              loading={loading}\n              style={{maxHeight: 500, overflow: 'scroll'}}\n              itemLayout=\"horizontal\"\n              dataSource={notifies}\n              renderItem={item => (\n                <List.Item className={styles.item} onClick={e => handleRead(e, item)}>\n                  <List.Item.Meta\n                    style={{opacity: reads.includes(item.id) ? 0.4 : 1}}\n                    avatar={<Icon type={item.source}/>}\n                    title={<span style={{fontWeight: 400, color: '#404040'}}>{item.title}</span>}\n                    description={[\n                      <div key=\"1\" style={{fontSize: 12, overflowWrap: 'anywhere'}}>{item.content}</div>,\n                      <div key=\"2\" style={{fontSize: 12}}>{moment(item['created_at']).fromNow()}</div>\n                    ]}/>\n                </List.Item>\n              )}/>\n            {notifies.length !== 0 && (\n              <div className={styles.btn} onClick={handleReadAll}>全部 已读</div>\n            )}\n          </Menu.Item>\n        </Menu>\n      )}>\n        <div className={styles.trigger}>\n          <Badge count={count > 0 ? count : 0}>\n            <NotificationOutlined style={{fontSize: 16}}/>\n          </Badge>\n        </div>\n      </Dropdown>\n    </div>\n  )\n}"
  },
  {
    "path": "spug_web/src/layout/Sider.js",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Layout, Menu } from 'antd';\nimport { hasPermission, history } from 'libs';\nimport styles from './layout.module.less';\nimport routes from '../routes';\nimport logo from './logo-spug-white.png';\n\nlet selectedKey = window.location.pathname;\nconst OpenKeysMap = {};\nfor (let item of routes) {\n  if (item.child) {\n    for (let sub of item.child) {\n      if (sub.title) OpenKeysMap[sub.path] = item.title\n    }\n  } else if (item.title) {\n    OpenKeysMap[item.path] = 1\n  }\n}\n\nexport default function Sider(props) {\n  const [openKeys, setOpenKeys] = useState([]);\n  const [menus, setMenus] = useState([]);\n\n  useEffect(() => {\n    const tmp = []\n    for (let item of routes) {\n      const menu = handleRoute(item)\n      tmp.push(menu)\n    }\n    setMenus(tmp)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleRoute(item) {\n    if (item.auth && !hasPermission(item.auth)) return\n    if (!item.title) return;\n    const menu = {label: item.title, key: item.path, icon: item.icon}\n    if (item.child) {\n      menu.children = []\n      for (let sub of item.child) {\n        const subMenu = handleRoute(sub)\n        menu.children.push(subMenu)\n      }\n    }\n    return menu\n  }\n\n  const tmp = window.location.pathname;\n  const openKey = OpenKeysMap[tmp];\n  if (openKey) {\n    selectedKey = tmp;\n    if (openKey !== 1 && !props.collapsed && !openKeys.includes(openKey)) {\n      setOpenKeys([...openKeys, openKey])\n    }\n  }\n  return (\n    <Layout.Sider width={208} collapsed={props.collapsed} className={styles.sider}>\n      <div className={styles.logo}>\n        <img src={logo} alt=\"Logo\"/>\n      </div>\n      <div className={styles.menus} style={{height: `${document.body.clientHeight - 64}px`}}>\n        <Menu\n          theme=\"dark\"\n          mode=\"inline\"\n          items={menus}\n          className={styles.menus}\n          selectedKeys={[selectedKey]}\n          openKeys={openKeys}\n          onOpenChange={setOpenKeys}\n          onSelect={menu => history.push(menu.key)}/>\n      </div>\n    </Layout.Sider>\n  )\n}"
  },
  {
    "path": "spug_web/src/layout/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Switch, Route } from 'react-router-dom';\nimport { Layout, message } from 'antd';\nimport { NotFound } from 'components';\nimport Sider from './Sider';\nimport Header from './Header';\nimport Footer from './Footer'\nimport routes from '../routes';\nimport { hasPermission, isMobile } from 'libs';\nimport styles from './layout.module.less';\n\nfunction initRoutes(Routes, routes) {\n  for (let route of routes) {\n    if (route.component) {\n      if (!route.auth || hasPermission(route.auth)) {\n        Routes.push(<Route exact key={route.path} path={route.path} component={route.component}/>)\n      }\n    } else if (route.child) {\n      initRoutes(Routes, route.child)\n    }\n  }\n}\n\nexport default function () {\n  const [collapsed, setCollapsed] = useState(false)\n  const [Routes, setRoutes] = useState([]);\n\n  useEffect(() => {\n     if (isMobile) {\n      setCollapsed(true);\n      message.warn('检测到您在移动设备上访问，请使用横屏模式。', 5)\n    }\n    const Routes = [];\n    initRoutes(Routes, routes);\n    setRoutes(Routes)\n  }, [])\n\n  return (\n    <Layout>\n      <Sider collapsed={collapsed}/>\n      <Layout style={{height: '100vh'}}>\n        <Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)}/>\n        <Layout.Content className={styles.content} id=\"spug-container\">\n          <Switch>\n            {Routes}\n            <Route component={NotFound}/>\n          </Switch>\n        </Layout.Content>\n        <Footer/>\n      </Layout>\n    </Layout>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/layout/layout.module.less",
    "content": ".header {\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n  padding: 0 12px 0 0;;\n  height: 48px;\n  line-height: 48px;\n  background: #fff;\n  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);\n  z-index: 2;\n\n  .right {\n    display: flex;\n    flex-direction: row;\n    align-items: center;\n\n    .link {\n      color: #333333;\n      cursor: pointer;\n      padding: 0 16px;\n\n      &:hover {\n        background: rgba(0, 0, 0, 0.025);\n      }\n    }\n  }\n\n  .terminal {\n    padding: 0 12px;\n    cursor: pointer;\n    line-height: 48px;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n\n    &:hover {\n      background: rgba(0, 0, 0, 0.025);\n    }\n  }\n\n  .user {\n    .action {\n      cursor: pointer;\n      padding: 0 12px;\n      display: inline-block;\n      transition: all 0.3s;\n      height: 100%;\n    }\n\n    .action:hover {\n      background: rgba(0, 0, 0, 0.025);\n    }\n  }\n\n  .trigger {\n    cursor: pointer;\n    transition: all 0.3s, padding 0s;\n    padding: 0 12px;\n  }\n\n  .trigger:hover {\n    background: rgba(0, 0, 0, 0.025);\n  }\n}\n\n.notify {\n  width: 350px;\n  padding: 0;\n\n  .item {\n    align-items: center;\n    cursor: pointer;\n    padding: 12px 24px;\n  }\n\n  .item:hover {\n    background-color: rgb(233, 247, 254);\n  }\n\n  .btn {\n    line-height: 46px;\n    text-align: center;\n    cursor: pointer;\n    border-top: 1px solid #e8e8e8;\n  }\n}\n\n.notify :global(.ant-dropdown-menu-item:hover) {\n  background-color: #fff;\n}\n\n.content {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  padding: 24px;\n  overflow-y: scroll;\n}\n\n.content::-webkit-scrollbar {\n  width: 0;\n  height: 0;\n}\n\n.sider {\n  height: 100%;\n  width: 208px;\n  min-height: 100vh;\n  box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);\n  overflow: hidden;\n\n  .menus {\n    overflow: auto;\n  }\n\n  .menus::-webkit-scrollbar {\n    width: 0;\n    height: 0;\n  }\n\n  .logo {\n    height: 64px;\n    line-height: 64px;\n    overflow: hidden;\n    text-align: center;\n  }\n\n  .logo img {\n    height: 30px;\n  }\n}\n\n.footer {\n  width: 100%;\n  padding: 20px;\n  font-size: 14px;\n  text-align: center;\n  display: flex;\n  flex-direction: column;\n\n  .links {\n    margin-bottom: 7px;\n\n    .item {\n      margin-right: 40px;\n    }\n  }\n}"
  },
  {
    "path": "spug_web/src/libs/functools.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nlet Permission = {\n  isReady: false,\n  isSuper: false,\n  permissions: []\n};\n\nexport let X_TOKEN;\nexport const isMobile = /Android|iPhone/i.test(navigator.userAgent)\n\nexport function updatePermissions() {\n  X_TOKEN = localStorage.getItem('token');\n  Permission.isReady = true;\n  Permission.isSuper = localStorage.getItem('is_supper') === 'true';\n  try {\n    Permission.permissions = JSON.parse(localStorage.getItem('permissions') || '[]');\n  } catch (e) {\n\n  }\n}\n\n// 前端页面的权限判断(仅作为前端功能展示的控制，具体权限控制应在后端实现)\nexport function hasPermission(strCode) {\n  const {isSuper, permissions} = Permission;\n  if (!strCode || isSuper) return true;\n  for (let or_item of strCode.split('|')) {\n    if (isSubArray(permissions, or_item.split('&'))) {\n      return true\n    }\n  }\n  return false\n}\n\nexport function clsNames(...args) {\n  return args.filter(x => x).join(' ')\n}\n\nfunction isInclude(s, keys) {\n  if (!s) return false\n  if (Array.isArray(keys)) {\n    for (let k of keys) {\n      k = k.toLowerCase()\n      if (s.toLowerCase().includes(k)) return true\n    }\n    return false\n  } else {\n    let k = keys.toLowerCase()\n    return s.toLowerCase().includes(k)\n  }\n}\n\n// 字符串包含判断\nexport function includes(s, keys) {\n  if (Array.isArray(s)) {\n    for (let i of s) {\n      if (isInclude(i, keys)) return true\n    }\n    return false\n  } else {\n    return isInclude(s, keys)\n  }\n}\n\n// 清理输入的命令中包含的\\r符号\nexport function cleanCommand(text) {\n  return text ? text.replace(/\\r\\n/g, '\\n') : ''\n}\n\n//  数组包含关系判断\nexport function isSubArray(parent, child) {\n  for (let item of child) {\n    if (!parent.includes(item.trim())) {\n      return false\n    }\n  }\n  return true\n}\n\n// 用于替换toFixed方法，去除toFixed方法多余的0和小数点\nexport function trimFixed(data, bit) {\n  return String(data.toFixed(bit)).replace(/0*$/, '').replace(/\\.$/, '')\n}\n\n// 日期\nexport function human_date(date) {\n  const now = date || new Date();\n  let month = now.getMonth() + 1;\n  let day = now.getDate();\n  return `${now.getFullYear()}-${month < 10 ? '0' + month : month}-${day < 10 ? '0' + day : day}`\n}\n\n// 时间\nexport function human_time(date) {\n  const now = date || new Date();\n  const hour = now.getHours() < 10 ? '0' + now.getHours() : now.getHours();\n  const minute = now.getMinutes() < 10 ? '0' + now.getMinutes() : now.getMinutes();\n  const second = now.getSeconds() < 10 ? '0' + now.getSeconds() : now.getSeconds();\n  return `${hour}:${minute}:${second}`\n}\n\nexport function human_datetime(date) {\n  return `${human_date(date)} ${human_time(date)}`\n}\n\n// 生成唯一id\nexport function uniqueId() {\n  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {\n    const r = Math.random() * 16 | 0;\n    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)\n  });\n}"
  },
  {
    "path": "spug_web/src/libs/history.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport {createBrowserHistory} from 'history';\n\nexport default createBrowserHistory()\n"
  },
  {
    "path": "spug_web/src/libs/http.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport http from 'axios'\nimport history from './history'\nimport { X_TOKEN } from './functools';\nimport { message } from 'antd';\n\n// response处理\nfunction handleResponse(response) {\n  let result;\n  if (response.status === 401) {\n    result = '会话过期，请重新登录';\n    if (history.location.pathname !== '/') {\n      history.push('/', {from: history.location})\n    } else {\n      return Promise.reject()\n    }\n  } else if (response.status === 200) {\n    if (response.data.error) {\n      result = response.data.error\n    } else if (response.data.hasOwnProperty('data')) {\n      return Promise.resolve(response.data.data)\n    } else if (response.headers['content-type'] === 'application/octet-stream') {\n      return Promise.resolve(response)\n    } else if (!response.config.isInternal) {\n      return Promise.resolve(response.data)\n    } else {\n      result = '无效的数据格式'\n    }\n  } else {\n    result = `请求失败: ${response.status} ${response.statusText}`\n  }\n  message.error(result);\n  return Promise.reject(result)\n}\n\n// 请求拦截器\nhttp.interceptors.request.use(request => {\n  request.isInternal = request.url.startsWith('/api/');\n  if (request.isInternal) {\n    request.headers['X-Token'] = X_TOKEN\n  }\n  request.timeout = request.timeout || 30000;\n  return request;\n});\n\n// 返回拦截器\nhttp.interceptors.response.use(response => {\n  return handleResponse(response)\n}, error => {\n  if (error.response) {\n    return handleResponse(error.response)\n  }\n  const result = '请求异常: ' + error.message;\n  message.error(result);\n  return Promise.reject(result)\n});\n\nexport default http;\n"
  },
  {
    "path": "spug_web/src/libs/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport _http from './http';\nimport _history from './history';\n\nexport * from './functools';\nexport * from './router';\nexport const http = _http;\nexport const history = _history;\nexport const VERSION = 'v3.3.3';\n"
  },
  {
    "path": "spug_web/src/libs/libs.module.css",
    "content": ".container {\n    display: flex;\n    height: 80%;\n    align-items: center;\n}\n\n.imgBlock {\n    flex: 0 0 62.5%;\n    width: 62.5%;\n    zoom: 1;\n    padding-right: 88px;\n}\n\n.img {\n    float: right;\n    height: 360px;\n    width: 100%;\n    max-width: 430px;\n    background-size: contain;\n    background: url('./404.svg') no-repeat 50% 50%;\n}\n\n.title {\n    color: #434e59;\n    font-size: 72px;\n    font-weight: 600;\n    line-height: 72px;\n    margin-bottom: 24px;\n}\n\n.desc {\n    color: rgba(0, 0, 0, .45);\n    font-size: 20px;\n    line-height: 28px;\n    margin-bottom: 16px;\n}"
  },
  {
    "path": "spug_web/src/libs/router.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { Suspense } from 'react';\nimport { Switch, Route } from 'react-router-dom';\nimport moduleRoutes from '../routes';\nimport styles from './libs.module.css';\n\n\n// 创建单个路由\nexport function makeRoute(path, component) {\n  return {subPath: path, component}\n}\n\n// 创建模块路由\nexport function makeModuleRoute(prefix, routes) {\n  return {prefix, routes}\n}\n\n// 404 页面\nfunction NotFound() {\n  return (\n    <div className={styles.container}>\n      <div className={styles.imgBlock}>\n        <div className={styles.img}/>\n      </div>\n      <div>\n        <h1 className={styles.title}>404</h1>\n        <div className={styles.desc}>抱歉，你访问的页面不存在</div>\n      </div>\n    </div>\n  )\n}\n\n// 组合路由\nexport class Router extends React.Component {\n  constructor(props) {\n    super(props);\n    this.routes = [];\n    this.initialRoutes();\n  }\n\n  initialRoutes() {\n    for (let moduleRoute of moduleRoutes) {\n      for (let route of moduleRoute['routes']) {\n        route['path'] = moduleRoute['prefix'] + route['subPath'];\n        this.routes.push(route)\n      }\n    }\n  }\n\n  render() {\n    return (\n      <Suspense fallback={<div>Loading...</div>}>\n        <Switch>\n          {this.routes.map(route => <Route exact strict key={route.path} {...route}/>)}\n          <Route component={NotFound}/>\n        </Switch>\n      </Suspense>\n    )\n  }\n}\n"
  },
  {
    "path": "spug_web/src/pages/alarm/alarm/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Radio, Tag, Tooltip } from 'antd';\nimport { QuestionCircleOutlined } from '@ant-design/icons';\nimport { TableCard } from 'components';\nimport store from './store';\nimport groupStore from '../group/store';\n\n@observer\nclass ComTable extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      groupMap: {}\n    }\n  }\n\n  componentDidMount() {\n    store.fetchRecords();\n    if (groupStore.records.length === 0) {\n      groupStore.fetchRecords().then(this._handleGroups)\n    } else {\n      this._handleGroups()\n    }\n  }\n\n  _handleGroups = () => {\n    const tmp = {};\n    for (let item of groupStore.records) {\n      tmp[item.id] = item.name\n    }\n    this.setState({groupMap: tmp})\n  };\n\n  columns = [{\n    title: '任务名称',\n    dataIndex: 'name',\n  }, {\n    title: '监控类型',\n    dataIndex: 'type',\n  }, {\n    title: '监控对象',\n    dataIndex: 'target'\n  }, {\n    title: '状态',\n    dataIndex: 'status',\n    render: value => value === '1' ? <Tag color=\"orange\">报警发生</Tag> : <Tag color=\"green\">故障恢复</Tag>\n  }, {\n    title: '持续时间',\n    dataIndex: 'duration',\n  }, {\n    title: '通知方式',\n    dataIndex: 'notify_mode',\n  }, {\n    title: '通知对象',\n    dataIndex: 'notify_grp',\n    render: value => value.map(id => this.state.groupMap[id]).join(',')\n  }, {\n    title: '发生时间',\n    dataIndex: 'created_at'\n  }];\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"aa\"\n        rowKey=\"id\"\n        title={(\n          <div style={{display: 'flex', alignItems: 'center'}}>\n            <div>报警历史记录</div>\n            <Tooltip title=\"每天自动清理，仅保留最近30天的报警记录。\">\n              <QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>\n            </Tooltip>\n          </div>\n        )}\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <Radio.Group value={store.f_status} onChange={e => store.f_status = e.target.value}>\n            <Radio.Button value=\"\">全部</Radio.Button>\n            <Radio.Button value=\"1\">报警发生</Radio.Button>\n            <Radio.Button value=\"2\">报警恢复</Radio.Button>\n          </Radio.Group>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}\n        columns={this.columns}/>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/alarm/alarm/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { SyncOutlined } from '@ant-design/icons';\nimport { Input, Button } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"alarm.alarm.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>报警中心</Breadcrumb.Item>\n        <Breadcrumb.Item>报警历史</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"任务名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n        <SearchForm.Item span={8}>\n          <Button type=\"primary\" icon={<SyncOutlined/>} onClick={store.fetchRecords}>刷新</Button>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/alarm/alarm/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable isFetching = false;\n\n  @observable f_name;\n  @observable f_status = '';\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    if (this.f_status) records = records.filter(x => x.status === this.f_status);\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/alarm/alarm/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/alarm/contact/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useMemo } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Tooltip, Checkbox, Divider, message } from 'antd';\nimport { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';\nimport http from 'libs/http';\nimport store from './store';\n\nconst channelConfig = [\n  {\n    key: 'email',\n    label: '邮箱',\n    fields: [\n      { name: 'email', label: '邮箱地址', placeholder: '请输入邮箱地址', testMode: '4' }\n    ]\n  },\n  {\n    key: 'ding',\n    label: '钉钉',\n    fields: [\n      { name: 'ding', label: 'Webhook', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx', testMode: '3' },\n      { name: 'ding_secret', label: 'Secret', placeholder: 'SECxxxxxxxx', extra: '可选，机器人安全设置中的加签密钥' }\n    ],\n    help: { text: '钉钉收不到通知？请参考', link: 'https://ops.spug.cc/docs/use-problem#use-dd', linkText: '官方文档' }\n  },\n  {\n    key: 'feishu',\n    label: '飞书',\n    fields: [\n      { name: 'feishu', label: 'Webhook', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', testMode: '7' },\n      { name: 'feishu_secret', label: 'Secret', placeholder: 'xxxxxxxx', extra: '可选，机器人安全设置中的签名校验密钥' }\n    ]\n  },\n  {\n    key: 'qy_wx',\n    label: '企业微信',\n    fields: [\n      { name: 'qy_wx', label: 'Webhook', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx', testMode: '5' }\n    ]\n  }\n];\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [testLoading, setTestLoading] = useState('0');\n\n  const initialChannels = useMemo(() => ({\n    email: !!store.record.email,\n    ding: !!store.record.ding,\n    feishu: !!store.record.feishu,\n    qy_wx: !!store.record.qy_wx,\n  }), []);\n\n  const [channels, setChannels] = useState(initialChannels);\n\n  function handleChannelToggle(key, checked) {\n    setChannels(prev => ({ ...prev, [key]: checked }));\n    if (!checked) {\n      const channel = channelConfig.find(c => c.key === key);\n      if (channel) {\n        const resetFields = channel.fields.map(f => f.name);\n        form.resetFields(resetFields);\n      }\n    }\n  }\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    const secret = {};\n    if (formData.ding_secret) secret.ding = formData.ding_secret;\n    if (formData.feishu_secret) secret.feishu = formData.feishu_secret;\n    delete formData.ding_secret;\n    delete formData.feishu_secret;\n    formData.secret = Object.keys(secret).length ? JSON.stringify(secret) : null;\n    http.post('/api/alarm/contact/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleTest(mode, name) {\n    const value = form.getFieldValue(name)\n    if (!value) return message.error('请输入后再执行测试')\n    setTestLoading(mode)\n    http.post('/api/alarm/test/', {mode, value})\n      .then(() => {\n        message.success('执行成功')\n      })\n      .finally(() => setTestLoading('0'))\n  }\n\n  function Test(props) {\n    return testLoading === props.mode ? (\n      <LoadingOutlined style={{fontSize: 16, color: '#faad14'}}/>\n    ) : (\n      <Tooltip title=\"执行测试\">\n        <ThunderboltOutlined\n          style={{fontSize: 16, color: '#faad14', cursor: 'pointer'}}\n          onClick={() => handleTest(props.mode, props.name)}/>\n      </Tooltip>\n    );\n  }\n\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={store.record.id ? '编辑联系人' : '新建联系人'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={{\n        ...store.record,\n        ding_secret: store.record.secret ? JSON.parse(store.record.secret).ding : undefined,\n        feishu_secret: store.record.secret ? JSON.parse(store.record.secret).feishu : undefined,\n      }} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"姓名\">\n          <Input placeholder=\"请输入联系人姓名\"/>\n        </Form.Item>\n        <Form.Item name=\"phone\" label=\"手机号\">\n          <Input placeholder=\"请输入手机号\"/>\n        </Form.Item>\n        <Divider orientation=\"left\" style={{margin: '8px 0 16px'}}>通知渠道</Divider>\n        {channelConfig.map(channel => (\n          <div key={channel.key} style={{marginBottom: channels[channel.key] ? 16 : 4}}>\n            <Form.Item wrapperCol={{offset: 6, span: 14}} style={{marginBottom: 0}}>\n              <Checkbox\n                checked={channels[channel.key]}\n                onChange={e => handleChannelToggle(channel.key, e.target.checked)}\n              >\n                {channel.label}\n              </Checkbox>\n            </Form.Item>\n            {channels[channel.key] && channel.fields.map(field => {\n              const extra = field.extra || (channel.help && field === channel.fields[0] ? (\n                <span>\n                  {channel.help.text}\n                  <a target=\"_blank\" rel=\"noopener noreferrer\" href={channel.help.link}>{channel.help.linkText}</a>\n                </span>\n              ) : undefined);\n              return (\n                <Form.Item key={field.name} name={field.name} label={field.label} extra={extra}>\n                  <Input\n                    placeholder={field.placeholder}\n                    suffix={field.testMode ? <Test mode={field.testMode} name={field.name}/> : <span/>}\n                  />\n                </Form.Item>\n              );\n            })}\n          </div>\n        ))}\n      </Form>\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/alarm/contact/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/alarm/contact/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"ac\"\n        rowKey=\"id\"\n        title=\"报警联系人\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"alarm.contact.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"姓名\" dataIndex=\"name\"/>\n        <Table.Column title=\"手机号\" dataIndex=\"phone\"/>\n        <Table.Column ellipsis title=\"邮箱\" dataIndex=\"email\"/>\n        <Table.Column ellipsis hide title=\"钉钉\" dataIndex=\"ding\"/>\n        <Table.Column ellipsis hide title=\"微信\" dataIndex=\"wx_token\"/>\n        <Table.Column ellipsis hide title=\"企业微信\" dataIndex=\"qy_wx\"/>\n        <Table.Column ellipsis hide title=\"飞书\" dataIndex=\"feishu\"/>\n        {hasPermission('alarm.contact.edit|alarm.contact.del') && (\n          <Table.Column title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"alarm.contact.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button danger auth=\"alarm.contact.del\" onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/alarm/contact/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"alarm.contact.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>报警中心</Breadcrumb.Item>\n        <Breadcrumb.Item>报警联系人</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"姓名\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/alarm/contact/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n\n  @observable f_name;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()))\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/alarm/contact/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/alarm/group/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, {useEffect, useState} from 'react';\nimport {observer} from 'mobx-react';\nimport {Modal, Form, Input, Transfer, Spin, message} from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [contacts, setContacts] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [fetching, setFetching] = useState(false);\n\n  useEffect(() => {\n    setFetching(true)\n    http.get('/api/alarm/contact/?with_push=1')\n      .then(res => setContacts(res))\n      .finally(() => setFetching(false))\n  }, []);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/alarm/group/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(true))\n  }\n\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={store.record.id ? '编辑联系组' : '新建联系组'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"组名称\">\n          <Input placeholder=\"请输入联系组名称\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n        <Spin spinning={fetching}>\n          <Form.Item required name=\"contacts\" valuePropName=\"targetKeys\" label=\"选择联系人\">\n            <Transfer\n              rowKey={item => item.id}\n              titles={['已有联系人', '已选联系人']}\n              listStyle={{width: 199}}\n              dataSource={contacts}\n              render={item => item.name}/>\n          </Form.Item>\n        </Spin>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/alarm/group/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\nimport contactStore from '../contact/store';\n\n@observer\nclass ComTable extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      contactMap: {}\n    }\n  }\n\n  componentDidMount() {\n    store.fetchRecords();\n    if (contactStore.records.length === 0) {\n      contactStore.fetchRecords().then(this._handleContacts)\n    } else {\n      this._handleContacts()\n    }\n  }\n\n  _handleContacts = () => {\n    const tmp = {};\n    for (let item of contactStore.records) {\n      tmp[item.id] = item\n    }\n    this.setState({contactMap: tmp})\n  };\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/alarm/group/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"ag\"\n        rowKey=\"id\"\n        title=\"报警联系组\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"alarm.group.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"组名称\" dataIndex=\"name\"/>\n        <Table.Column ellipsis title=\"成员\" dataIndex=\"contacts\" render={value => `${value.length}个`}/>\n        <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n        {hasPermission('alarm.group.edit|alarm.group.del') && (\n          <Table.Column title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"alarm.group.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button danger auth=\"alarm.group.del\" onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/alarm/group/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"alarm.group.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>报警中心</Breadcrumb.Item>\n        <Breadcrumb.Item>报警联系组</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"组名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/alarm/group/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n\n  @observable f_name;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    if (this.f_type) records = records.filter(x => x.type.toLowerCase().includes(this.f_type.toLowerCase()));\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/alarm/group/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/config/app/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false)\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/app/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title={store.record.id ? '编辑应用' : '新建应用'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"应用名称\">\n          <Input placeholder=\"请输入应用名称，例如：订单服务\"/>\n        </Form.Item>\n        <Form.Item\n          required\n          name=\"key\"\n          label=\"唯一标识符\"\n          tooltip=\"应用的唯一标识符，会作为生成配置的前缀。\"\n          extra=\"可以由字母、数字和下划线组成。\">\n          <Input placeholder=\"请输入唯一标识符，例如：api_order\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/config/app/Rel.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Transfer, message, Tabs } from 'antd';\nimport { http, hasPermission } from 'libs';\nimport serviceStore from '../service/store';\nimport store from './store';\n\n@observer\nclass Rel extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n      services: [],\n      apps: store.records.filter(x => x.id !== store.record.id).map(x => ({...x, key: x.id, _key: x.key}))\n    }\n  }\n\n  componentDidMount() {\n    if (serviceStore.records.length === 0) {\n      serviceStore.fetchRecords().then(this._updateRecords)\n    } else {\n      this._updateRecords()\n    }\n  }\n\n  _updateRecords = () => {\n    const services = serviceStore.records.map(x => {\n      return {...x, key: x.id, _key: x.key}\n    });\n    this.setState({services})\n  };\n\n  handleSubmit = () => {\n    this.setState({loading: true});\n    const {app, service} = store.confRel;\n    http.patch('/api/app/', {id: store.record.id, rel_apps: app, rel_services: service})\n      .then(res => {\n        message.success('操作成功');\n        store.relVisible = false;\n        store.fetchRecords()\n      }, () => this.setState({loading: false}))\n  };\n\n  render() {\n    return (\n      <Modal\n        visible\n        width={700}\n        maskClosable={false}\n        title=\"配置服务依赖\"\n        onCancel={() => store.relVisible = false}\n        confirmLoading={this.state.loading}\n        footer={hasPermission('config.app.edit_config') ? undefined : null}\n        onOk={this.handleSubmit}>\n        <Tabs tabPosition=\"left\">\n          <Tabs.TabPane tab=\"应用依赖\" key=\"app\">\n            <Form.Item extra=\"设置依赖后，该应用将能够获取到所依赖应用的配置。\">\n              <Transfer\n                listStyle={{width: 280, minHeight: 300}}\n                titles={['所有应用', '已选应用']}\n                dataSource={this.state.apps}\n                targetKeys={store.confRel.app}\n                onChange={keys => store.confRel.app = keys}\n                render={item => `${item.name}(${item._key})`}/>\n            </Form.Item>\n          </Tabs.TabPane>\n          <Tabs.TabPane tab=\"服务依赖\" key=\"service\">\n            <Form.Item extra=\"设置依赖后，该应用将能够获取到所依赖服务的配置。\">\n              <Transfer\n                listStyle={{width: 280, minHeight: 300}}\n                titles={['所有服务', '已选服务']}\n                dataSource={this.state.services}\n                targetKeys={store.confRel.service}\n                onChange={keys => store.confRel.service = keys}\n                render={item => `${item.name}(${item._key})`}/>\n            </Form.Item>\n          </Tabs.TabPane>\n        </Tabs>\n      </Modal>\n    )\n  }\n}\n\nexport default Rel\n"
  },
  {
    "path": "spug_web/src/pages/config/app/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission, history } from 'libs';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/app/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  toConfig = (info) => {\n    store.record = info;\n    history.push(`/config/setting/app/${info.id}`)\n  }\n\n  render() {\n    let data = store.records;\n    if (store.f_name) {\n      data = data.filter(item => item['name'].toLowerCase().includes(store.f_name.toLowerCase()))\n    }\n    return (\n      <TableCard\n        tKey=\"ca\"\n        rowKey=\"id\"\n        title=\"应用列表\"\n        loading={store.isFetching}\n        dataSource={data}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"config.app.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"应用名称\" dataIndex=\"name\"/>\n        <Table.Column title=\"标识符\" dataIndex=\"key\"/>\n        <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n        {hasPermission('config.app.edit|config.app.del|config.app.view_config') && (\n          <Table.Column width={210} title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"config.app.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button auth=\"config.app.view_config\" onClick={() => store.showRel(info)}>依赖</Action.Button>\n              <Action.Button auth=\"config.app.view_config\" onClick={() => this.toConfig(info)}>配置</Action.Button>\n              <Action.Button danger auth=\"config.app.del\" onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/config/app/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport Rel from './Rel';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"config.app.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>配置中心</Breadcrumb.Item>\n        <Breadcrumb.Item>应用配置</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"应用名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n      {store.relVisible && <Rel/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/config/app/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from \"mobx\";\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable confRel = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable relVisible = false;\n\n  @observable f_name;\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/app/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  };\n\n  showRel = (info) => {\n    this.relVisible = true;\n    this.record = info;\n    this.confRel = {\n      app: info['rel_apps'],\n      service: info['rel_services']\n    }\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/config/environment/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/config/environment/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title={store.record.id ? '编辑环境' : '新建环境'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"环境名称\">\n          <Input placeholder=\"请输入环境名称，例如：开发环境\"/>\n        </Form.Item>\n        <Form.Item\n          required\n          name=\"key\"\n          label=\"唯一标识符\"\n          tooltip=\"环境的唯一标识符，会在配置中心API中使用，具体请参考官方文档。\"\n          extra=\"可以由字母、数字和下划线组成。\">\n          <Input placeholder=\"请输入唯一标识符，例如：dev\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/config/environment/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, Divider, message } from 'antd';\nimport { PlusOutlined, UpSquareOutlined, DownSquareOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\n\nfunction ComTable() {\n  useEffect(() => {\n    store.fetchRecords()\n  }, [])\n\n  function handleDelete(text) {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/config/environment/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  }\n\n  function handleSort(info, sort) {\n    store.fetching = true;\n    http.patch('/api/config/environment/', {id: info.id, sort})\n      .then(store.fetchRecords, () => store.fetching = false)\n  }\n\n  return (\n    <TableCard\n      tKey=\"ce\"\n      rowKey=\"id\"\n      title=\"环境列表\"\n      loading={store.isFetching}\n      dataSource={store.dataSource}\n      onReload={store.fetchRecords}\n      actions={[\n        <AuthButton\n          auth=\"config.env.add\"\n          type=\"primary\"\n          icon={<PlusOutlined/>}\n          onClick={() => store.showForm()}>新建</AuthButton>\n      ]}\n      pagination={{\n        showSizeChanger: true,\n        showLessItems: true,\n        showTotal: total => `共 ${total} 条`,\n        pageSizeOptions: ['10', '20', '50', '100']\n      }}>\n      <Table.Column width={120} title=\"排序\" key=\"series\" render={(info) => (\n        <div>\n          <UpSquareOutlined\n            onClick={() => handleSort(info, 'up')}\n            style={{cursor: 'pointer', color: '#1890ff'}}/>\n          <Divider type=\"vertical\"/>\n          <DownSquareOutlined\n            onClick={() => handleSort(info, 'down')}\n            style={{cursor: 'pointer', color: '#1890ff'}}/>\n        </div>\n      )}/>\n      <Table.Column title=\"环境名称\" dataIndex=\"name\"/>\n      <Table.Column title=\"标识符\" dataIndex=\"key\"/>\n      <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n      {hasPermission('config.env.edit|config.env.del') && (\n        <Table.Column title=\"操作\" render={info => (\n          <Action>\n            <Action.Button auth=\"config.env.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n            <Action.Button danger auth=\"config.env.del\" onClick={() => handleDelete(info)}>删除</Action.Button>\n          </Action>\n        )}/>\n      )}\n    </TableCard>\n  )\n}\n\nexport default observer(ComTable)\n"
  },
  {
    "path": "spug_web/src/pages/config/environment/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"config.env.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>配置中心</Breadcrumb.Item>\n        <Breadcrumb.Item>环境管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"环境名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/config/environment/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from \"mobx\";\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable idMap = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n\n  @observable f_name;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/config/environment/')\n      .then(res => {\n        this.records = res;\n        for (let item of res) {\n          this.idMap[item.id] = item\n        }\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/config/service/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/config/service/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title={store.record.id ? '编辑服务' : '新建服务'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"服务名称\" tooltip=\"服务可以理解为一些配置的集合。\">\n          <Input placeholder=\"请输入服务名称\"/>\n        </Form.Item>\n        <Form.Item required name=\"key\" label=\"唯一标识符\" tooltip=\"服务的唯一标识符，会作为生成配置的前缀。\"\n                   extra=\"可以由字母、数字和下划线组成。\">\n          <Input placeholder=\"请输入唯一标识符\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/config/service/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission, history } from 'libs';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `将会同步删除服务的配置信息，确定要删除服务【${text['name']}】? `,\n      onOk: () => {\n        return http.delete('/api/config/service/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  toConfig = (info) => {\n    store.record = info;\n    history.push(`/config/setting/src/${info.id}`)\n  }\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"cs\"\n        rowKey=\"id\"\n        title=\"服务列表\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"config.src.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"服务名称\" dataIndex=\"name\"/>\n        <Table.Column title=\"标识符\" dataIndex=\"key\"/>\n        <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n        {hasPermission('config.src.edit|config.src.del|config.src.view_config') && (\n          <Table.Column title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"config.src.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button auth=\"config.src.view_config\" onClick={() => this.toConfig(info)}>配置</Action.Button>\n              <Action.Button danger auth=\"config.src.del\" onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/config/service/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"config.src.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>配置中心</Breadcrumb.Item>\n        <Breadcrumb.Item>服务配置</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"服务名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/config/service/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n\n  @observable f_name;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/config/service/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/DiffConfig.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { ArrowLeftOutlined } from '@ant-design/icons';\nimport { Modal, Form, Table, Row, Col, Checkbox, Button, Alert } from 'antd';\nimport http from 'libs/http';\nimport envStore from '../environment/store';\nimport styles from './index.module.css';\nimport store from './store';\n\n@observer\nclass Record extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n      records: [],\n      envs: [],\n      page: 0,\n      hideSame: false\n    }\n  }\n\n  handleEnvCheck = (env) => {\n    const index = this.state.envs.indexOf(env);\n    if (index !== -1) {\n      this.state.envs.splice(index, 1);\n    } else {\n      this.state.envs.push(env);\n    }\n    this.setState({envs: this.state.envs})\n  };\n\n  handleNext = () => {\n    this.setState({page: this.state.page + 1, loading: true});\n    const envs = this.state.envs.map(x => x.id);\n    http.post('/api/config/diff/', {type: store.type, o_id: store.id, envs})\n      .then(res => this.setState({records: res}))\n      .finally(() => this.setState({loading: false}))\n  };\n\n  getColumns = () => {\n    const columns = [{title: 'Key', dataIndex: 'key'}];\n    for (let env of this.state.envs) {\n      columns.push({title: `${env.name} (${env.key})`, dataIndex: env.id})\n    }\n    return columns\n  };\n\n  render() {\n    let records = this.state.records;\n    const {loading, envs, page, hideSame} = this.state;\n    if (hideSame) {\n      records = records.filter(item => new Set(envs.map(x => item[x.id])).size !== 1)\n    }\n    return (\n      <Modal\n        visible\n        width={1000}\n        maskClosable={false}\n        title=\"对比配置\"\n        onCancel={() => store.diffVisible = false}\n        footer={null}>\n        <div style={{display: page === 0 ? 'block' : 'none'}}>\n          <Alert style={{width: 500, margin: '10px auto 20px', color: '#31708f !important'}} type=\"info\"\n                 message=\"Tips: 通过对比配置功能，可以查看多个环境间的配置差异\"/>\n          <Form.Item labelCol={{span: 6}} wrapperCol={{span: 14, offset: 1}} label=\"要对比的环境\"\n                     style={{lineHeight: '40px'}}>\n            {envStore.records.map((item, index) => (\n              <Row\n                key={item.id}\n                onClick={() => this.handleEnvCheck(item)}\n                style={{cursor: 'pointer', borderTop: index ? '1px solid #e8e8e8' : ''}}>\n                <Col span={2}><Checkbox checked={envs.map(x => x.id).includes(item.id)}/></Col>\n                <Col span={10} className={styles.ellipsis}>{item.key}</Col>\n                <Col span={10} className={styles.ellipsis}>{item.name}</Col>\n              </Row>\n            ))}\n          </Form.Item>\n          <Form.Item labelCol={{span: 6}} wrapperCol={{span: 14, offset: 7}}>\n            <Button disabled={envs.length < 2} type=\"primary\" onClick={this.handleNext}>下一步</Button>\n          </Form.Item>\n        </div>\n        <div style={{display: page === 1 ? 'block' : 'none'}}>\n          <Button type=\"link\" icon={<ArrowLeftOutlined/>} style={{marginRight: 20}}\n                  onClick={() => this.setState({page: page - 1})}>上一步</Button>\n          <Checkbox checked={hideSame} onChange={() => this.setState({hideSame: !hideSame})}>隐藏相同配置</Checkbox>\n          <Table pagination={false} dataSource={records} loading={loading} columns={this.getColumns()}/>\n        </div>\n      </Modal>\n    );\n  }\n}\n\nexport default Record\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Checkbox, Switch, Row, Col, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\nimport envStore from '../environment/store'\nimport styles from './index.module.css';\nimport lds from 'lodash';\n\nexport default observer(function () {\n  const isModify = store.record.id !== undefined;\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [envs, setEnvs] = useState(isModify ? [store.env.id] : []);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['is_public'] = store.type === 'src' ? false : formData['is_public'];\n    let request;\n    if (isModify) {\n      formData['id'] = store.record.id;\n      request = http.patch('/api/config/', formData)\n    } else {\n      formData['type'] = store.type;\n      formData['o_id'] = store.id;\n      formData['envs'] = envs;\n      request = http.post('/api/config/', formData)\n    }\n    request.then(res => {\n      message.success('操作成功');\n      store.formVisible = false;\n      store.fetchRecords()\n    }, () => setLoading(false))\n  }\n\n  function handleEnvCheck(id) {\n    if (!isModify) {\n      const tmp = lds.clone(envs);\n      const index = tmp.indexOf(id);\n      if (index !== -1) {\n        tmp.splice(index, 1)\n      } else {\n        tmp.push(id)\n      }\n      setEnvs(tmp)\n    }\n  }\n\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={store.record.id ? '更新配置' : '新增配置'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"key\" label=\"Key\">\n          <Input disabled={isModify} placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item name=\"value\" label=\"Value\">\n          <Input.TextArea placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n        {store.type === 'app' && (\n          <Form.Item\n            label=\"类型\"\n            name=\"is_public\"\n            valuePropName=\"checked\"\n            initialValue={store.record.is_public === undefined || store.record.is_public}\n            tooltip={<a target=\"_blank\" rel=\"noopener noreferrer\"\n                        href=\"https://ops.spug.cc/docs/conf-app\">什么是公共/私有配置？</a>}>\n            <Switch checkedChildren=\"公共\" unCheckedChildren=\"私有\"/>\n          </Form.Item>\n        )}\n        {isModify ? null : (\n          <Form.Item label=\"选择环境\" style={{lineHeight: '40px'}}>\n            {envStore.records.map((item, index) => (\n              <Row\n                key={item.id}\n                onClick={() => handleEnvCheck(item.id)}\n                style={{cursor: 'pointer', borderTop: index ? '1px solid #e8e8e8' : ''}}>\n                <Col span={2}><Checkbox checked={envs.includes(item.id)}/></Col>\n                <Col span={10} className={styles.ellipsis}>{item.key}</Col>\n                <Col span={10} className={styles.ellipsis}>{item.name}</Col>\n              </Row>\n            ))}\n          </Form.Item>\n        )}\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/config/setting/JSONView.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { SaveOutlined, EditOutlined } from '@ant-design/icons';\nimport { Button, message } from 'antd';\nimport { AuthButton, ACEditor } from 'components';\nimport { http } from 'libs';\nimport store from './store';\n\n@observer\nclass JSONView extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n      readOnly: true,\n      body: ''\n    }\n  }\n\n  componentDidMount() {\n    this.updateValue()\n  }\n\n  updateValue = () => {\n    const body = {};\n    for (let item of store.records) {\n      body[item.key] = item.value\n    }\n    this.setState({readOnly: true, body: JSON.stringify(body, null, 2)})\n  };\n\n  handleSubmit = () => {\n    try {\n      const data = JSON.parse(this.state.body);\n      this.setState({loading: true});\n      const formData = {type: store.type, o_id: store.id, env_id: store.env.id, data};\n      http.post('/api/config/parse/json/', formData)\n        .then(res => {\n          message.success('保存成功');\n          store.fetchRecords().then(this.updateValue)\n        })\n        .finally(() => this.setState({loading: false}))\n    } catch (err) {\n      message.error('解析JSON失败，请检查输入内容')\n    }\n  };\n\n  render() {\n    const {body, readOnly, loading} = this.state;\n    return (\n      <div style={{position: 'relative'}}>\n        <ACEditor\n          mode=\"json\"\n          theme=\"tomorrow\"\n          height=\"500px\"\n          width=\"100%\"\n          readOnly={readOnly}\n          setOptions={{useWorker: false}}\n          value={body}\n          onChange={v => this.setState({body: v})}/>\n        {readOnly && <AuthButton\n          icon={<EditOutlined/>}\n          type=\"link\"\n          size=\"large\"\n          auth={`config.${store.type}.edit_config`}\n          style={{position: 'absolute', top: 0, right: 0}}\n          onClick={() => this.setState({readOnly: false})}>编辑</AuthButton>}\n        {readOnly || <Button\n          icon={<SaveOutlined />}\n          type=\"link\"\n          size=\"large\"\n          loading={loading}\n          style={{position: 'absolute', top: 0, right: 0}}\n          onClick={this.handleSubmit}>保存</Button>}\n      </div>\n    )\n  }\n}\n\nexport default JSONView\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/Record.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Table, Tooltip, Tag } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\n@observer\nclass Record extends React.Component {\n  constructor(props) {\n    super(props);\n    this.isModify = store.record.id !== undefined;\n    this.state = {\n      loading: true,\n      envs: this.isModify ? [store.env.id] : []\n    }\n  }\n\n  componentDidMount() {\n    const formData = {type: store.type, o_id: store.id, env_id: store.env.id};\n    http.post('/api/config/history/', formData)\n      .then(res => this.setState({records: res}))\n      .finally(() => this.setState({loading: false}))\n  }\n\n  colorMap = {'1': 'green', '2': 'orange', '3': 'red'};\n\n  columns = [{\n    title: 'Key',\n    key: 'key',\n    render: info => <Tooltip title={info.desc}>{info.key}</Tooltip>\n  }, {\n    title: 'Old Value',\n    dataIndex: 'old_value',\n    ellipsis: true\n  }, {\n    title: 'New Value',\n    dataIndex: 'value',\n    ellipsis: true\n  }, {\n    title: '动作',\n    render: info => <Tag color={this.colorMap[info.action]}>{info['action_alias']}</Tag>\n  }, {\n    title: '操作人',\n    width: 120,\n    dataIndex: 'update_user'\n  }, {\n    title: '操作时间',\n    width: 180,\n    dataIndex: 'updated_at'\n  }];\n\n  render() {\n    const {loading, records} = this.state;\n    return (\n      <Modal\n        visible\n        width={1000}\n        maskClosable={false}\n        title={`${store.env.name} - 更改历史记录`}\n        onCancel={() => store.recordVisible = false}\n        footer={null}>\n        <Table\n          rowKey=\"id\"\n          loading={loading}\n          dataSource={records}\n          pagination={{\n            showSizeChanger: true,\n            showLessItems: true,\n            hideOnSinglePage: true,\n            showTotal: total => `共 ${total} 条`,\n            pageSizeOptions: ['10', '20', '50', '100']\n          }}\n          columns={this.columns}/>\n      </Modal>\n    )\n  }\n}\n\nexport default Record\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/TableView.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { LockOutlined } from '@ant-design/icons';\nimport { Table, Modal, Tooltip, message } from 'antd';\nimport { Action } from 'components';\nimport ComForm from './Form';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\n\n@observer\nclass TableView extends React.Component {\n  lockIcon = <Tooltip title=\"私有配置应用专用，不会被其他应用获取到\">\n    <LockOutlined style={{marginRight: 5}}/>\n  </Tooltip>;\n\n  columns = [{\n    title: 'Key',\n    key: 'key',\n    render: info => {\n      let prefix = (store.type === 'app' && info.is_public === false) ? this.lockIcon : null;\n      let content = info.desc ? <span style={{color: '#1890ff'}}>{info.key}</span> : info.key;\n      return <React.Fragment>\n        {prefix}\n        <Tooltip title={info.desc}>{content}</Tooltip>\n      </React.Fragment>\n    }\n  }, {\n    title: 'Value',\n    dataIndex: 'value',\n  }, {\n    title: '修改人',\n    width: 120,\n    dataIndex: 'update_user'\n  }, {\n    title: '修改时间',\n    width: 180,\n    dataIndex: 'updated_at'\n  }, {\n    title: '操作',\n    width: 120,\n    className: hasPermission(`config.${store.type}.edit_config`) ? null : 'none',\n    render: info => (\n      <Action>\n        <Action.Button auth={`config.${store.type}.edit_config`} onClick={() => store.showForm(info)}>编辑</Action.Button>\n        <Action.Button\n          danger\n          auth={`config.${store.type}.edit_config`}\n          onClick={() => this.handleDelete(info)}>删除</Action.Button>\n      </Action>\n    )\n  }];\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${store.env.name}】环境下的配置【${text['key']}】?`,\n      onOk: () => {\n        return http.delete('/api/config/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    let data = store.records;\n    if (store.f_name) {\n      data = data.filter(item => item['key'].toLowerCase().includes(store.f_name.toLowerCase()))\n    }\n    return (\n      <React.Fragment>\n        <Table\n          size=\"small\"\n          rowKey=\"id\"\n          loading={store.isFetching}\n          dataSource={data}\n          pagination={{\n            showSizeChanger: true,\n            showLessItems: true,\n            hideOnSinglePage: true,\n            showTotal: total => `共 ${total} 条`,\n            pageSizeOptions: ['10', '20', '50', '100']\n          }}\n          columns={this.columns}/>\n        {store.formVisible && <ComForm/>}\n      </React.Fragment>\n    )\n  }\n}\n\nexport default TableView\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/TextView.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Button, message } from 'antd';\nimport { SaveOutlined, EditOutlined } from '@ant-design/icons';\nimport { ACEditor } from 'components';\nimport store from './store';\nimport { http } from 'libs';\n\n\nimport { AuthButton } from 'components';\n\n@observer\nclass TextView extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n      readOnly: true,\n      body: ''\n    }\n  }\n\n  componentDidMount() {\n    this.updateValue()\n  }\n\n  updateValue = () => {\n    let body = '';\n    for (let item of store.records) {\n      body += `${item.key} = ${item.value}\\n`\n    }\n    this.setState({readOnly: true, body})\n  };\n\n  handleSubmit = () => {\n    this.setState({loading: true});\n    const formData = {type: store.type, o_id: store.id, env_id: store.env.id, data: this.state.body};\n    http.post('/api/config/parse/text/', formData)\n      .then(res => {\n        message.success('保存成功');\n        store.fetchRecords().then(this.updateValue)\n      })\n      .finally(() => this.setState({loading: false}))\n  };\n\n  render() {\n    const {body, loading, readOnly} = this.state;\n    return (\n      <div style={{position: 'relative'}}>\n        <ACEditor\n          mode=\"space\"\n          width=\"100%\"\n          height=\"500px\"\n          theme=\"tomorrow\"\n          value={body}\n          readOnly={readOnly}\n          onChange={v => this.setState({body: v})}/>\n        {readOnly && <AuthButton\n          icon={<EditOutlined/>}\n          type=\"link\"\n          size=\"large\"\n          auth={`config.${store.type}.edit_config`}\n          style={{position: 'absolute', top: 0, right: 0}}\n          onClick={() => this.setState({readOnly: false})}>编辑</AuthButton>}\n        {readOnly || <Button\n          icon={<SaveOutlined />}\n          type=\"link\"\n          size=\"large\"\n          loading={loading}\n          style={{position: 'absolute', top: 0, right: 0}}\n          onClick={this.handleSubmit}>保存</Button>}\n      </div>\n    )\n  }\n}\n\nexport default TextView\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Menu, Input, Button, PageHeader, Modal, Space, Radio, Form, Alert } from 'antd';\nimport {\n  DiffOutlined,\n  HistoryOutlined,\n  NumberOutlined,\n  TableOutlined,\n  UnorderedListOutlined,\n  PlusOutlined\n} from '@ant-design/icons';\nimport envStore from '../environment/store';\nimport styles from './index.module.css';\nimport history from 'libs/history';\nimport { AuthDiv, AuthButton, Breadcrumb } from 'components';\nimport DiffConfig from './DiffConfig';\nimport TableView from './TableView';\nimport TextView from './TextView';\nimport JSONView from './JSONView';\nimport Record from './Record';\nimport store from './store';\n\n@observer\nclass Index extends React.Component {\n  constructor(props) {\n    super(props);\n    this.textView = null;\n    this.JSONView = null;\n    this.state = {\n      view: '1'\n    }\n  }\n\n  componentDidMount() {\n    const {type, id} = this.props.match.params;\n    store.initial(type, id)\n      .then(() => {\n        if (envStore.records.length === 0) {\n          envStore.fetchRecords().then(() => {\n            if (envStore.records.length === 0) {\n              Modal.error({\n                title: '无可用环境',\n                content: <div>配置依赖应用的运行环境，请在 <a href=\"/config/environment\">环境管理</a> 中创建环境。</div>\n              })\n            } else {\n              this.updateEnv()\n            }\n          })\n        } else {\n          this.updateEnv()\n        }\n      })\n  }\n\n  updateEnv = (env) => {\n    store.env = env || envStore.records[0] || {};\n    this.handleRefresh()\n  };\n\n  handleRefresh = () => {\n    store.fetchRecords().then(() => {\n      if (this.textView) this.textView.updateValue();\n      if (this.JSONView) this.JSONView.updateValue();\n    })\n  };\n\n  render() {\n    const {view} = this.state;\n    const isApp = store.type === 'app';\n    return (\n      <AuthDiv auth={`config.${store.type}.view_config`}>\n        <Breadcrumb extra={<Alert message=\"4.0将移除公共/私有配置概念，所有配置将被视为公共配置。\" banner/>}>\n          <Breadcrumb.Item>配置中心</Breadcrumb.Item>\n          <Breadcrumb.Item onClick={() => history.goBack()}>{isApp ? '应用配置' : '服务配置'}</Breadcrumb.Item>\n          <Breadcrumb.Item>{store.obj.name}</Breadcrumb.Item>\n        </Breadcrumb>\n        <div className={styles.container}>\n          <div className={styles.left}>\n            <PageHeader\n              title=\"环境列表\"\n              style={{padding: '0 0 10px 10px'}}\n              onBack={() => history.goBack()}\n              extra={<Button type=\"link\" icon={<DiffOutlined/>} onClick={store.showDiff}>对比配置</Button>}/>\n            <Menu\n              mode=\"inline\"\n              selectedKeys={[String(store.env.id)]}\n              style={{border: 'none'}}\n              onSelect={({item}) => this.updateEnv(item.props.env)}>\n              {envStore.records.map(item => (\n                <Menu.Item key={item.id} env={item}>{item.name} ({item.key})</Menu.Item>\n              ))}\n            </Menu>\n          </div>\n          <div className={styles.right}>\n            <Form layout=\"inline\" style={{marginBottom: 16}}>\n              <Form.Item label=\"视图\" style={{paddingLeft: 0}}>\n                <Radio.Group value={view} onChange={e => this.setState({view: e.target.value})}>\n                  <Radio.Button value=\"1\"><TableOutlined title=\"表格视图\"/></Radio.Button>\n                  <Radio.Button value=\"2\"><UnorderedListOutlined title=\"文本视图\"/></Radio.Button>\n                  <Radio.Button value=\"3\"><NumberOutlined title=\"JSON视图\"/></Radio.Button>\n                </Radio.Group>\n              </Form.Item>\n              <Form.Item label=\"Key\">\n                <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value}\n                       placeholder=\"请输入\"/>\n              </Form.Item>\n              <Space style={{flex: 1, justifyContent: 'flex-end'}}>\n                <AuthButton\n                  auth=\"config.app.edit_config|config.service.edit_config\"\n                  disabled={view !== '1'}\n                  type=\"primary\"\n                  icon={<PlusOutlined/>}\n                  onClick={() => store.showForm()}>新增配置</AuthButton>\n                <Button\n                  type=\"primary\"\n                  style={{backgroundColor: 'orange', borderColor: 'orange'}}\n                  icon={<HistoryOutlined/>}\n                  onClick={store.showRecord}>更改历史</Button>\n              </Space>\n            </Form>\n\n            {view === '1' && <TableView/>}\n            {view === '2' && <TextView ref={ref => this.textView = ref}/>}\n            {view === '3' && <JSONView ref={ref => this.JSONView = ref}/>}\n          </div>\n        </div>\n        {store.recordVisible && <Record/>}\n        {store.diffVisible && <DiffConfig/>}\n      </AuthDiv>\n    )\n  }\n}\n\nexport default Index\n"
  },
  {
    "path": "spug_web/src/pages/config/setting/index.module.css",
    "content": ".container {\n    display: flex;\n    background-color: #fff;\n    padding: 16px 0;\n}\n.left {\n    width: 250px;\n    border-right: 1px solid #e8e8e8;\n}\n.right {\n    flex: 1;\n    padding: 8px 40px;\n}\n\n.title {\n    margin-bottom: 12px;\n    color: rgba(0, 0, 0, .85);\n    font-weight: 500;\n    font-size: 20px;\n    line-height: 28px;\n}\n\n.form {\n    max-width: 320px;\n}\n\n.ellipsis {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}"
  },
  {
    "path": "spug_web/src/pages/config/setting/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from \"mobx\";\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable env = {};\n  @observable obj = {};\n  @observable type;\n  @observable id;\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable recordVisible = false;\n  @observable diffVisible = false;\n\n  @observable f_name;\n\n  initial = (type, id) => {\n    this.type = type\n    this.id = id\n    const url = type === 'app' ? '/api/app/' : '/api/config/service/'\n    this.isFetching = true\n    return http.get(url, {params: {id}})\n      .then(res => this.obj = res)\n  }\n\n  fetchRecords = () => {\n    const params = {type: this.type, id: this.id, env_id: this.env.id};\n    this.isFetching = true;\n    return http.get('/api/config/', {params})\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info) => {\n    this.formVisible = true;\n    this.record = info || {};\n  };\n\n  showRecord = () => {\n    this.recordVisible = true\n  };\n\n  showDiff = () => {\n    this.diffVisible = true\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/dashboard/AlarmTrend.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Card, Cascader } from 'antd';\nimport { Chart, Geom, Axis, Tooltip } from 'bizcharts';\nimport { http } from 'libs';\n\nexport default function () {\n  const [loading, setLoading] = useState(true);\n  const [options, setOptions] = useState([]);\n  const [params, setParams] = useState({});\n  const [res, setRes] = useState([]);\n\n  useEffect(() => {\n    setLoading(true);\n    http.get('/api/home/alarm/', {params})\n      .then(res => setRes(res))\n      .finally(() => setLoading(false))\n  }, [params])\n\n  useEffect(() => {\n    const data = {};\n    http.get('/api/monitor/')\n      .then(res => {\n        for (let item of res.detections) {\n          if (!data[item.type]) {\n            data[item.type] = {value: item.type_alias, label: item.type_alias, children: []}\n          }\n          data[item.type].children.push({value: item.name, label: item.name})\n        }\n        setOptions(Object.values(data))\n      })\n  }, [])\n\n  function handleChange(v) {\n    switch (v.length) {\n      case 2:\n        setParams({name: v[1]});\n        break;\n      case 1:\n        setParams({type: v[0]});\n        break;\n      default:\n        setParams({})\n    }\n  }\n\n  return (\n    <Card loading={loading} title=\"报警趋势\" bodyStyle={{height: 353}} extra={(\n      <Cascader changeOnSelect style={{width: 260}} options={options} onChange={handleChange} placeholder=\"过滤监控项，默认所有\"/>\n    )}>\n      <Chart height={300} data={res} padding={[10, 10, 30, 35]} scale={{value: {alias: '报警次数'}}} forceFit>\n        <Axis name=\"date\"/>\n        <Axis name=\"value\"/>\n        <Tooltip\n          crosshairs={{\n            type: \"y\"\n          }}\n        />\n        <Geom type=\"line\" position=\"date*value\" size={2} shape={\"smooth\"}/>\n        <Geom\n          type=\"point\"\n          position=\"date*value\"\n          size={4}\n          shape={\"circle\"}\n          style={{\n            stroke: \"#fff\",\n            lineWidth: 1\n          }}\n        />\n      </Chart>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/dashboard/RequestTop.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Card, DatePicker } from 'antd';\nimport { Chart, Geom, Axis, Tooltip } from 'bizcharts';\nimport styles from './index.module.css';\nimport moment from 'moment';\nimport { http } from 'libs';\n\n\nexport default function () {\n  const [loading, setLoading] = useState(false);\n  const [duration, setDuration] = useState([moment(), moment()]);\n  const [range, setRange] = useState('day');\n  const [res, setRes] = useState([])\n\n  useEffect(() => {\n    setLoading(true);\n    const strDuration = duration.map(x => x.format('YYYY-MM-DD'))\n    http.post('/api/home/request/', {duration: strDuration})\n      .then(res => setRes(res))\n      .finally(() => setLoading(false))\n  }, [duration])\n\n  function handleClick(val) {\n    let duration = [];\n    switch (val) {\n      case 'day':\n        setRange('day');\n        duration = [moment(), moment()];\n        break;\n      case 'week':\n        setRange('week');\n        duration = [moment().weekday(0), moment().weekday(6)];\n        break;\n      case 'month':\n        setRange('month');\n        const s_date = moment().startOf('month')\n        const e_date = moment().endOf('month')\n        duration = [s_date, e_date];\n        break;\n      default:\n        setRange('custom')\n        duration = val\n    }\n    setDuration(duration)\n  }\n\n  return (\n    <Card loading={loading} title=\"发布申请Top20\" style={{marginTop: 20}} bodyStyle={{height: 353}} extra={(\n      <div style={{display: 'flex', alignItems: 'center'}}>\n        <span className={range === 'day' ? styles.spanButtonActive : styles.spanButton}\n              onClick={() => handleClick('day')}>今日</span>\n        <span className={range === 'week' ? styles.spanButtonActive : styles.spanButton}\n              onClick={() => handleClick('week')}>本周</span>\n        <span className={range === 'month' ? styles.spanButtonActive : styles.spanButton}\n              onClick={() => handleClick('month')}>本月</span>\n        <DatePicker.RangePicker allowClear={false} style={{width: 250}} value={duration} onChange={handleClick}/>\n      </div>\n    )}>\n      <Chart height={300} data={res} padding={[10, 0, 30, 35]} scale={{count: {alias: '发布申请数量'}}} forceFit>\n        <Axis name=\"name\"/>\n        <Axis name=\"count\" title/>\n        <Tooltip/>\n        <Geom type=\"interval\" position=\"name*count\"/>\n      </Chart>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/dashboard/StatisticCard.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Statistic, Card, Row, Col } from 'antd';\nimport { http } from 'libs';\n\nexport default class StatisticCard extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: true,\n      res: {}\n    }\n  }\n\n  componentDidMount() {\n    http.get('/api/home/statistic/')\n      .then(res => this.setState({res}))\n      .finally(() => this.setState({loading: false}))\n  }\n\n  render() {\n    const {res, loading} = this.state;\n    return (\n      <Row gutter={16} style={{marginBottom: 20}}>\n        <Col span={6}>\n          <Card loading={loading}>\n            <Statistic\n              title=\"应用\"\n              value={res.app}\n              suffix={<span style={{fontSize: 16}}>个</span>}\n              formatter={v => <a href=\"/deploy/app\">{v}</a>}/>\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card loading={loading}>\n            <Statistic\n              title=\"主机\"\n              value={res.host}\n              suffix={<span style={{fontSize: 16}}>台</span>}\n              formatter={v => <a href=\"/host\">{v}</a>}/>\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card loading={loading}>\n            <Statistic\n              title=\"任务\"\n              value={res.task}\n              suffix={<span style={{fontSize: 16}}>个</span>}\n              formatter={v => <a href=\"/schedule\">{v}</a>}/>\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card loading={loading}>\n            <Statistic\n              title=\"监控\"\n              value={res['detection']}\n              suffix={<span style={{fontSize: 16}}>项</span>}\n              formatter={v => <a href=\"/monitor\">{v}</a>}/>\n          </Card>\n        </Col>\n      </Row>\n    )\n  }\n}\n"
  },
  {
    "path": "spug_web/src/pages/dashboard/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { AuthDiv } from 'components';\nimport StatisticsCard from './StatisticCard';\nimport AlarmTrend from './AlarmTrend';\nimport RequestTop from './RequestTop';\n\nclass HomeIndex extends React.Component {\n  render() {\n    return (\n      <AuthDiv auth=\"dashboard.dashboard.view\">\n        <StatisticsCard/>\n        <AlarmTrend/>\n        <RequestTop/>\n      </AuthDiv>\n    )\n  }\n}\n\nexport default HomeIndex\n"
  },
  {
    "path": "spug_web/src/pages/dashboard/index.module.css",
    "content": ":global(.ant-card-extra) {\n  padding: 12px 0;\n}\n\n.spanButton {\n  cursor: pointer;\n  margin-right: 24px;\n  color: rgba(0, 0, 0, .65);\n}\n\n.spanButtonActive {\n  cursor: pointer;\n  margin-right: 24px;\n  color: #1890ff;\n}\n\n.spanButton:hover {\n  color: #1890ff;\n}\n\n.spanText {\n  cursor: pointer;\n  color: #1890ff;\n  padding: 0 4px;\n}\n\n.loginActive {\n  height: 329px;\n  overflow: auto;\n}"
  },
  {
    "path": "spug_web/src/pages/deploy/app/AddSelect.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { BuildOutlined, OrderedListOutlined } from '@ant-design/icons';\nimport { Modal, Card } from 'antd';\nimport store from './store';\nimport styles from './index.module.css';\n\n@observer\nclass AddSelect extends React.Component {\n  switchExt1 = () => {\n    store.addVisible = false;\n    store.ext1Visible = true;\n    store.deploy = {\n      git_type: 'branch',\n      is_audit: false,\n      rst_notify: {mode: '0'},\n      host_ids: [],\n      filter_rule: {type: 'exclude', data: ''}\n    }\n  };\n\n  switchExt2 = () => {\n    store.addVisible = false;\n    store.ext2Visible = true;\n    store.deploy = {\n      is_audit: false,\n      rst_notify: {mode: '0'},\n      host_ids: [],\n      host_actions: [],\n      server_actions: []\n    }\n  };\n\n  render() {\n    const modalStyle = {\n      display: 'flex',\n      justifyContent: 'space-around',\n      backgroundColor: 'rgba(240, 242, 245, 1)',\n      padding: '80px 0'\n    };\n\n    return (\n      <Modal\n        visible\n        width={800}\n        maskClosable={false}\n        title=\"选择发布方式\"\n        bodyStyle={modalStyle}\n        onCancel={() => store.addVisible = false}\n        footer={null}>\n        <Card\n          style={{width: 300, cursor: 'pointer'}}\n          bodyStyle={{display: 'flex'}}\n          onClick={this.switchExt1}>\n          <div style={{marginRight: 16}}>\n            <OrderedListOutlined style={{fontSize: 36, color: '#1890ff'}} />\n          </div>\n          <div>\n            <div className={styles.cardTitle}>常规发布</div>\n            <div className={styles.cardDesc}>\n              由 Spug 来控制发布的主流程，你可以通过添加钩子脚本来执行额外的自定义操作。\n            </div>\n          </div>\n        </Card>\n        <Card\n          style={{width: 300, cursor: 'pointer'}}\n          bodyStyle={{display: 'flex'}}\n          onClick={this.switchExt2}>\n          <div style={{marginRight: 16}}>\n            <BuildOutlined style={{fontSize: 36, color: '#1890ff'}} />\n          </div>\n          <div>\n            <div className={styles.cardTitle}>自定义发布</div>\n            <div className={styles.cardDesc}>\n              你可以完全自己定义发布的所有流程和操作，Spug 负责按顺序依次执行你记录的动作。\n            </div>\n          </div>\n        </Card>\n      </Modal>\n    )\n  }\n}\n\nexport default AddSelect\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/AutoDeploy.js",
    "content": "import React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Select, Radio, Button, Alert, message } from 'antd';\nimport { LoadingOutlined, SyncOutlined } from '@ant-design/icons';\nimport { http } from 'libs';\nimport store from './store';\nimport styles from './index.module.css';\n\n\nexport default observer(function AutoDeploy() {\n  const [type, setType] = useState('branch');\n  const [fetching, setFetching] = useState(false);\n  const [branches, setBranches] = useState([]);\n  const [branch, setBranch] = useState();\n  const [url, setURL] = useState();\n  const [key, setKey] = useState();\n\n  useEffect(() => {\n    if (store.deploy.extend === '1') {\n      fetchVersions()\n    }\n    http.post('/api/app/kit/key/', {key: 'api_key'})\n      .then(res => setKey(res))\n  }, [])\n\n  useEffect(() => {\n    const prefix = window.location.origin;\n    let tmp = `${prefix}/api/apis/deploy/${store.deploy.id}/${type}/`;\n    if (type === 'branch') {\n      tmp += `?name=${branch}`\n    }\n    setURL(tmp)\n  }, [type, branch])\n\n  function fetchVersions() {\n    setFetching(true);\n    http.get(`/api/app/deploy/${store.deploy.id}/versions/`)\n      .then(res => setBranches(Object.keys(res.branches)))\n      .finally(() => setFetching(false))\n  }\n\n  function copyToClipBoard(data) {\n    const t = document.createElement('input');\n    t.value = data;\n    document.body.appendChild(t);\n    t.select();\n    document.execCommand('copy');\n    t.remove();\n    message.success('已复制')\n  }\n\n  const tagMode = type === 'tag';\n  return (\n    <Modal\n      visible\n      width={540}\n      title=\"Webhook\"\n      footer={null}\n      onCancel={() => store.autoVisible = false}>\n      <Alert showIcon type=\"info\" style={{width: 440, margin: '0 auto 24px'}} message=\"Webhook可以用来与Git结合实现触发后自动发布。\"/>\n      <Form labelCol={{span: 6}} wrapperCol={{span: 16}}>\n        <Form.Item required label=\"触发方式\">\n          <Radio.Group value={type} onChange={e => setType(e.target.value)}>\n            <Radio.Button value=\"branch\">Branch</Radio.Button>\n            <Radio.Button value=\"tag\">Tag</Radio.Button>\n          </Radio.Group>\n        </Form.Item>\n        {store.deploy.extend === '1' ? (\n          <Form.Item hidden={tagMode} required={!tagMode} label=\"选择分支\" extra={<span>\n            根据你的网络情况，首次刷新可能会很慢，请耐心等待。\n            <a target=\"_blank\" rel=\"noopener noreferrer\"\n               href=\"https://ops.spug.cc/docs/use-problem#clone\">刷新失败？</a>\n          </span>}>\n            <Form.Item style={{display: 'inline-block', marginBottom: 0, width: '246px'}}>\n              <Select placeholder=\"仅指定分支的事件触发自动发布\" value={branch} onChange={setBranch}>\n                {branches.map(item => (\n                  <Select.Option key={item} value={item}>{item}</Select.Option>\n                ))}\n              </Select>\n            </Form.Item>\n            <Form.Item style={{display: 'inline-block', width: 82, textAlign: 'center', marginBottom: 0}}>\n              {fetching ? <LoadingOutlined style={{fontSize: 18, color: '#1890ff'}}/> :\n                <Button type=\"link\" icon={<SyncOutlined/>} disabled={fetching || tagMode}\n                        onClick={fetchVersions}>刷新</Button>\n              }\n            </Form.Item>\n          </Form.Item>\n        ) : (\n          <Form.Item required hidden={tagMode} label=\"指定分支\">\n            <Input\n              value={branch}\n              onChange={e => setBranch(e.target.value)}\n              placeholder=\"仅指定分支的事件触发自动发布\"/>\n          </Form.Item>\n        )}\n        {type === 'branch' && !branch ? (\n          <Form.Item label=\"Webhook URL\">\n            <div style={{color: '#ff4d4f'}}>请指定分支名称。</div>\n          </Form.Item>\n        ) : (\n          <Form.Item label=\"Webhook URL\" extra=\"点击复制链接，目前支持Gitee、Github、Gitlab、Gogs、Coding和Codeup(阿里云)。\">\n            <div className={styles.webhook} onClick={() => copyToClipBoard(url)}>{url}</div>\n          </Form.Item>\n        )}\n        {key ? (\n          <Form.Item\n            label=\"Secret Token\"\n            tooltip=\"调用该Webhook接口的访问凭据，在Gitee中为WebHook密码，Gogs中为密钥文本。\"\n            extra={`点击复制，老版本gitlab等无该项设置的可以在上述Webhook URL后边附加 &token=${key}`}>\n            <div className={styles.webhook} onClick={() => copyToClipBoard(key)}>{key}</div>\n          </Form.Item>\n        ) : (\n          <Form.Item label=\"Secret Token\" tooltip=\"调用该Webhook接口的访问凭据，在Gitee中为WebHook密码，Gogs中为密钥文本。\">\n            <div style={{color: '#ff4d4f'}}>请在系统管理/系统设置/开放服务设置中设置。</div>\n          </Form.Item>\n        )}\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/app/CloneConfirm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Select, Form } from 'antd';\nimport envStore from 'pages/config/environment/store';\nimport { includes } from 'libs';\nimport store from './store';\nimport lds from 'lodash';\n\nexport default observer(function (props) {\n  const [form] = Form.useForm()\n  const [apps] = useState(Object.values(store.records))\n  const [appId, setAppId] = useState()\n  const [deploys, setDeploys] = useState([])\n\n  useEffect(() => {\n    if (appId) {\n      props.onChange(null)\n      form.setFieldsValue({env_id: undefined})\n      store.loadDeploys(appId)\n        .then(res => setDeploys(res))\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [appId])\n\n  function handleChange(deployId) {\n    const deploy = lds.find(deploys, {id: deployId})\n    props.onChange(deploy)\n  }\n\n  return (\n    <Form form={form} layout=\"vertical\" style={{marginTop: 24}}>\n      <Form.Item required label=\"克隆的应用\">\n        <Select showSearch filterOption={(i, o) => includes(o.children, i)} placeholder=\"请选择要克隆的应用\" onChange={setAppId}>\n          {apps.map(item => (\n            <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n          ))}\n        </Select>\n      </Form.Item>\n      <Form.Item required name=\"env_id\" label=\"克隆的环境\">\n        <Select\n          showSearch\n          filterOption={(i, o) => includes(o.children, i)}\n          placeholder=\"请选择要克隆的环境\"\n          disabled={deploys.length === 0}\n          onChange={handleChange}>\n          {deploys.map(item => (\n            <Select.Option key={item.id} value={item.id}>{envStore.idMap[item.env_id]?.name}</Select.Option>\n          ))}\n        </Select>\n      </Form.Item>\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext1Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Steps } from 'antd';\nimport Setup1 from './Ext1Setup1';\nimport Setup2 from './Ext1Setup2';\nimport Setup3 from './Ext1Setup3';\nimport store from './store';\nimport styles from './index.module.css';\n\nexport default observer(function Ext1From() {\n  const appName = store.currentRecord.name;\n  let title = `常规发布 - ${appName}`;\n  if (store.deploy.id) {\n    store.isReadOnly ? title = '查看' + title : title = '编辑' + title;\n  } else {\n    title = '新建' + title\n  }\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={title}\n      onCancel={() => store.ext1Visible = false}\n      footer={null}>\n      <Steps current={store.page} className={styles.steps}>\n        <Steps.Step key={0} title=\"基本配置\"/>\n        <Steps.Step key={1} title=\"构建配置\"/>\n        <Steps.Step key={2} title=\"发布配置\"/>\n      </Steps>\n      {store.page === 0 && <Setup1/>}\n      {store.page === 1 && <Setup2/>}\n      {store.page === 2 && <Setup3/>}\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext1Setup1.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Link } from 'react-router-dom';\nimport { Switch, Form, Input, Select, Button, Radio } from 'antd';\nimport Repo from './Repo';\nimport envStore from 'pages/config/environment/store';\nimport HostSelector from 'pages/host/Selector';\nimport store from './store';\n\nexport default observer(function Ext1Setup1() {\n  const [envs, setEnvs] = useState([]);\n  const [visible, setVisible] = useState(false);\n\n  function updateEnvs() {\n    const ids = store.currentRecord['deploys'].map(x => x.env_id);\n    setEnvs(ids.filter(x => x !== store.deploy.env_id))\n  }\n\n  useEffect(() => {\n    if (store.currentRecord['deploys'] === undefined) {\n      store.loadDeploys(store.app_id).then(updateEnvs)\n    } else {\n      updateEnvs()\n    }\n  }, [])\n\n  const info = store.deploy;\n  let modePlaceholder;\n  switch (info['rst_notify']['mode']) {\n    case '0':\n      modePlaceholder = '已关闭'\n      break\n    case '1':\n      modePlaceholder = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'\n      break\n    case '3':\n      modePlaceholder = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx'\n      break\n    case '4':\n      modePlaceholder = 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'\n      break\n    default:\n      modePlaceholder = '请输入'\n  }\n  return (\n    <Form labelCol={{span: 6}} wrapperCol={{span: 14}}>\n      <Form.Item required label=\"发布环境\" style={{marginBottom: 0}} tooltip=\"可以建立多个环境，实现同一应用在不同环境里配置不同的发布流程。\">\n        <Form.Item style={{display: 'inline-block', width: '80%'}}>\n          <Select disabled={store.isReadOnly} value={info.env_id} onChange={v => info.env_id = v} placeholder=\"请选择发布环境\">\n            {envStore.records.map(item => (\n              <Select.Option disabled={envs.includes(item.id)} value={item.id} key={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item style={{display: 'inline-block', width: '20%', textAlign: 'right'}}>\n          <Link disabled={store.isReadOnly} to=\"/config/environment\">新建环境</Link>\n        </Form.Item>\n      </Form.Item>\n      <Form.Item required label=\"目标主机\" tooltip=\"该发布配置作用于哪些目标主机。\">\n        <HostSelector value={info.host_ids} onChange={ids => info.host_ids = ids}/>\n      </Form.Item>\n      <Form.Item required label=\"Git仓库地址\" extra={<span className=\"btn\" onClick={() => setVisible(true)}>私有仓库？</span>}>\n        <Input disabled={store.isReadOnly} value={info['git_repo']} onChange={e => info['git_repo'] = e.target.value}\n               placeholder=\"请输入Git仓库地址\"/>\n      </Form.Item>\n      <Form.Item label=\"发布模式\" tooltip=\"串行即发布时一台完成后再发布下一台，期间出现异常则终止发布。并行则每个主机相互独立发布同时进行。\">\n        <Radio.Group\n          buttonStyle=\"solid\"\n          defaultValue={true}\n          value={info.is_parallel}\n          onChange={e => info.is_parallel = e.target.value}>\n          <Radio.Button value={true}>并行</Radio.Button>\n          <Radio.Button value={false}>串行</Radio.Button>\n        </Radio.Group>\n      </Form.Item>\n      <Form.Item label=\"发布审核\" tooltip=\"开启后发布申请需要审核（审核权限在系统管理/角色管理/功能权限中配置）通过后才能发布。\">\n        <Switch\n          disabled={store.isReadOnly}\n          checkedChildren=\"开启\"\n          unCheckedChildren=\"关闭\"\n          checked={info['is_audit']}\n          onChange={v => info['is_audit'] = v}/>\n      </Form.Item>\n      <Form.Item label=\"消息通知\" extra={<span>\n        应用审核及发布成功或失败结果通知，\n        <a target=\"_blank\" rel=\"noopener noreferrer\"\n           href=\"https://ops.spug.cc/docs/use-problem#use-dd\">钉钉收不到通知？</a>\n      </span>}>\n        <Input\n          addonBefore={(\n            <Select\n              disabled={store.isReadOnly}\n              value={info['rst_notify']['mode']} style={{width: 100}}\n              onChange={v => info['rst_notify']['mode'] = v}>\n              <Select.Option value=\"0\">关闭</Select.Option>\n              <Select.Option value=\"1\">钉钉</Select.Option>\n              <Select.Option value=\"4\">飞书</Select.Option>\n              <Select.Option value=\"3\">企业微信</Select.Option>\n              <Select.Option value=\"2\">Webhook</Select.Option>\n            </Select>\n          )}\n          disabled={store.isReadOnly || info['rst_notify']['mode'] === '0'}\n          value={info['rst_notify']['value']}\n          onChange={e => info['rst_notify']['value'] = e.target.value}\n          placeholder={modePlaceholder}/>\n      </Form.Item>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button\n          type=\"primary\"\n          disabled={!(info.env_id && info.git_repo && info.host_ids.length)}\n          onClick={() => store.page += 1}>下一步</Button>\n      </Form.Item>\n      {visible && <Repo url={info['git_repo']} onOk={v => info['git_repo'] = v} onCancel={() => setVisible(false)}/>}\n    </Form>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext1Setup2.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { QuestionCircleOutlined } from '@ant-design/icons';\nimport { Form, Radio, Button, Tooltip } from 'antd';\nimport { ACEditor } from 'components';\nimport { cleanCommand } from 'libs';\nimport 'ace-builds/src-noconflict/mode-text';\nimport 'ace-builds/src-noconflict/mode-sh';\nimport 'ace-builds/src-noconflict/theme-tomorrow';\nimport Tips from './Tips';\nimport store from './store';\n\nexport default observer(function () {\n  function handleNext() {\n    store.page += 1\n  }\n\n  const FilterHead = (\n    <div style={{width: 512, display: 'flex', justifyContent: 'space-between'}}>\n      <span>\n        文件过滤规则 &nbsp;\n        <Tooltip title=\"请输入相对于项目根目录的文件路径，根据包含或排除规则进行打包。\">\n          <QuestionCircleOutlined style={{color: 'rgba(0, 0, 0, 0.45)'}}/>\n        </Tooltip>\n      </span>\n      <Radio.Group\n        size=\"small\"\n        value={store.deploy.filter_rule.type}\n        onChange={e => store.deploy.filter_rule.type = e.target.value}>\n        <Radio.Button value=\"contain\">\n          <Tooltip title=\"仅打包匹配到的文件或目录，如果内容为空则打包所有。\">包含</Tooltip>\n        </Radio.Button>\n        <Radio.Button value=\"exclude\">\n          <Tooltip title=\"打包时排除匹配到的文件或目录，如果内容为空则不排除任何文件。\">排除</Tooltip>\n        </Radio.Button>\n      </Radio.Group>\n    </div>\n  )\n\n  const info = store.deploy;\n  return (\n    <Form layout=\"vertical\" style={{padding: '0 120px'}}>\n      <Form.Item label={FilterHead}>\n        <ACEditor\n          readOnly={store.isReadOnly}\n          mode=\"sh\"\n          width=\"100%\"\n          height=\"80px\"\n          placeholder=\"每行一条规则\"\n          value={info['filter_rule']['data']}\n          onChange={v => info['filter_rule']['data'] = cleanCommand(v)}\n          style={{border: '1px solid #e8e8e8'}}/>\n      </Form.Item>\n      <Form.Item\n        label=\"代码检出前执行\"\n        tooltip=\"在运行 Spug 的服务器(或容器)上执行，当前目录为仓库源代码目录，可以执行任意自定义命令。\"\n        extra={<span>{Tips}，请避免在此修改已跟踪的文件，防止在检出代码时失败。</span>}>\n        <ACEditor\n          readOnly={store.isReadOnly}\n          mode=\"sh\"\n          theme=\"tomorrow\"\n          width=\"100%\"\n          height=\"120px\"\n          placeholder=\"输入要执行的命令\"\n          value={info['hook_pre_server']}\n          onChange={v => info['hook_pre_server'] = cleanCommand(v)}\n          style={{border: '1px solid #e8e8e8'}}/>\n      </Form.Item>\n      <Form.Item\n        label=\"代码检出后执行\"\n        style={{marginTop: 12, marginBottom: 24}}\n        tooltip=\"在运行 Spug 的服务器(或容器)上执行，当前目录为检出后的源代码目录，可执行任意自定义命令。\"\n        extra={<span>{Tips}，大多数情况下在此进行构建操作。</span>}>\n        <ACEditor\n          readOnly={store.isReadOnly}\n          mode=\"sh\"\n          theme=\"tomorrow\"\n          width=\"100%\"\n          height=\"120px\"\n          placeholder=\"输入要执行的命令\"\n          value={info['hook_post_server']}\n          onChange={v => info['hook_post_server'] = cleanCommand(v)}\n          style={{border: '1px solid #e8e8e8'}}/>\n      </Form.Item>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button type=\"primary\" onClick={handleNext}>下一步</Button>\n        <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n      </Form.Item>\n    </Form>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext1Setup3.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Form, Button, Input, Row, Col, message } from 'antd';\nimport { ACEditor } from 'components';\nimport { http, cleanCommand } from 'libs';\nimport Tips from './Tips';\nimport store from './store';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    const {dst_dir, dst_repo} = store.deploy;\n    const t_dst_dir = dst_dir.replace(/\\/*$/, '/');\n    const t_dst_repo = dst_repo.replace(/\\/*$/, '/');\n    if (t_dst_repo.includes(t_dst_dir)) {\n      return message.error('存储路径不能位于部署路径内')\n    }\n    setLoading(true);\n    const info = store.deploy;\n    info['app_id'] = store.app_id;\n    info['extend'] = '1';\n    http.post('/api/app/deploy/', info)\n      .then(() => {\n        message.success('保存成功');\n        store.loadDeploys(store.app_id);\n        store.ext1Visible = false\n      }, () => setLoading(false))\n  }\n\n  const info = store.deploy;\n  return (\n    <Form layout=\"vertical\" style={{padding: '0 120px'}}>\n      <Form.Item required label=\"部署路径\" tooltip=\"应用最终在主机上的部署路径，为了数据安全请确保该目录不存在，Spug 将会自动创建并接管该目录，可使用全局变量，例如：/www/$SPUG_APP_KEY\">\n        <Input value={info['dst_dir']} onChange={e => info['dst_dir'] = e.target.value} placeholder=\"请输入部署目标路径\"/>\n      </Form.Item>\n      <Row gutter={24}>\n        <Col span={14}>\n          <Form.Item required label=\"存储路径\" tooltip=\"此目录用于存储应用的历史版本，可使用全局变量，例如：/data/repos/$SPUG_APP_KEY\">\n            <Input value={info['dst_repo']} onChange={e => info['dst_repo'] = e.target.value} placeholder=\"请输入部署目标路径\"/>\n          </Form.Item>\n        </Col>\n        <Col span={10}>\n          <Form.Item required label=\"版本数量\" tooltip=\"早于指定数量的构建纪录及历史版本会被删除，以释放磁盘空间。\">\n            <Input value={info['versions']} onChange={e => info['versions'] = e.target.value} placeholder=\"请输入保存的版本数量\"/>\n          </Form.Item>\n        </Col>\n      </Row>\n      <Form.Item\n        label=\"应用发布前执行\"\n        tooltip=\"在发布的目标主机上运行，当前目录为目标主机上待发布的源代码目录，可执行任意自定义命令。\"\n        extra={<span>{Tips}，此时还未进行文件变更，可进行一些发布前置操作。</span>}>\n        <ACEditor\n          readOnly={store.isReadOnly}\n          mode=\"sh\"\n          theme=\"tomorrow\"\n          width=\"100%\"\n          height=\"150px\"\n          placeholder=\"输入要执行的命令\"\n          value={info['hook_pre_host']}\n          onChange={v => info['hook_pre_host'] = cleanCommand(v)}\n          style={{border: '1px solid #e8e8e8'}}/>\n      </Form.Item>\n      <Form.Item\n        label=\"应用发布后执行\"\n        style={{marginTop: 12, marginBottom: 24}}\n        tooltip=\"在发布的目标主机上运行，当前目录为已发布的应用目录，可执行任意自定义命令。\"\n        extra={<span>{Tips}，可以在发布后进行重启服务等操作。</span>}>\n        <ACEditor\n          readOnly={store.isReadOnly}\n          mode=\"sh\"\n          theme=\"tomorrow\"\n          width=\"100%\"\n          height=\"150px\"\n          placeholder=\"输入要执行的命令\"\n          value={info['hook_post_host']}\n          onChange={v => info['hook_post_host'] = cleanCommand(v)}\n          style={{border: '1px solid #e8e8e8'}}/>\n      </Form.Item>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button disabled={store.isReadOnly} loading={loading} type=\"primary\" onClick={handleSubmit}>提交</Button>\n        <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n      </Form.Item>\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext2Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Steps } from 'antd';\nimport styles from './index.module.css';\nimport Setup1 from './Ext2Setup1';\nimport Setup2 from './Ext2Setup2';\nimport store from './store';\n\nexport default observer(function Ext2From() {\n  const appName = store.currentRecord.name;\n  let title = `自定义发布 - ${appName}`;\n  if (store.deploy.id) {\n    store.isReadOnly ? title = '查看' + title : title = '编辑' + title;\n  } else {\n    title = '新建' + title\n  }\n  return (\n    <Modal\n      visible\n      width={900}\n      maskClosable={false}\n      title={title}\n      onCancel={() => store.ext2Visible = false}\n      footer={null}>\n      <Steps current={store.page} className={styles.steps}>\n        <Steps.Step key={0} title=\"基本配置\"/>\n        <Steps.Step key={1} title=\"执行动作\"/>\n      </Steps>\n      {store.page === 0 && <Setup1/>}\n      {store.page === 1 && <Setup2/>}\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext2Setup1.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Link } from 'react-router-dom';\nimport { Form, Switch, Select, Button, Input, Radio } from 'antd';\nimport envStore from 'pages/config/environment/store';\nimport HostSelector from 'pages/host/Selector';\nimport store from './store';\n\nexport default observer(function Ext2Setup1() {\n  const [envs, setEnvs] = useState([]);\n\n  function updateEnvs() {\n    const ids = store.currentRecord['deploys'].map(x => x.env_id);\n    setEnvs(ids.filter(x => x !== store.deploy.env_id))\n  }\n\n  useEffect(() => {\n    if (store.currentRecord['deploys'] === undefined) {\n      store.loadDeploys(store.app_id).then(updateEnvs)\n    } else {\n      updateEnvs()\n    }\n  }, [])\n\n  const info = store.deploy;\n  let modePlaceholder;\n  switch (info['rst_notify']['mode']) {\n    case '0':\n      modePlaceholder = '已关闭'\n      break\n    case '1':\n      modePlaceholder = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'\n      break\n    case '3':\n      modePlaceholder = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx'\n      break\n    case '4':\n      modePlaceholder = 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'\n      break\n    default:\n      modePlaceholder = '请输入'\n  }\n  return (\n    <Form labelCol={{span: 6}} wrapperCol={{span: 14}}>\n      <Form.Item required label=\"发布环境\" style={{marginBottom: 0}} tooltip=\"可以建立多个环境，实现同一应用在不同环境里配置不同的发布流程。\">\n        <Form.Item style={{display: 'inline-block', width: '80%'}}>\n          <Select disabled={store.isReadOnly} value={info.env_id} onChange={v => info.env_id = v} placeholder=\"请选择发布环境\">\n            {envStore.records.map(item => (\n              <Select.Option disabled={envs.includes(item.id)} value={item.id} key={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item style={{display: 'inline-block', width: '20%', textAlign: 'right'}}>\n          <Link disabled={store.isReadOnly} to=\"/config/environment\">新建环境</Link>\n        </Form.Item>\n      </Form.Item>\n      <Form.Item required label=\"目标主机\" tooltip=\"该发布配置作用于哪些目标主机。\">\n        <HostSelector value={info.host_ids} onChange={ids => info.host_ids = ids}/>\n      </Form.Item>\n      <Form.Item label=\"发布模式\" tooltip=\"串行即发布时一台完成后再发布下一台，期间出现异常则终止发布。并行则每个主机相互独立发布同时进行。\">\n        <Radio.Group\n          buttonStyle=\"solid\"\n          defaultValue={true}\n          value={info.is_parallel}\n          onChange={e => info.is_parallel = e.target.value}>\n          <Radio.Button value={true}>并行</Radio.Button>\n          <Radio.Button value={false}>串行</Radio.Button>\n        </Radio.Group>\n      </Form.Item>\n      <Form.Item label=\"发布审核\" tooltip=\"开启后发布申请需要审核（审核权限在系统管理/角色管理/功能权限中配置）通过后才能发布。\">\n        <Switch\n          disabled={store.isReadOnly}\n          checkedChildren=\"开启\"\n          unCheckedChildren=\"关闭\"\n          checked={info['is_audit']}\n          onChange={v => info['is_audit'] = v}/>\n      </Form.Item>\n      <Form.Item label=\"消息通知\" extra={<span>\n        应用审核及发布成功或失败结果通知，\n        <a target=\"_blank\" rel=\"noopener noreferrer\"\n           href=\"https://ops.spug.cc/docs/use-problem#use-dd\">钉钉收不到通知？</a>\n      </span>}>\n        <Input\n          addonBefore={(\n            <Select disabled={store.isReadOnly}\n                    value={info['rst_notify']['mode']} style={{width: 100}}\n                    onChange={v => info['rst_notify']['mode'] = v}>\n              <Select.Option value=\"0\">关闭</Select.Option>\n              <Select.Option value=\"1\">钉钉</Select.Option>\n              <Select.Option value=\"4\">飞书</Select.Option>\n              <Select.Option value=\"3\">企业微信</Select.Option>\n              <Select.Option value=\"2\">Webhook</Select.Option>\n            </Select>\n          )}\n          disabled={store.isReadOnly || info['rst_notify']['mode'] === '0'}\n          value={info['rst_notify']['value']}\n          onChange={e => info['rst_notify']['value'] = e.target.value}\n          placeholder={modePlaceholder}/>\n      </Form.Item>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button\n          type=\"primary\"\n          disabled={!(info.env_id && info.host_ids.length)}\n          onClick={() => store.page += 1}>下一步</Button>\n      </Form.Item>\n    </Form>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Ext2Setup2.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { MinusCircleOutlined, PlusOutlined, UpOutlined, DownOutlined } from '@ant-design/icons';\nimport { Form, Input, Button, message, Divider, Alert, Select } from 'antd';\nimport { ACEditor } from 'components';\nimport styles from './index.module.css';\nimport { http, cleanCommand } from 'libs';\nimport Tips from './Tips';\nimport store from './store';\nimport lds from 'lodash';\n\n@observer\nclass Ext2Setup2 extends React.Component {\n  constructor(props) {\n    super(props);\n    this.helpMap = {\n      '0': null,\n      '1': '相对于输入的本地路径的文件路径，仅将匹配到文件传输至要发布的目标主机。',\n      '2': '支持模糊匹配，基于输入的本地路径匹配，匹配到文件将不会被传输。'\n    }\n    this.state = {\n      loading: false,\n    }\n  }\n\n  handleSubmit = () => {\n    this.setState({loading: true});\n    const info = store.deploy;\n    info['app_id'] = store.app_id;\n    info['extend'] = '2';\n    info['host_actions'] = info['host_actions'].filter(x => (x.title && x.data) || (x.title && (x.src || x.src_mode === '1') && x.dst));\n    info['server_actions'] = info['server_actions'].filter(x => x.title && x.data);\n    http.post('/api/app/deploy/', info)\n      .then(res => {\n        message.success('保存成功');\n        store.ext2Visible = false;\n        store.loadDeploys(store.app_id)\n      }, () => this.setState({loading: false}))\n  };\n\n  _doAction = (actions, index, action) => {\n    if (action === 'up') {\n      if (index > 0) {\n        [actions[index], actions[index - 1]] = [actions[index - 1], actions[index]]\n      }\n    } else {\n      if (index < actions.length - 1) {\n        [actions[index], actions[index + 1]] = [actions[index + 1], actions[index]]\n      }\n    }\n  }\n\n  handleHostAction = (index, action) => {\n    const actions = store.deploy['host_actions'];\n    this._doAction(actions, index, action)\n  }\n\n  handleServerAction = (index, action) => {\n    const actions = store.deploy['server_actions'];\n    this._doAction(actions, index, action)\n  }\n\n  render() {\n    const server_actions = store.deploy['server_actions'];\n    const host_actions = store.deploy['host_actions'];\n    return (\n      <Form labelCol={{span: 6}} wrapperCol={{span: 14}} className={styles.ext2Form}>\n        {store.deploy.id === undefined && (\n          <Alert\n            closable\n            showIcon\n            type=\"info\"\n            message=\"小提示\"\n            style={{margin: '0 80px 20px'}}\n            description={[\n              <p key={1}>Spug 将遵循先本地后目标主机的原则，按照顺序依次执行添加的动作，例如：本地动作1 -> 本地动作2 -> 目标主机动作1 -> 目标主机动作2 ...</p>,\n              <p key={2}>执行的命令内可以使用发布申请中设置的环境变量 SPUG_RELEASE，一般可用于标记一次发布的版本号或提交ID等，在执行的脚本内通过使用 $SPUG_RELEASE\n                获取其值来执行相应操作。</p>,\n              <p key={3}>{Tips}。</p>\n            ]}/>\n        )}\n        {server_actions.map((item, index) => (\n          <div key={index} style={{marginBottom: 30, position: 'relative'}}>\n            <Form.Item required label={`本地动作${index + 1}`}>\n              <Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}\n                     placeholder=\"请输入\"/>\n            </Form.Item>\n\n            <Form.Item required label=\"执行内容\">\n              <ACEditor\n                readOnly={store.isReadOnly}\n                mode=\"sh\"\n                theme=\"tomorrow\"\n                width=\"100%\"\n                height=\"100px\"\n                value={item['data']}\n                onChange={v => item['data'] = cleanCommand(v)}\n                placeholder=\"请输入要执行的动作\"/>\n            </Form.Item>\n            {!store.isReadOnly && (\n              <React.Fragment>\n                <Button type=\"dashed\" icon={<UpOutlined/>} className={styles.upAction}\n                        onClick={() => this.handleServerAction(index, 'up')}/>\n                <div className={styles.delAction} onClick={() => server_actions.splice(index, 1)}>\n                  <MinusCircleOutlined/>移除\n                </div>\n                <Button type=\"dashed\" icon={<DownOutlined/>} className={styles.downAction}\n                        onClick={() => this.handleServerAction(index, 'down')}/>\n              </React.Fragment>\n            )}\n          </div>\n        ))}\n        {!store.isReadOnly && (\n          <Form.Item wrapperCol={{span: 14, offset: 6}}>\n            <Button type=\"dashed\" block onClick={() => server_actions.push({})}>\n              <PlusOutlined/>添加本地执行动作（在服务端本地执行）\n            </Button>\n          </Form.Item>\n        )}\n        <Divider/>\n        {host_actions.map((item, index) => (\n          <div key={index} style={{marginBottom: 30, position: 'relative'}}>\n            <Form.Item required label={`目标主机动作${index + 1}`}>\n              <Input disabled={store.isReadOnly} value={item['title']} onChange={e => item['title'] = e.target.value}\n                     placeholder=\"请输入\"/>\n            </Form.Item>\n            {item['type'] === 'transfer' ? ([\n              <Form.Item key={0} required label=\"数据来源\">\n                <Input\n                  spellCheck={false}\n                  disabled={store.isReadOnly || item['src_mode'] === '1'}\n                  placeholder=\"请输入本地（部署spug的容器或主机）路径\"\n                  value={item['src_mode'] === '1' ? 'N/A' : item['src']}\n                  onChange={e => item['src'] = e.target.value}\n                  addonBefore={(\n                    <Select disabled={store.isReadOnly} style={{width: 120}} value={item['src_mode'] || '0'}\n                            onChange={v => item['src_mode'] = v}>\n                      <Select.Option value=\"0\">本地路径</Select.Option>\n                      <Select.Option value=\"1\">发布时上传</Select.Option>\n                    </Select>\n                  )}/>\n              </Form.Item>,\n              [undefined, '0'].includes(item['src_mode']) ? (\n                <Form.Item key={1} label=\"过滤规则\" extra={this.helpMap[item['mode']]}>\n                  <Input\n                    spellCheck={false}\n                    placeholder={item['mode'] === '0' ? 'N/A' : '请输入逗号分割的过滤规则'}\n                    value={item['rule']}\n                    onChange={e => item['rule'] = e.target.value.replace('，', ',')}\n                    disabled={store.isReadOnly || item['mode'] === '0'}\n                    addonBefore={(\n                      <Select disabled={store.isReadOnly} style={{width: 120}} value={item['mode']}\n                              onChange={v => item['mode'] = v}>\n                        <Select.Option value=\"0\">关闭</Select.Option>\n                        <Select.Option value=\"1\">包含</Select.Option>\n                        <Select.Option value=\"2\">排除</Select.Option>\n                      </Select>\n                    )}/>\n                </Form.Item>\n              ) : null,\n              <Form.Item key={2} required label=\"目标路径\" extra={<a\n                target=\"_blank\" rel=\"noopener noreferrer\"\n                href=\"https://ops.spug.cc/docs/deploy-config#transfer\">使用前请务必阅读官方文档。</a>}>\n                <Input\n                  disabled={store.isReadOnly}\n                  spellCheck={false}\n                  value={item['dst']}\n                  placeholder=\"请输入目标主机路径\"\n                  onChange={e => item['dst'] = e.target.value}/>\n              </Form.Item>\n            ]) : (\n              <Form.Item required label=\"执行内容\">\n                <ACEditor\n                  readOnly={store.isReadOnly}\n                  mode=\"sh\"\n                  theme=\"tomorrow\"\n                  width=\"100%\"\n                  height=\"100px\"\n                  value={item['data']}\n                  onChange={v => item['data'] = cleanCommand(v)}\n                  placeholder=\"请输入要执行的动作\"/>\n              </Form.Item>\n            )}\n            {!store.isReadOnly && (\n              <React.Fragment>\n                <Button type=\"dashed\" icon={<UpOutlined/>} className={styles.upAction}\n                        onClick={() => this.handleHostAction(index, 'up')}/>\n                <div className={styles.delAction} onClick={() => host_actions.splice(index, 1)}>\n                  <MinusCircleOutlined/>移除\n                </div>\n                <Button type=\"dashed\" icon={<DownOutlined/>} className={styles.downAction}\n                        onClick={() => this.handleHostAction(index, 'down')}/>\n              </React.Fragment>\n            )}\n          </div>\n        ))}\n        {!store.isReadOnly && (\n          <Form.Item wrapperCol={{span: 14, offset: 6}}>\n            <Button disabled={store.isReadOnly} type=\"dashed\" block onClick={() => host_actions.push({})}>\n              <PlusOutlined/>添加目标主机执行动作（在部署目标主机执行）\n            </Button>\n            <Button\n              block\n              type=\"dashed\"\n              style={{marginTop: 8}}\n              disabled={store.isReadOnly || lds.findIndex(host_actions, x => x.type === 'transfer') !== -1}\n              onClick={() => host_actions.push({type: 'transfer', title: '数据传输', mode: '0', src_mode: '0'})}>\n              <PlusOutlined/>添加数据传输动作（仅能添加一个）\n            </Button>\n          </Form.Item>\n        )}\n        <Form.Item wrapperCol={{span: 14, offset: 6}} style={{marginTop: 24}}>\n          <Button\n            type=\"primary\"\n            disabled={store.isReadOnly || [...host_actions, ...server_actions].filter(x => x.title && x.data).length === 0}\n            loading={this.state.loading}\n            onClick={this.handleSubmit}>提交</Button>\n          <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n        </Form.Item>\n      </Form>\n    )\n  }\n}\n\nexport default Ext2Setup2\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/app/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title={store.record.id ? '编辑应用' : '新建应用'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"应用名称\">\n          <Input placeholder=\"请输入应用名称，例如：订单服务\"/>\n        </Form.Item>\n        <Form.Item\n          required\n          name=\"key\"\n          label=\"唯一标识符\"\n          tooltip=\"给应用设置的唯一标识符，会用于配置中心的配置生成。\"\n          extra=\"可以由字母、数字和下划线组成。\">\n          <Input placeholder=\"请输入唯一标识符，例如：api_order\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Repo.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { Modal, Form, Radio, Input, message } from 'antd';\nimport { http } from 'libs';\n\nfunction Repo(props) {\n  const [form] = Form.useForm()\n  const [key, setKey] = useState()\n\n  useEffect(() => {\n    http.post('/api/app/kit/key/', {key: 'public_key'})\n      .then(res => setKey(res))\n    if (props.url) {\n      const fields = props.url.match(/^(https?:\\/\\/)(.+):(.+)@(.*)$/)\n      if (fields && fields.length === 5) {\n        form.setFieldsValue({\n          type: 'password',\n          url: fields[1] + fields[4],\n          username: decodeURIComponent(fields[2]),\n          password: decodeURIComponent(fields[3])\n        })\n      } else if (props.url.startsWith('git@')) {\n        form.setFieldsValue({type: 'key', url: props.url})\n      } else {\n        form.setFieldsValue({url: props.url})\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleSubmit() {\n    const formData = form.getFieldsValue()\n    if (!formData.url) return message.error('请输入仓库地址')\n    let url = formData.url;\n    if (formData.type === 'password') {\n      if (!formData.username) return message.error('请输入账户')\n      if (!formData.password) return message.error('请输入密码')\n      if (formData.url.startsWith('http')) {\n        const username = encodeURIComponent(formData.username)\n        const password = encodeURIComponent(formData.password)\n        url = formData.url.replace(/^(https?:\\/\\/)/, `$1${username}:${password}@`)\n      } else {\n        return message.error('认证类型为账户密码，仓库地址需以http或https开头。')\n      }\n    } else if (formData.url.startsWith('http')) {\n      return message.error('输入的仓库地址以http或https开头，则认证类型需为账户密码认证。')\n    }\n    props.onOk(url)\n    props.onCancel()\n  }\n\n  function copyToClipBoard() {\n    const t = document.createElement('input');\n    t.value = key;\n    document.body.appendChild(t);\n    t.select();\n    document.execCommand('copy');\n    t.remove();\n    message.success('已复制')\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title=\"设置Git仓库\"\n      onCancel={props.onCancel}\n      onOk={handleSubmit}>\n      <Form form={form} labelCol={{span: 6}} wrapperCol={{span: 16}}>\n        <Form.Item label=\"认证类型\" name=\"type\" initialValue=\"password\">\n          <Radio.Group>\n            <Radio.Button value=\"password\">账户密码</Radio.Button>\n            <Radio.Button value=\"key\">密钥</Radio.Button>\n          </Radio.Group>\n        </Form.Item>\n        <Form.Item required label=\"仓库地址\" name=\"url\">\n          <Input placeholder=\"请输入\"/>\n        </Form.Item>\n\n        <Form.Item noStyle shouldUpdate>\n          {({getFieldValue}) =>\n            getFieldValue('type') === 'password' ? (\n              <React.Fragment>\n                <Form.Item required label=\"账户\" name=\"username\">\n                  <Input placeholder=\"请输入\"/>\n                </Form.Item>\n                <Form.Item required label=\"密码\" name=\"password\">\n                  <Input placeholder=\"请输入\"/>\n                </Form.Item>\n              </React.Fragment>\n            ) : (\n              <Form.Item label=\"密钥\" extra={(\n                <span>\n                  请复制该密钥，以Gitee为例可参考\n                  <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://gitee.com/help/articles/4191\">Gitee文档</a>\n                  进行后续配置。\n              </span>\n              )}>\n                <span className=\"btn\" onClick={copyToClipBoard}>点击复制密钥</span>\n              </Form.Item>\n            )\n          }\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n}\n\nexport default Repo\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport {\n  BuildOutlined,\n  DownSquareOutlined,\n  ExclamationCircleOutlined,\n  OrderedListOutlined,\n  UpSquareOutlined,\n  PlusOutlined\n} from '@ant-design/icons';\nimport { Table, Modal, Tag, Divider, message } from 'antd';\nimport { http, hasPermission } from 'libs';\nimport { Action, TableCard, AuthButton } from \"components\";\nimport CloneConfirm from './CloneConfirm';\nimport store from './store';\nimport envStore from 'pages/config/environment/store';\nimport lds from 'lodash';\n\nfunction ComTable() {\n  function handleClone(e, id) {\n    e.stopPropagation();\n    let deploy = null;\n    Modal.confirm({\n      icon: <ExclamationCircleOutlined/>,\n      title: '选择克隆对象',\n      content: <CloneConfirm onChange={v => deploy = v}/>,\n      onOk: () => {\n        if (!deploy) {\n          message.error('请选择要克隆的应用及环境')\n          return Promise.reject()\n        }\n        deploy.env_id = undefined;\n        store.showExtForm(null, id, deploy, true)\n      },\n    })\n  }\n\n  function handleDelete(e, text) {\n    e.stopPropagation();\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除应用【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/app/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  }\n\n  function handleDeployDelete(text) {\n    Modal.confirm({\n      title: '删除确认',\n      content: `删除发布配置将会影响基于该配置所创建发布申请的发布和回滚功能，确定要删除【${lds.get(envStore.idMap, `${text.env_id}.name`)}】的发布配置?`,\n      onOk: () => {\n        return http.delete('/api/app/deploy/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.loadDeploys(text.app_id)\n          })\n      }\n    })\n  }\n\n  function handleSort(e, info, sort) {\n    e.stopPropagation();\n    store.fetching = true;\n    http.patch('/api/app/', {id: info.id, sort})\n      .then(store.fetchRecords, () => store.fetching = false)\n  }\n\n  function handleExpand(expanded, row) {\n    if (expanded && !row.isLoaded) {\n      store.loadDeploys(row.id)\n    }\n  }\n\n  function expandedRowRender(record) {\n    return (\n      <Table\n        rowKey=\"id\"\n        loading={record['deploys'] === undefined}\n        dataSource={record['deploys']}\n        pagination={false}>\n        <Table.Column width={80} title=\"模式\" dataIndex=\"extend\" render={value => value === '1' ?\n          <OrderedListOutlined style={{fontSize: 20, color: '#1890ff'}}/> :\n          <BuildOutlined style={{fontSize: 20, color: '#1890ff'}}/>}/>\n        <Table.Column title=\"发布环境\" dataIndex=\"env_id\" render={value => lds.get(envStore.idMap, `${value}.name`)}/>\n        <Table.Column title=\"关联主机\" dataIndex=\"host_ids\" render={value => `${value.length} 台`}/>\n        <Table.Column title=\"发布审核\" dataIndex=\"is_audit\"\n                      render={value => value ? <Tag color=\"green\">开启</Tag> : <Tag color=\"red\">关闭</Tag>}/>\n        {hasPermission('deploy.app.config|deploy.app.edit') && (\n          <Table.Column title=\"操作\" render={info => (\n            <Action>\n              <Action.Button\n                auth=\"deploy.app.config\"\n                onClick={e => store.showAutoDeploy(info)}>Webhook</Action.Button>\n              {hasPermission('deploy.app.edit') ? (\n                <Action.Button onClick={e => store.showExtForm(e, record.id, info)}>编辑</Action.Button>\n              ) : hasPermission('deploy.app.config') ? (\n                <Action.Button onClick={e => store.showExtForm(e, record.id, info, false, true)}>查看</Action.Button>\n              ) : null}\n              <Action.Button danger auth=\"deploy.app.edit\" onClick={() => handleDeployDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </Table>\n    )\n  }\n\n  return (\n    <TableCard\n      tKey=\"da\"\n      title=\"应用列表\"\n      rowKey=\"id\"\n      loading={store.isFetching}\n      dataSource={store.dataSource}\n      expandable={{expandedRowRender, expandRowByClick: true, onExpand: handleExpand}}\n      onReload={store.fetchRecords}\n      actions={[\n        <AuthButton\n          auth=\"deploy.app.add\"\n          type=\"primary\"\n          icon={<PlusOutlined/>}\n          onClick={() => store.showForm()}>新建</AuthButton>\n      ]}\n      pagination={{\n        showSizeChanger: true,\n        showLessItems: true,\n        showTotal: total => `共 ${total} 条`,\n        pageSizeOptions: ['10', '20', '50', '100']\n      }}>\n      <Table.Column width={80} title=\"排序\" key=\"series\" render={(info) => (\n        <div>\n          <UpSquareOutlined\n            onClick={e => handleSort(e, info, 'up')}\n            style={{cursor: 'pointer', color: '#1890ff'}}/>\n          <Divider type=\"vertical\"/>\n          <DownSquareOutlined\n            onClick={e => handleSort(e, info, 'down')}\n            style={{cursor: 'pointer', color: '#1890ff'}}/>\n        </div>\n      )}/>\n      <Table.Column title=\"应用名称\" dataIndex=\"name\"/>\n      <Table.Column title=\"标识符\" dataIndex=\"key\"/>\n      <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n      {hasPermission('deploy.app.edit|deploy.app.del') && (\n        <Table.Column width={260} title=\"操作\" render={info => (\n          <Action>\n            <Action.Button auth=\"deploy.app.edit\" onClick={e => store.showExtForm(e, info.id)}>新建发布</Action.Button>\n            <Action.Button auth=\"deploy.app.edit\" onClick={e => handleClone(e, info.id)}>克隆发布</Action.Button>\n            <Action.Button auth=\"deploy.app.edit\" onClick={e => store.showForm(e, info)}>编辑</Action.Button>\n            <Action.Button danger auth=\"deploy.app.del\" onClick={e => handleDelete(e, info)}>删除</Action.Button>\n          </Action>\n        )}/>\n      )}\n    </TableCard>\n  )\n}\n\nexport default observer(ComTable)\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/Tips.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Tooltip } from 'antd';\n\nconst Tips1 = (\n  <a\n    target=\"_blank\"\n    rel=\"noopener noreferrer\"\n    href=\"https://ops.spug.cc/docs/deploy-config#global-env\">内置全局变量</a>\n)\n\nconst Tips2 = (\n  <Tooltip title=\"配置中心应用的配置将会以 _SPUG_标识符_Key 方式组合成环境变量，可通过执行 env | grep SPUG 来查看所有的内置的和配置中心的可使用变量。\">\n    <span style={{color: '#2563fc'}}>配置中心的配置变量</span>\n  </Tooltip>\n)\n\nexport default (\n  <span>可使用 {Tips1} 和 {Tips2}</span>\n)"
  },
  {
    "path": "spug_web/src/pages/deploy/app/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport Ext1Form from './Ext1Form';\nimport Ext2Form from './Ext2Form';\nimport AddSelect from './AddSelect';\nimport AutoDeploy from './AutoDeploy';\nimport store from './store';\nimport envStore from 'pages/config/environment/store';\n\nexport default observer(function () {\n  useEffect(() => {\n    store.fetchRecords();\n    if (envStore.records.length === 0) {\n      envStore.fetchRecords()\n    }\n  }, [])\n  return (\n    <AuthDiv auth=\"deploy.app.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>应用发布</Breadcrumb.Item>\n        <Breadcrumb.Item>应用管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={7} title=\"应用名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n        <SearchForm.Item span={7} title=\"描述信息\">\n          <Input allowClear value={store.f_desc} onChange={e => store.f_desc = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n      {store.addVisible && <AddSelect/>}\n      {store.ext1Visible && <Ext1Form/>}\n      {store.ext2Visible && <Ext2Form/>}\n      {store.autoVisible && <AutoDeploy/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/app/index.module.css",
    "content": ".steps {\n    width: 520px;\n    margin: 0 auto 30px;\n}\n\n.delIcon {\n    font-size: 24px;\n    position: relative;\n    top: 4px\n}\n\n.delIcon:hover {\n    color: #f5222d;\n}\n\n.deployBlock {\n    height: 100px;\n    margin-top: 63px;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n}\n\n.cardBlock {\n    display: flex;\n    justify-content: space-around;\n    background-color: rgba(240, 242, 245, 1);\n    padding: 50px 0;\n}\n\n.cardTitle {\n    margin-bottom: 12px;\n    font-weight: 500;\n    font-size: 16px;\n    color: rgba(0, 0, 0, .85);\n}\n\n.cardDesc {\n    height: 64px;\n    overflow: hidden;\n    color: rgba(0, 0, 0, .65);\n}\n\n.ext2Form :global(.ant-form-item) {\n    margin-bottom: 10px;\n}\n\n.delAction {\n    cursor: pointer;\n    position: absolute;\n    width: 35px;\n    padding: 5px 10px;\n    text-align: center;\n    top: 32px;\n    right: 60px;\n    border: 1px dashed #d9d9d9;\n    border-radius: 5px;\n}\n\n.delAction:hover {\n    border-color: rgb(255, 96, 59);\n    color: rgb(255, 96, 59);\n}\n\n.upAction {\n    position: absolute;\n    width: 35px;\n    height: 26px;\n    top: 0;\n    right: 60px;\n    border-radius: 5px;\n    color: #d9d9d9;\n}\n\n.downAction {\n    position: absolute;\n    width: 35px;\n    height: 26px;\n    bottom: 0;\n    right: 60px;\n    border-radius: 5px;\n    color: #d9d9d9;\n}\n\n.fullScreen {\n    background-color: #fff;\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    z-index: 999;\n}\n\n.webhook {\n    cursor: pointer;\n    color: #1890ff;\n}"
  },
  {
    "path": "spug_web/src/pages/deploy/app/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed, toJS } from 'mobx';\nimport http from 'libs/http';\nimport lds from 'lodash';\n\nclass Store {\n  @observable records = {};\n  @observable record = {};\n  @observable deploy = {};\n  @observable page = 0;\n  @observable loading = {};\n  @observable isReadOnly = false;\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable addVisible = false;\n  @observable ext1Visible = false;\n  @observable ext2Visible = false;\n  @observable autoVisible = false;\n\n  @observable f_name;\n  @observable f_desc;\n\n  @computed get dataSource() {\n    let records = Object.values(toJS(this.records));\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    if (this.f_desc) records = records.filter(x => x.desc && x.desc.toLowerCase().includes(this.f_desc.toLowerCase()));\n    return records\n  }\n\n  @computed get currentRecord() {\n    return this.records[`a${this.app_id}`]\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/app/')\n      .then(res => {\n        const tmp = {};\n        for (let item of res) {\n          Object.assign(item, lds.pick(this.records[`a${item.id}`], ['isLoaded', 'deploys']));\n          tmp[`a${item.id}`] = item\n        }\n        this.records = tmp\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  loadDeploys = (app_id) => {\n    this.records[`a${app_id}`].isLoaded = true;\n    return http.get('/api/app/deploy/', {params: {app_id}})\n      .then(res => this.records[`a${app_id}`]['deploys'] = res)\n  };\n\n  showForm = (e, info) => {\n    if (e) e.stopPropagation();\n    this.record = info || {};\n    this.formVisible = true;\n  };\n\n  showExtForm = (e, app_id, info, isClone, isReadOnly = false) => {\n    if (e) e.stopPropagation();\n    this.page = 0;\n    this.app_id = app_id;\n    this.isReadOnly = isReadOnly\n    if (info) {\n      if (info.extend === '1') {\n        this.ext1Visible = true\n      } else {\n        this.ext2Visible = true\n      }\n      isClone && delete info.id;\n      this.deploy = info\n    } else {\n      this.addVisible = true;\n    }\n  };\n\n  showAutoDeploy = (deploy) => {\n    this.deploy = deploy;\n    this.autoVisible = true\n  }\n\n  addHost = () => {\n    this.deploy['host_ids'].push(undefined)\n  };\n\n  editHost = (index, v) => {\n    this.deploy['host_ids'][index] = v\n  };\n\n  delHost = (index) => {\n    this.deploy['host_ids'].splice(index, 1)\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/Console.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect, useRef } from 'react';\nimport { observer } from 'mobx-react';\nimport { FullscreenOutlined, FullscreenExitOutlined, LoadingOutlined } from '@ant-design/icons';\nimport { FitAddon } from 'xterm-addon-fit';\nimport { Terminal } from 'xterm';\nimport { Modal, Steps, Spin } from 'antd';\nimport { X_TOKEN, http } from 'libs';\nimport styles from './index.module.less';\nimport store from './store';\n\nexport default observer(function Console() {\n  const el = useRef()\n  const [term] = useState(new Terminal({disableStdin: true}))\n  const [fullscreen, setFullscreen] = useState(false);\n  const [step, setStep] = useState(0);\n  const [status, setStatus] = useState('process');\n  const [fetching, setFetching] = useState(true);\n\n  useEffect(() => {\n    let socket;\n    initialTerm()\n    http.get(`/api/repository/${store.record.id}/`)\n      .then(res => {\n        term.write(res.data)\n        setStep(res.step)\n        if (res.status === '1') {\n          socket = _makeSocket(res.index)\n        } else {\n          setStatus('wait')\n        }\n      })\n      .finally(() => setFetching(false))\n    return () => socket && socket.close()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function _makeSocket(index = 0) {\n    const token = store.record.spug_version;\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/build/${token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => socket.send(String(index));\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send(String(index))\n      } else {\n        index += 1;\n        const {data, step, status} = JSON.parse(e.data);\n        if (data !== undefined) term.write(data);\n        if (step !== undefined) setStep(step);\n        if (status !== undefined) setStatus(status);\n      }\n    }\n    socket.onerror = () => {\n      setStatus('error')\n      term.reset()\n      term.write('\\u001b[31mWebsocket connection failed!\\u001b[0m')\n    }\n    return socket\n  }\n\n  useEffect(() => {\n    term.fit && term.fit()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [fullscreen])\n\n  function initialTerm() {\n    const fitPlugin = new FitAddon()\n    term.loadAddon(fitPlugin)\n    term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')\n    term.setOption('theme', {background: '#fafafa', foreground: '#000', selection: '#999'})\n    term.attachCustomKeyEventHandler((arg) => {\n      if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {\n        document.execCommand('copy')\n        return false\n      }\n      return true\n    })\n    term.open(el.current)\n    term.fit = () => fitPlugin.fit()\n    fitPlugin.fit()\n  }\n\n  function handleClose() {\n    store.fetchRecords();\n    store.logVisible = false\n  }\n\n  function StepItem(props) {\n    let icon = null;\n    if (props.step === step && status === 'process') {\n      icon = <LoadingOutlined style={{fontSize: 32}}/>\n    }\n    return <Steps.Step {...props} icon={icon}/>\n  }\n\n  return (\n    <Modal\n      visible\n      width={fullscreen ? '100%' : 1000}\n      title={[\n        <span key=\"1\">构建控制台</span>,\n        <div key=\"2\" className={styles.fullscreen} onClick={() => setFullscreen(!fullscreen)}>\n          {fullscreen ? <FullscreenExitOutlined/> : <FullscreenOutlined/>}\n        </div>\n      ]}\n      footer={null}\n      onCancel={handleClose}\n      className={styles.console}\n      maskClosable={false}>\n      <Steps current={step} status={status}>\n        <StepItem title=\"构建准备\" step={0}/>\n        <StepItem title=\"检出前任务\" step={1}/>\n        <StepItem title=\"执行检出\" step={2}/>\n        <StepItem title=\"检出后任务\" step={3}/>\n        <StepItem title=\"执行打包\" step={4}/>\n      </Steps>\n      <Spin spinning={fetching}>\n        <div className={styles.out}>\n          <div ref={el}/>\n        </div>\n      </Spin>\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/Detail.js",
    "content": "import React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Drawer, Descriptions, Table, Button } from 'antd';\nimport { AuthDiv } from 'components';\nimport { http } from 'libs';\nimport store from './store';\n\nexport default observer(function (props) {\n  const [fetching, setFetching] = useState(true);\n  const [requests, setRequests] = useState([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    if (store.record.id && props.visible) {\n      http.get('/api/repository/request/', {params: {repository_id: store.record.id}})\n        .then(res => setRequests(res))\n        .finally(() => setFetching(false))\n    }\n  }, [props.visible])\n\n  function handleDelete() {\n    setLoading(true);\n    http.delete('/api/repository/', {params: {id: store.record.id}})\n      .then(() => {\n        store.fetchRecords();\n        store.detailVisible = false\n      })\n      .finally(() => setLoading(false))\n  }\n\n  const record = store.record;\n  const [extra1, extra2, extra3] = record.extra || [];\n  return (\n    <Drawer\n      width={600}\n      visible={props.visible}\n      onClose={() => store.detailVisible = false}\n      footer={(\n        <AuthDiv\n          auth=\"deploy.repository.del\"\n          style={{display: 'flex', justifyContent: 'flex-end', alignItems: 'flex-end'}}>\n          <span style={{color: '#999', fontSize: 12}}>Tips: 已关联发布申请的构建版本无法删除（删除发布申请时将同步删除该记录）。</span>\n          <Button danger loading={loading} disabled={requests.length > 0} onClick={handleDelete}>删除</Button>\n        </AuthDiv>\n      )}>\n      <Descriptions column={1} title={<span style={{fontSize: 22}}>基本信息</span>}>\n        <Descriptions.Item label=\"应用\">{record.app_name}</Descriptions.Item>\n        <Descriptions.Item label=\"环境\">{record.env_name}</Descriptions.Item>\n        <Descriptions.Item label=\"版本\">{record.version}</Descriptions.Item>\n        {extra1 === 'branch' ? ([\n          <Descriptions.Item key=\"1\" label=\"Git分支\">{extra2}</Descriptions.Item>,\n          <Descriptions.Item key=\"2\" label=\"CommitID\">{extra3}</Descriptions.Item>,\n        ]) : (\n          <Descriptions.Item label=\"Git标签\">{extra2}</Descriptions.Item>\n        )}\n        <Descriptions.Item label=\"内部版本\">{record.spug_version}</Descriptions.Item>\n        <Descriptions.Item label=\"构建时间\">{record.created_at}</Descriptions.Item>\n        <Descriptions.Item label=\"备注信息\">{record.remarks}</Descriptions.Item>\n        <Descriptions.Item label=\"构建人\">{record.created_by_user}</Descriptions.Item>\n      </Descriptions>\n      <Descriptions title={<span style={{fontSize: 22}}>发布记录</span>} style={{marginTop: 24}}/>\n      <Table rowKey=\"id\" loading={fetching} dataSource={requests} pagination={false}>\n        <Table.Column title=\"发布申请\" dataIndex=\"name\"/>\n        <Table.Column title=\"主机数量\" dataIndex=\"host_ids\" render={v => `${v.length}台`}/>\n        <Table.Column title=\"状态\" dataIndex=\"status_alias\"/>\n        <Table.Column title=\"申请时间\" dataIndex=\"created_at\"/>\n      </Table>\n    </Drawer>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { LoadingOutlined, SyncOutlined } from '@ant-design/icons';\nimport { Modal, Form, Input, Select, Button, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\nimport lds from 'lodash';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [fetching, setFetching] = useState(true);\n  const [git_type, setGitType] = useState();\n  const [extra, setExtra] = useState([]);\n  const [extra1, setExtra1] = useState();\n  const [extra2, setExtra2] = useState();\n  const [versions, setVersions] = useState({});\n\n  useEffect(() => {\n    fetchVersions();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function _setDefault(type, new_extra, new_versions) {\n    const now_extra = new_extra || extra;\n    const now_versions = new_versions || versions;\n    const {branches, tags} = now_versions;\n    if (type === 'branch') {\n      let [branch, commit] = [now_extra[1], null];\n      if (branches[branch]) {\n        commit = lds.get(branches[branch], '0.id')\n      } else {\n        branch = lds.get(Object.keys(branches), 0)\n        commit = lds.get(branches, `${branch}.0.id`)\n      }\n      setExtra1(branch)\n      setExtra2(commit)\n    } else {\n      setExtra1(lds.get(Object.keys(tags), 0))\n      setExtra2(null)\n    }\n  }\n\n  function _initial(versions) {\n    const {branches, tags} = versions;\n    if (branches && tags) {\n      for (let item of store.records) {\n        if (item.deploy_id === store.deploy.id) {\n          const type = item.extra[0];\n          setExtra(item.extra);\n          setGitType(type);\n          return _setDefault(type, item.extra, versions);\n        }\n      }\n      setGitType('branch');\n      const branch = lds.get(Object.keys(branches), 0);\n      const commit = lds.get(branches, `${branch}.0.id`);\n      setExtra1(branch);\n      setExtra2(commit)\n    }\n  }\n\n  function fetchVersions() {\n    setFetching(true);\n    http.get(`/api/app/deploy/${store.deploy.id}/versions/`, {timeout: 120000})\n      .then(res => {\n        setVersions(res);\n        _initial(res)\n      })\n      .finally(() => setFetching(false))\n  }\n\n  function switchType(v) {\n    setGitType(v);\n    _setDefault(v)\n  }\n\n  function switchExtra1(v) {\n    setExtra1(v)\n    if (git_type === 'branch') {\n      setExtra2(lds.get(versions.branches[v], '0.id'))\n    }\n  }\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['deploy_id'] = store.deploy.id;\n    formData['extra'] = [git_type, extra1, extra2];\n    http.post('/api/repository/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.showConsole(res)\n      }, () => setLoading(false))\n  }\n\n  const {branches, tags} = versions;\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title=\"新建构建\"\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 5}} wrapperCol={{span: 17}}>\n        <Form.Item required name=\"version\" label=\"构建版本\">\n          <Input placeholder=\"请输入构建版本\"/>\n        </Form.Item>\n        <Form.Item required label=\"选择分支/标签/版本\" style={{marginBottom: 12}} extra={<span>\n            根据网络情况，首次刷新可能会很慢，请耐心等待。\n            <a target=\"_blank\" rel=\"noopener noreferrer\"\n               href=\"https://ops.spug.cc/docs/use-problem#clone\">clone 失败？</a>\n          </span>}>\n          <Form.Item style={{display: 'inline-block', marginBottom: 0, width: '450px'}}>\n            <Input.Group compact>\n              <Select value={git_type} onChange={switchType} style={{width: 100}}>\n                <Select.Option value=\"branch\">Branch</Select.Option>\n                <Select.Option value=\"tag\">Tag</Select.Option>\n              </Select>\n              <Select\n                showSearch\n                style={{width: 350}}\n                value={extra1}\n                placeholder=\"请稍等\"\n                onChange={switchExtra1}\n                filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}>\n                {git_type === 'branch' ? (\n                  Object.keys(branches || {}).map(b => <Select.Option key={b} value={b}>{b}</Select.Option>)\n                ) : (\n                  Object.entries(tags || {}).map(([tag, info]) => (\n                    <Select.Option key={tag} value={tag}>\n                      <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                        <span style={{\n                          width: 200,\n                          overflow: 'hidden',\n                          textOverflow: 'ellipsis'\n                        }}>{`${tag} ${info.author} ${info.message}`}</span>\n                        <span style={{color: '#999', fontSize: 12}}>{info['date']} </span>\n                      </div>\n                    </Select.Option>\n                  ))\n                )}\n              </Select>\n            </Input.Group>\n          </Form.Item>\n          <Form.Item style={{display: 'inline-block', width: 82, textAlign: 'center', marginBottom: 0}}>\n            {fetching ? <LoadingOutlined style={{fontSize: 18, color: '#1890ff'}}/> :\n              <Button type=\"link\" icon={<SyncOutlined/>} disabled={fetching} onClick={fetchVersions}>刷新</Button>\n            }\n          </Form.Item>\n        </Form.Item>\n        {git_type === 'branch' && (\n          <Form.Item required label=\"选择Commit ID\">\n            <Select value={extra2} placeholder=\"请选择\" onChange={v => setExtra2(v)}>\n              {extra1 && branches ? branches[extra1].map(item => (\n                <Select.Option key={item.id}>\n                  <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                    <span style={{\n                      width: 400,\n                      overflow: 'hidden',\n                      textOverflow: 'ellipsis'\n                    }}>{item.id.substr(0, 6)} {item['author']} {item['message']}</span>\n                    <span style={{color: '#999', fontSize: 12}}>{item['date']} </span>\n                  </div>\n                </Select.Option>\n              )) : null}\n            </Select>\n          </Form.Item>\n        )}\n        <Form.Item name=\"remarks\" label=\"备注信息\">\n          <Input placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, Tag, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton } from 'components';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\n\nfunction ComTable() {\n  const [loading, setLoading] = useState();\n\n  function handleRebuild(info) {\n    if (info.status === '5') {\n      Modal.confirm({\n        title: '重新构建提示',\n        content: `当前选择版本 ${info.version} 已完成构建，再次构建将覆盖已有的数据，要再次重新构建吗？`,\n        onOk: () => _rebuild(info)\n      })\n    } else if (info.status === '1') {\n      return message.error('已在构建中，请点击日志查看详情')\n    } else {\n      _rebuild(info)\n    }\n  }\n\n  function _rebuild(info) {\n    setLoading(info.id);\n    http.patch('/api/repository/', {id: info.id, action: 'rebuild'})\n      .then(() => store.showConsole(info))\n      .finally(() => setLoading(null))\n  }\n\n  function expandedRowRender(record) {\n    return (\n      <Table rowKey=\"id\" dataSource={record.child} pagination={false}>\n        <Table.Column title=\"版本\" render={info => (\n          <div style={{color: '#1890ff', cursor: 'pointer'}} onClick={() => store.showDetail(info)}>{info.version}</div>\n        )}/>\n        <Table.Column title=\"环境\" dataIndex=\"env_name\"/>\n        <Table.Column title=\"构建时间\" dataIndex=\"created_at\"/>\n        <Table.Column title=\"备注\" dataIndex=\"remarks\"/>\n        <Table.Column title=\"状态\" render={info => <Tag color={statusColorMap[info.status]}>{info.status_alias}</Tag>}/>\n        {hasPermission('deploy.repository.detail|deploy.repository.build|deploy.repository.log') && (\n          <Table.Column width={180} title=\"操作\" render={info => (\n            <Action>\n              <Action.Button\n                auth=\"deploy.repository.build\"\n                loading={loading === info.id}\n                disabled={info.remarks === 'SPUG AUTO MAKE'}\n                onClick={() => handleRebuild(info)}>构建</Action.Button>\n              <Action.Button auth=\"deploy.repository.build\" onClick={() => store.showConsole(info)}>日志</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </Table>\n    )\n  }\n\n  const statusColorMap = {'0': 'cyan', '1': 'blue', '2': 'red', '5': 'green'};\n  return (\n    <TableCard\n      tKey=\"dre\"\n      rowKey=\"id\"\n      title=\"构建版本列表\"\n      loading={store.isFetching}\n      dataSource={store.dataSource}\n      onReload={store.fetchRecords}\n      actions={[\n        <AuthButton\n          auth=\"deploy.repository.add\"\n          type=\"primary\"\n          icon={<PlusOutlined/>}\n          onClick={store.showForm}>新建</AuthButton>\n      ]}\n      expandable={{expandedRowRender, expandRowByClick: true}}\n      pagination={{\n        showSizeChanger: true,\n        showLessItems: true,\n        showTotal: total => `共 ${total} 条`,\n        pageSizeOptions: ['10', '20', '50', '100']\n      }}>\n      <Table.Column title=\"应用\" dataIndex=\"app_name\"/>\n      <Table.Column title=\"最新版本\" render={info => `${info.version}（${info.env_name}）`}/>\n      <Table.Column title=\"构建时间\" dataIndex=\"created_at\"/>\n      <Table.Column title=\"构建人\" dataIndex=\"created_by_user\"/>\n      <Table.Column width={100} title=\"状态\"\n                    render={info => <Tag color={statusColorMap[info.status]}>{info.status_alias}</Tag>}/>\n\n    </TableCard>\n  )\n}\n\nexport default observer(ComTable)\n"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Select } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb, AppSelector } from 'components';\nimport { includes } from 'libs';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport Console from './Console';\nimport Detail from './Detail';\nimport store from './store';\nimport envStore from 'pages/config/environment/store';\nimport appStore from 'pages/config/app/store';\n\nexport default observer(function () {\n  useEffect(() => {\n    store.fetchRecords();\n    if (!appStore.records.length) appStore.fetchRecords()\n  }, [])\n  return (\n    <AuthDiv auth=\"deploy.repository.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>应用发布</Breadcrumb.Item>\n        <Breadcrumb.Item>构建仓库</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={6} title=\"应用\">\n          <Select\n            allowClear\n            showSearch\n            value={store.f_app_id}\n            onChange={v => store.f_app_id = v}\n            filterOption={(i, o) => includes(o.children, i)}\n            placeholder=\"请选择\">\n            {appStore.records.map(item => (\n              <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={6} title=\"环境\">\n          <Select\n            allowClear\n            showSearch\n            value={store.f_env_id}\n            onChange={v => store.f_env_id = v}\n            filterOption={(i, o) => includes(o.children, i)}\n            placeholder=\"请选择\">\n            {envStore.records.map(item => (\n              <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.addVisible && (\n        <AppSelector\n        visible\n        filter={item => item.extend === '1'}\n        onCancel={() => store.addVisible = false}\n        onSelect={store.confirmAdd}/>\n      )}\n\n      <Detail visible={store.detailVisible}/>\n      {store.formVisible && <ComForm/>}\n      {store.logVisible && <Console/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/index.module.less",
    "content": ".console {\n  .fullscreen {\n    position: absolute;\n    top: 0;\n    right: 0;\n    display: block;\n    width: 56px;\n    height: 56px;\n    line-height: 56px;\n    text-align: center;\n    cursor: pointer;\n    color: rgba(0, 0, 0, .45);\n    margin-right: 56px;\n  }\n\n  .fullscreen:hover {\n    color: #000;\n  }\n\n  .out {\n    margin-top: 24px;\n    padding: 8px 0 8px 15px;\n    border: 1px solid #d9d9d9;\n    border-radius: 4px;\n    background-color: #fafafa;\n  }\n}\n\n.split {\n  height: 1px;\n  background-color: #eee;\n  margin: 16px 0 24px 0;\n  clear: both\n}"
  },
  {
    "path": "spug_web/src/pages/deploy/repository/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from \"mobx\";\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable deploy = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable addVisible = false;\n  @observable logVisible = false;\n  @observable detailVisible = false;\n\n  @observable f_app_id;\n  @observable f_env_id;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_app_id) records = records.filter(x => x.app_id === this.f_app_id);\n    if (this.f_env_id) records = records.filter(x => x.env_id === this.f_env_id);\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/repository/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = () => {\n    this.record = {};\n    this.addVisible = true\n  };\n\n  confirmAdd = (deploy) => {\n    this.deploy = deploy;\n    this.formVisible = true;\n    this.addVisible = false;\n  };\n\n  showConsole = (info) => {\n    this.record = info;\n    this.logVisible = true\n  };\n\n  showDetail = (info) => {\n    this.record = info;\n    this.detailVisible = true\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Approve.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Switch, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\nimport styles from './index.module.less';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [isPass, setIsPass] = useState(true);\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    http.patch(`/api/deploy/request/${store.record.id}/`, formData)\n      .then(res => {\n        message.success('操作成功');\n        store.approveVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleChange(val) {\n    if (val.is_pass !== undefined) {\n      setIsPass(val.is_pass)\n    }\n  }\n  return (\n    <Modal\n      visible\n      width={600}\n      maskClosable={false}\n      title=\"审核发布申请\"\n      onCancel={() => store.approveVisible = false}\n      confirmLoading={loading}\n      className={styles.approve}\n      onOk={handleSubmit}>\n      <Form form={form} labelCol={{span: 6}} wrapperCol={{span: 14}} onValuesChange={handleChange}>\n        <Form.Item required name=\"is_pass\" initialValue={true} valuePropName=\"checked\" label=\"审批结果\">\n          <Switch checkedChildren=\"通过\" unCheckedChildren=\"驳回\"/>\n        </Form.Item>\n        <Form.Item name=\"reason\" required={isPass === false} label={isPass ? '审批意见' : '驳回原因'}>\n          <Input.TextArea placeholder={isPass ? '请输入审批意见' : '请输入驳回原因'}/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/BatchDelete.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Select, Radio, DatePicker, Space, message } from 'antd';\nimport { http, includes } from 'libs';\nimport store from './store';\nimport appStore from '../app/store';\nimport envStore from 'pages/config/environment/store';\n\nexport default observer(function () {\n  const [mode, setMode] = useState('expire')\n  const [value, setValue] = useState()\n  const [appId, setAppId] = useState()\n  const [envId, setEnvId] = useState()\n  const [loading, setLoading] = useState()\n\n  useEffect(() => {\n    if (Object.keys(appStore.records).length === 0) appStore.fetchRecords()\n    if (envStore.records.length === 0) envStore.fetchRecords()\n  }, [])\n\n  function handleSubmit() {\n    const formData = {mode, value};\n    if (mode === 'deploy') {\n      if (!appId || !envId) return message.error('请选择要删除的应用和环境')\n      formData.value = `${appId},${envId}`\n    } else if (mode === 'expire') {\n      if (!value) return message.error('请选择截止日期')\n      formData.value = value.format('YYYY-MM-DD')\n    } else if (!value) {\n      return message.error('请输入保留个数')\n    }\n    setLoading(true);\n    http.delete('/api/deploy/request/', {params: formData})\n      .then(res => {\n        message.success(`删除 ${res} 条发布记录`);\n        store.batchVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleChange(e) {\n    setMode(e.target.value)\n    setValue()\n  }\n\n  return (\n    <Modal\n      visible\n      width={400}\n      maskClosable={false}\n      title=\"批量删除发布申请\"\n      onCancel={() => store.batchVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form layout=\"vertical\">\n        <Form.Item label=\"删除方式 :\">\n          <Radio.Group value={mode} placeholder=\"请选择\" style={{width: 280}} onChange={handleChange}>\n            <Radio.Button value=\"expire\">截止时间</Radio.Button>\n            <Radio.Button value=\"count\">保留记录</Radio.Button>\n            <Radio.Button value=\"deploy\">发布配置</Radio.Button>\n          </Radio.Group>\n        </Form.Item>\n        {mode === 'expire' && (\n          <Form.Item\n            label=\"截止日期 :\"\n            extra={<div>将删除截止日期<span style={{color: 'red'}}>之前</span>的所有发布申请记录。</div>}>\n            <DatePicker value={value} style={{width: 290}} onChange={setValue} placeholder=\"请选择截止日期\"/>\n          </Form.Item>\n        )}\n        {mode === 'count' && (\n          <Form.Item label=\"保留记录 :\" extra=\"每个应用每个环境仅保留最新的N条发布申请。\">\n            <Input value={value} style={{width: 290}} onChange={e => setValue(e.target.value)} placeholder=\"请输入保留个数\"/>\n          </Form.Item>\n        )}\n        {mode === 'deploy' && (\n          <Form.Item label=\"发布配置 :\" extra=\"删除指定应用环境下的发布申请记录。\">\n            <Space>\n              <Select\n                showSearch\n                style={{width: 160}}\n                value={appId}\n                onChange={setAppId}\n                filterOption={(i, o) => includes(o.children, i)}\n                placeholder=\"请选择应用\">\n                {Object.values(appStore.records).map(item => (\n                  <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n                ))}\n              </Select>\n              <Select\n                showSearch\n                style={{width: 122}}\n                value={envId}\n                onChange={setEnvId}\n                filterOption={(i, o) => includes(o.children, i)}\n                placeholder=\"请选择环境\">\n                {envStore.records.map(item => (\n                  <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n                ))}\n              </Select>\n            </Space>\n          </Form.Item>\n        )}\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Ext1Console.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { observer, useLocalStore } from 'mobx-react';\nimport { Card, Progress, Modal, Collapse, Steps, Skeleton } from 'antd';\nimport { ShrinkOutlined, LoadingOutlined, CloseOutlined, CodeOutlined } from '@ant-design/icons';\nimport OutView from './OutView';\nimport { http, X_TOKEN } from 'libs';\nimport styles from './index.module.less';\nimport store from './store';\n\nfunction Ext1Console(props) {\n  const outputs = useLocalStore(() => ({}));\n  const terms = useLocalStore(() => ({}));\n  const [mini, setMini] = useState(false);\n  const [visible, setVisible] = useState(true);\n  const [fetching, setFetching] = useState(true);\n\n  useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])\n\n  function readDeploy() {\n    let socket;\n    http.get(`/api/deploy/request/${props.request.id}/`)\n      .then(res => {\n        Object.assign(outputs, res.outputs)\n        setTimeout(() => setFetching(false), 100)\n        if (res.status === '2') {\n          socket = _makeSocket(res.index)\n        }\n      })\n    return () => socket && socket.close()\n  }\n\n  function doDeploy() {\n    let socket;\n    http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode})\n      .then(res => {\n        Object.assign(outputs, res.outputs)\n        setTimeout(() => setFetching(false), 100)\n        socket = _makeSocket()\n        store.fetchInfo(props.request.id)\n      })\n    return () => socket && socket.close()\n  }\n\n  function _makeSocket(index = 0) {\n    const token = props.request.id;\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => socket.send(String(index));\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send(String(index))\n      } else {\n        index += 1;\n        const {key, data, step, status} = JSON.parse(e.data);\n        if (!outputs[key]) return\n        if (data !== undefined) {\n          outputs[key].data += data\n          if (terms[key]) terms[key].write(data)\n        }\n        if (step !== undefined) outputs[key].step = step;\n        if (status !== undefined) outputs[key].status = status;\n      }\n    }\n    socket.onerror = () => {\n      for (let key of Object.keys(outputs)) {\n        outputs[key]['status'] = 'error'\n        outputs[key].data = '\\u001b[31mWebsocket connection failed!\\u001b[0m'\n        if (terms[key]) {\n          terms[key].reset()\n          terms[key].write('\\u001b[31mWebsocket connection failed!\\u001b[0m')\n        }\n      }\n    }\n    return socket\n  }\n\n  function StepItem(props) {\n    let icon = null;\n    if (props.step === props.item.step && props.item.status !== 'error') {\n      icon = <LoadingOutlined/>\n    }\n    return <Steps.Step {...props} icon={icon}/>\n  }\n\n  function switchMiniMode() {\n    setMini(true)\n    setVisible(false)\n  }\n\n  function handleSetTerm(term, key) {\n    if (outputs[key] && outputs[key].data) {\n      term.write(outputs[key].data)\n    }\n    terms[key] = term\n  }\n\n  function openTerminal(e, item) {\n    e.stopPropagation()\n    window.open(`/ssh?id=${item.id}`)\n  }\n\n  let {local, ...hosts} = outputs;\n  return (\n    <div>\n      {mini && (\n        <Card\n          className={styles.item}\n          bodyStyle={{padding: '8px 12px'}}\n          onClick={() => setVisible(true)}>\n          <div className={styles.header}>\n            <div className={styles.title}>{props.request.name}</div>\n            <CloseOutlined onClick={() => store.showConsole(props.request, true)}/>\n          </div>\n          {local && (\n            <Progress\n              percent={(local.step + 1) * 18}\n              status={local.step === 100 ? 'success' : outputs.local.status === 'error' ? 'exception' : 'active'}/>\n          )}\n          {Object.values(hosts).map(item => (\n            <Progress\n              key={item.id}\n              percent={(item.step + 1) * 18}\n              status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>\n          ))}\n        </Card>\n      )}\n      <Modal\n        visible={visible}\n        width=\"70%\"\n        footer={null}\n        maskClosable={false}\n        className={styles.console}\n        onCancel={() => store.showConsole(props.request, true)}\n        title={[\n          <span key=\"1\">{props.request.name}</span>,\n          <div key=\"2\" className={styles.miniIcon} onClick={switchMiniMode}>\n            <ShrinkOutlined/>\n          </div>\n        ]}>\n        <Skeleton loading={fetching} active>\n          {local && (\n            <Collapse defaultActiveKey={['0']} className={styles.collapse} style={{marginBottom: 24}}>\n              <Collapse.Panel header={(\n                <div className={styles.header}>\n                  <b className={styles.title}/>\n                  <Steps size=\"small\" className={styles.step} current={local.step} status={local.status} style={{margin: 0}}>\n                    <StepItem title=\"构建准备\" item={local} step={0}/>\n                    <StepItem title=\"检出前任务\" item={local} step={1}/>\n                    <StepItem title=\"执行检出\" item={local} step={2}/>\n                    <StepItem title=\"检出后任务\" item={local} step={3}/>\n                    <StepItem title=\"执行打包\" item={local} step={4}/>\n                  </Steps>\n                </div>\n              )}>\n                <OutView setTerm={term => handleSetTerm(term, 'local')}/>\n              </Collapse.Panel>\n            </Collapse>\n          )}\n\n          <Collapse defaultActiveKey=\"0\" className={styles.collapse}>\n            {Object.entries(hosts).map(([key, item], index) => (\n              <Collapse.Panel\n                key={index}\n                header={\n                  <div className={styles.header}>\n                    <b className={styles.title}>{item.title}</b>\n                    <Steps size=\"small\" className={styles.step} current={item.step} status={item.status}>\n                      <StepItem title=\"等待调度\" item={item} step={0}/>\n                      <StepItem title=\"数据准备\" item={item} step={1}/>\n                      <StepItem title=\"发布前任务\" item={item} step={2}/>\n                      <StepItem title=\"执行发布\" item={item} step={3}/>\n                      <StepItem title=\"发布后任务\" item={item} step={4}/>\n                    </Steps>\n                    <CodeOutlined className={styles.codeIcon} onClick={e => openTerminal(e, item)}/>\n                  </div>}>\n                <OutView setTerm={term => handleSetTerm(term, key)}/>\n              </Collapse.Panel>\n            ))}\n          </Collapse>\n        </Skeleton>\n      </Modal>\n    </div>\n  )\n\n}\n\nexport default observer(Ext1Console)"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Ext1Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Select, DatePicker, Button, message } from 'antd';\nimport { LoadingOutlined, SyncOutlined } from '@ant-design/icons';\nimport HostSelector from './HostSelector';\nimport { http, history, includes } from 'libs';\nimport store from './store';\nimport lds from 'lodash';\nimport moment from 'moment';\n\nfunction NoVersions() {\n  return (\n    <div>\n      <span>未找到符合条件的版本，</span>\n      <Button\n        type=\"link\"\n        style={{padding: 0}}\n        onClick={() => history.push('/deploy/repository')}>\n        去构建新版本？</Button>\n    </div>\n  )\n}\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [visible, setVisible] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [repositories, setRepositories] = useState([]);\n  const [host_ids, setHostIds] = useState([]);\n  const [plan, setPlan] = useState(store.record.plan);\n  const [fetching, setFetching] = useState(false);\n  const [git_type, setGitType] = useState();\n  const [extra, setExtra] = useState([]);\n  const [extra1, setExtra1] = useState();\n  const [extra2, setExtra2] = useState();\n  const [versions, setVersions] = useState({});\n\n  useEffect(() => {\n    const {app_host_ids, host_ids} = store.record;\n    setHostIds(lds.clone(host_ids || app_host_ids));\n    fetchVersions()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function fetchVersions() {\n    setFetching(true);\n    const deploy_id = store.record.deploy_id\n    const p1 = http.get(`/api/app/deploy/${deploy_id}/versions/`, {timeout: 300000})\n    const p2 = http.get('/api/repository/', {params: {deploy_id}})\n    Promise.all([p1, p2])\n      .then(([res1, res2]) => {\n        if (!versions.branches) _initial(res1, res2)\n        setVersions(res1)\n        setRepositories(res2)\n      })\n      .finally(() => setFetching(false))\n  }\n\n  function handleSubmit() {\n    if (host_ids.length === 0) {\n      return message.error('请至少选择一个要发布的主机')\n    }\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    formData['deploy_id'] = store.record.deploy_id;\n    formData['host_ids'] = host_ids;\n    formData['type'] = store.record.type;\n    formData['extra'] = [git_type, extra1, extra2];\n    if (plan) formData.plan = plan.format('YYYY-MM-DD HH:mm:00');\n    http.post('/api/deploy/request/ext1/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.ext1Visible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function _setDefault(type, new_extra, new_versions, new_repositories) {\n    const now_extra = new_extra || extra;\n    const now_versions = new_versions || versions;\n    const now_repositories = new_repositories || repositories;\n    const {branches, tags} = now_versions;\n    if (type === 'branch') {\n      let [branch, commit] = [now_extra[1], null];\n      if (branches[branch]) {\n        commit = lds.get(branches[branch], '0.id')\n      } else {\n        branch = lds.get(Object.keys(branches), 0)\n        commit = lds.get(branches, [branch, 0, 'id'])\n      }\n      setExtra1(branch)\n      setExtra2(commit)\n    } else if (type === 'tag') {\n      setExtra1(lds.get(Object.keys(tags), 0))\n      setExtra2(null)\n    } else {\n      setExtra1(lds.get(now_repositories, '0.id'))\n      setExtra2(null)\n    }\n  }\n\n  function _initial(versions, repositories) {\n    const {branches, tags} = versions;\n    if (branches && tags) {\n      for (let item of store.records) {\n        if (item.extra && item.deploy_id === store.record.deploy_id) {\n          const type = item.extra[0];\n          setExtra(item.extra);\n          setGitType(type);\n          return _setDefault(type, item.extra, versions, repositories);\n        }\n      }\n      setGitType('branch');\n      const branch = lds.get(Object.keys(branches), 0);\n      const commit = lds.get(branches, [branch, 0, 'id'])\n      setExtra1(branch);\n      setExtra2(commit)\n    }\n  }\n\n  function switchType(v) {\n    setGitType(v);\n    _setDefault(v)\n  }\n\n  function switchExtra1(v) {\n    setExtra1(v)\n    if (git_type === 'branch') {\n      setExtra2(lds.get(versions.branches[v], '0.id'))\n    }\n  }\n\n  const {app_host_ids, type, rb_id} = store.record;\n  const {branches, tags} = versions;\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={`${store.record.id ? '编辑' : '新建'}发布申请`}\n      onCancel={() => store.ext1Visible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 5}} wrapperCol={{span: 17}}>\n        <Form.Item required name=\"name\" label=\"申请标题\">\n          <Input placeholder=\"请输入申请标题\"/>\n        </Form.Item>\n        <Form.Item required label=\"选择分支/标签/版本\" style={{marginBottom: 12}} extra={<span>\n            根据网络情况，首次刷新可能会很慢，请耐心等待。\n            <a target=\"_blank\" rel=\"noopener noreferrer\"\n               href=\"https://ops.spug.cc/docs/use-problem#clone\">clone 失败？</a>\n          </span>}>\n          <Form.Item style={{display: 'inline-block', marginBottom: 0, width: '450px'}}>\n            <Input.Group compact>\n              <Select value={git_type} onChange={switchType} style={{width: 100}}>\n                <Select.Option value=\"branch\">Branch</Select.Option>\n                <Select.Option value=\"tag\">Tag</Select.Option>\n                <Select.Option value=\"repository\">构建仓库</Select.Option>\n              </Select>\n              <Select\n                showSearch\n                style={{width: 350}}\n                value={extra1}\n                placeholder=\"请稍等\"\n                onChange={switchExtra1}\n                notFoundContent={git_type === 'repository' ? <NoVersions/> : undefined}\n                filterOption={(input, option) => includes(option.content, input)}>\n                {git_type === 'branch' ? (\n                  Object.keys(branches || {}).map(b => (\n                    <Select.Option key={b} value={b} content={b}>{b}</Select.Option>\n                  ))\n                ) : git_type === 'tag' ? (\n                  Object.entries(tags || {}).map(([tag, info]) => (\n                    <Select.Option key={tag} value={tag} content={`${tag} ${info.author} ${info.message}`}>\n                      <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                        <span style={{\n                          width: 200,\n                          overflow: 'hidden',\n                          textOverflow: 'ellipsis'\n                        }}>{`${tag} ${info.author} ${info.message}`}</span>\n                        <span style={{color: '#999', fontSize: 12}}>{info['date']} </span>\n                      </div>\n                    </Select.Option>\n                  ))\n                ) : (\n                  repositories.map(item => (\n                    <Select.Option key={item.id} value={item.id} content={item.version}\n                                   disabled={type === '2' && item.id >= rb_id}>\n                      <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                        <span>{item.version}</span>\n                        <span style={{color: '#999', fontSize: 12}}>构建于 {moment(item.created_at).fromNow()}</span>\n                      </div>\n                    </Select.Option>\n                  ))\n                )}\n              </Select>\n            </Input.Group>\n          </Form.Item>\n          <Form.Item style={{display: 'inline-block', width: 82, textAlign: 'center', marginBottom: 0}}>\n            {fetching ? <LoadingOutlined style={{fontSize: 18, color: '#1890ff'}}/> :\n              <Button type=\"link\" icon={<SyncOutlined/>} disabled={fetching} onClick={fetchVersions}>刷新</Button>\n            }\n          </Form.Item>\n        </Form.Item>\n        {git_type === 'branch' && (\n          <Form.Item required label=\"选择Commit ID\">\n            <Select value={extra2} placeholder=\"请选择\" onChange={v => setExtra2(v)}>\n              {extra1 && branches ? branches[extra1].map(item => (\n                <Select.Option key={item.id}>\n                  <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                    <span style={{\n                      width: 400,\n                      overflow: 'hidden',\n                      textOverflow: 'ellipsis'\n                    }}>{item.id.substr(0, 6)} {item['author']} {item['message']}</span>\n                    <span style={{color: '#999', fontSize: 12}}>{item['date']} </span>\n                  </div>\n                </Select.Option>\n              )) : null}\n            </Select>\n          </Form.Item>\n        )}\n        <Form.Item required label=\"目标主机\" tooltip=\"可以通过创建多个发布申请单，选择主机分批发布。\">\n          {host_ids.length > 0 && (\n            <span style={{marginRight: 16}}>已选择 {host_ids.length} 台（可选{app_host_ids.length}）</span>\n          )}\n          <Button type=\"link\" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n        {type !== '2' && (\n          <Form.Item label=\"定时发布\" tooltip=\"在到达指定时间后自动发布，会有最多1分钟的延迟。\">\n            <DatePicker\n              showTime\n              value={plan}\n              style={{width: 180}}\n              format=\"YYYY-MM-DD HH:mm\"\n              placeholder=\"请设置发布时间\"\n              onChange={setPlan}/>\n            {plan ? <span style={{marginLeft: 24, fontSize: 12, color: '#888'}}>大约 {plan.fromNow()}</span> : null}\n          </Form.Item>\n        )}\n      </Form>\n      {visible && <HostSelector\n        host_ids={host_ids}\n        app_host_ids={app_host_ids}\n        onCancel={() => setVisible(false)}\n        onOk={ids => setHostIds(ids)}/>}\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Ext2Console.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { observer, useLocalStore } from 'mobx-react';\nimport { Card, Progress, Modal, Collapse, Steps, Skeleton } from 'antd';\nimport { ShrinkOutlined, LoadingOutlined, CloseOutlined, CodeOutlined } from '@ant-design/icons';\nimport OutView from './OutView';\nimport { http, X_TOKEN } from 'libs';\nimport styles from './index.module.less';\nimport store from './store';\n\nfunction Ext2Console(props) {\n  const terms = useLocalStore(() => ({}));\n  const outputs = useLocalStore(() => ({local: {id: 'local'}}));\n  const [sActions, setSActions] = useState([]);\n  const [hActions, setHActions] = useState([]);\n  const [mini, setMini] = useState(false);\n  const [visible, setVisible] = useState(true);\n  const [fetching, setFetching] = useState(true);\n\n  useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])\n\n  function readDeploy() {\n    let socket;\n    http.get(`/api/deploy/request/${props.request.id}/`)\n      .then(res => {\n        setSActions(res.s_actions);\n        setHActions(res.h_actions);\n        Object.assign(outputs, res.outputs);\n        setTimeout(() => setFetching(false), 100)\n        if (res.status === '2') {\n          socket = _makeSocket(res.index)\n        }\n      })\n    return () => socket && socket.close()\n  }\n\n  function doDeploy() {\n    let socket;\n    http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode})\n      .then(res => {\n        setSActions(res.s_actions);\n        setHActions(res.h_actions);\n        Object.assign(outputs, res.outputs)\n        setTimeout(() => setFetching(false), 100)\n        socket = _makeSocket()\n        store.fetchInfo(props.request.id)\n      })\n    return () => socket && socket.close()\n  }\n\n  function _makeSocket(index = 0) {\n    const token = props.request.id;\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => socket.send(String(index));\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send(String(index))\n      } else {\n        index += 1;\n        const {key, data, step, status} = JSON.parse(e.data);\n        if (!outputs[key]) return\n        if (data !== undefined) {\n          outputs[key].data += data\n          if (terms[key]) terms[key].write(data)\n        }\n        if (step !== undefined) outputs[key].step = step;\n        if (status !== undefined) outputs[key].status = status;\n      }\n    }\n    socket.onerror = () => {\n      for (let key of Object.keys(outputs)) {\n        outputs[key]['status'] = 'error'\n        outputs[key].data = '\\u001b[31mWebsocket connection failed!\\u001b[0m'\n        if (terms[key]) {\n          terms[key].reset()\n          terms[key].write('\\u001b[31mWebsocket connection failed!\\u001b[0m')\n        }\n      }\n    }\n    return socket\n  }\n\n  function StepItem(props) {\n    let icon = null;\n    if (props.step === props.item.step && props.item.status !== 'error') {\n      if (props.item.id === 'local' || outputs.local.step === 100) {\n        icon = <LoadingOutlined/>\n      }\n    }\n    return <Steps.Step {...props} icon={icon}/>\n  }\n\n  function switchMiniMode() {\n    setMini(true)\n    setVisible(false)\n  }\n\n  function handleSetTerm(term, key) {\n    if (outputs[key] && outputs[key].data) {\n      term.write(outputs[key].data)\n    }\n    terms[key] = term\n  }\n\n  function openTerminal(e, item) {\n    e.stopPropagation()\n    window.open(`/ssh?id=${item.id}`)\n  }\n\n  const hostOutputs = Object.values(outputs).filter(x => x.id !== 'local');\n  return (\n    <div>\n      {mini && (\n        <Card\n          className={styles.item}\n          bodyStyle={{padding: '8px 12px'}}\n          onClick={() => setVisible(true)}>\n          <div className={styles.header}>\n            <div className={styles.title}>{props.request.name}</div>\n            <CloseOutlined onClick={() => store.showConsole(props.request, true)}/>\n          </div>\n          <Progress percent={(outputs.local.step + 1) * (90 / (1 + sActions.length)).toFixed(0)}\n                    status={outputs.local.step === 100 ? 'success' : outputs.local.status === 'error' ? 'exception' : 'active'}/>\n          {Object.values(outputs).filter(x => x.id !== 'local').map(item => (\n            <Progress\n              key={item.id}\n              percent={item.step * (90 / (hActions.length).toFixed(0))}\n              status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>\n          ))}\n        </Card>\n      )}\n      <Modal\n        visible={visible}\n        width={1000}\n        footer={null}\n        maskClosable={false}\n        className={styles.console}\n        onCancel={() => store.showConsole(props.request, true)}\n        title={[\n          <span key=\"1\">{props.request.name}</span>,\n          <div key=\"2\" className={styles.miniIcon} onClick={switchMiniMode}>\n            <ShrinkOutlined/>\n          </div>\n        ]}>\n        <Skeleton loading={fetching} active>\n          {sActions.length > 0 && (\n            <Collapse defaultActiveKey={['0']} className={styles.collapse}>\n              <Collapse.Panel header={(\n                <div className={styles.header}>\n                  <b className={styles.title}/>\n                  <Steps size=\"small\" className={styles.step} current={outputs.local.step}\n                         status={outputs.local.status}>\n                    <StepItem title=\"建立连接\" item={outputs.local} step={0}/>\n                    {sActions.map((item, index) => (\n                      <StepItem key={index} title={item.title} item={outputs.local} step={index + 1}/>\n                    ))}\n                  </Steps>\n                </div>\n              )}>\n                <OutView setTerm={term => handleSetTerm(term, 'local')}/>\n              </Collapse.Panel>\n            </Collapse>\n          )}\n\n          {hostOutputs.length > 0 && (\n            <Collapse\n              accordion\n              defaultActiveKey=\"0\"\n              className={styles.collapse}\n              style={{marginTop: sActions.length > 0 ? 24 : 0}}>\n              {hostOutputs.map((item, index) => (\n                <Collapse.Panel\n                  key={index}\n                  header={\n                    <div className={styles.header}>\n                      <b className={styles.title}>{item.title}</b>\n                      <Steps size=\"small\" className={styles.step} current={item.step} status={item.status}>\n                        <StepItem title=\"等待调度\" item={item} step={0}/>\n                        {hActions.map((action, index) => (\n                          <StepItem key={index} title={action.title} item={item} step={index + 1}/>\n                        ))}\n                      </Steps>\n                      <CodeOutlined className={styles.codeIcon} onClick={e => openTerminal(e, item)}/>\n                    </div>}>\n                  <OutView setTerm={term => handleSetTerm(term, item.id)}/>\n                </Collapse.Panel>\n              ))}\n            </Collapse>\n          )}\n        </Skeleton>\n      </Modal>\n    </div>\n  )\n}\n\nexport default observer(Ext2Console)"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Ext2Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { UploadOutlined } from '@ant-design/icons';\nimport { Modal, Form, Input, Upload, DatePicker, message, Button } from 'antd';\nimport HostSelector from './HostSelector';\nimport { http, clsNames, X_TOKEN } from 'libs';\nimport styles from './index.module.less';\nimport store from './store';\nimport lds from 'lodash';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [visible, setVisible] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [uploading, setUploading] = useState(false);\n  const [fileList, setFileList] = useState([]);\n  const [host_ids, setHostIds] = useState([]);\n  const [plan, setPlan] = useState(store.record.plan);\n\n  useEffect(() => {\n    const {app_host_ids, host_ids, extra} = store.record;\n    setHostIds(lds.clone(host_ids || app_host_ids));\n    if (store.record.extra) setFileList([{...extra, uid: '0'}])\n  }, [])\n\n  function handleSubmit() {\n    if (host_ids.length === 0) {\n      return message.error('请至少选择一个要发布的目标主机')\n    }\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    formData['host_ids'] = host_ids;\n    formData['type'] = store.record.type;\n    formData['deploy_id'] = store.record.deploy_id;\n    if (plan) formData.plan = plan.format('YYYY-MM-DD HH:mm:00');\n    if (fileList.length > 0) formData['extra'] = lds.pick(fileList[0], ['path', 'name']);\n    http.post('/api/deploy/request/ext2/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.ext2Visible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleUploadChange(v) {\n    if (v.fileList.length === 0) {\n      setFileList([])\n    }\n  }\n\n  function handleUpload(file, fileList) {\n    setUploading(true);\n    const formData = new FormData();\n    formData.append('file', file);\n    formData.append('deploy_id', store.record.deploy_id);\n    http.post('/api/deploy/request/upload/', formData, {timeout: 300000})\n      .then(res => {\n        file.path = res;\n        setFileList([file])\n      })\n      .finally(() => setUploading(false))\n    return false\n  }\n\n  const {app_host_ids, deploy_id, type, require_upload} = store.record;\n  return (\n    <Modal\n      visible\n      width={700}\n      maskClosable={false}\n      title={`${store.record.id ? '编辑' : '新建'}发布申请`}\n      onCancel={() => store.ext2Visible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 16}}>\n        <Form.Item required name=\"name\" label=\"申请标题\">\n          <Input placeholder=\"请输入申请标题\"/>\n        </Form.Item>\n        <Form.Item\n          name=\"version\"\n          label=\"SPUG_RELEASE\"\n          tooltip=\"可以在自定义脚本中引用该变量，用于设置本次发布相关的动态变量，在脚本中通过 $SPUG_RELEASE 来使用该值。\">\n          <Input placeholder=\"请输入环境变量 SPUG_RELEASE 的值\"/>\n        </Form.Item>\n        {require_upload && (\n          <Form.Item required label=\"上传数据\" tooltip=\"通过数据传输动作来使用上传的文件。\"\n                     className={clsNames(styles.upload, fileList.length ? styles.uploadHide : null)}>\n            <Upload.Dragger name=\"file\" fileList={fileList} headers={{'X-Token': X_TOKEN}} beforeUpload={handleUpload}\n                            data={{deploy_id}} onChange={handleUploadChange}>\n              <Button type=\"link\" loading={uploading} icon={<UploadOutlined/>}>点击或拖动文件至此区域上传</Button>\n            </Upload.Dragger>\n          </Form.Item>\n        )}\n        <Form.Item required label=\"目标主机\" tooltip=\"可以通过创建多个发布申请单，选择主机分批发布。\">\n          {host_ids.length > 0 && (\n            <span style={{marginRight: 16}}>已选择 {host_ids.length} 台（可选{app_host_ids.length}）</span>\n          )}\n          <Button type=\"link\" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n        {type !== '2' && (\n          <Form.Item label=\"定时发布\" tooltip=\"在到达指定时间后自动发布，会有最多1分钟的延迟。\">\n            <DatePicker\n              showTime\n              value={plan}\n              style={{width: 180}}\n              format=\"YYYY-MM-DD HH:mm\"\n              placeholder=\"请设置发布时间\"\n              onChange={setPlan}/>\n            {plan ? <span style={{marginLeft: 24, fontSize: 12, color: '#888'}}>大约 {plan.fromNow()}</span> : null}\n          </Form.Item>\n        )}\n      </Form>\n      {visible && <HostSelector\n        host_ids={host_ids}\n        app_host_ids={app_host_ids}\n        onCancel={() => setVisible(false)}\n        onOk={ids => setHostIds(ids)}/>}\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/HostSelector.js",
    "content": "import React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport {Modal, Table, Button, Alert, Spin, Space} from 'antd';\nimport hostStore from 'pages/host/store';\nimport lds from 'lodash';\n\nexport default observer(function (props) {\n  const [selectedRowKeys, setSelectedRowKeys] = useState(props.host_ids || []);\n  const [isLoading, setIsLoading] = useState(true);\n\n  useEffect(() => {\n    // 增加异步逻辑，以修复页面在初次载入时主机列表弹框看不到主机信息的问题\n    hostStore.initial().then(() => {\n      // 异步执行完后，去除 loading 状态\n      setIsLoading(false)\n    })\n  }, [])\n\n  function handleClickRow(record) {\n    const index = selectedRowKeys.indexOf(record.id);\n    if (index !== -1) {\n      selectedRowKeys.splice(index, 1)\n    } else {\n      selectedRowKeys.push(record.id)\n    }\n    setSelectedRowKeys([...selectedRowKeys])\n  }\n\n  function handleSubmit() {\n    if (props.onOk) {\n      const res = props.onOk(selectedRowKeys);\n      if (res && res.then) {\n        res.then(props.onCancel)\n      } else {\n        props.onCancel();\n      }\n    }\n  }\n\n  // 若主机列表数据未加载完成，则返回 loading 状态\n  if (isLoading) {\n    return (\n        <Modal\n            visible\n            width={600}\n            title='可选主机列表'\n            onOk={handleSubmit}\n            onCancel={props.onCancel}>\n            <Space style={{\n              display: 'flex',\n              justifyContent: 'center',\n              alignItems: 'center'\n            }}>\n              <Spin spinning={isLoading} tip=\"加载中......\" size=\"large\"></Spin>\n            </Space>\n        </Modal>\n    )\n  }\n\n  return (\n    <Modal\n      visible\n      width={600}\n      title='可选主机列表'\n      onOk={handleSubmit}\n      onCancel={props.onCancel}>\n      {selectedRowKeys.length > 0 && (\n        <Alert\n          style={{marginBottom: 12}}\n          message={`已选择 ${selectedRowKeys.length} 台主机`}\n          action={<Button type=\"link\" onClick={() => setSelectedRowKeys([])}>取消选择</Button>}/>\n      )}\n      <Table\n        rowKey=\"id\"\n        dataSource={props.app_host_ids.map(id => ({id}))}\n        pagination={false}\n        scroll={{y: 480}}\n        onRow={record => {\n          return {\n            onClick: () => handleClickRow(record)\n          }\n        }}\n        rowSelection={{\n          selectedRowKeys,\n          onSelect: handleClickRow,\n          onSelectAll: (_, __, changeRows) => changeRows.map(x => handleClickRow(x))\n        }}>\n        <Table.Column\n          title=\"主机名称\"\n          dataIndex=\"id\"\n          render={id => lds.get(hostStore.idMap, `${id}.name`)}/>\n        <Table.Column\n          title=\"连接地址\"\n          dataIndex=\"id\"\n          render={id => lds.get(hostStore.idMap, `${id}.hostname`)}/>\n      </Table>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/OutView.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useRef } from 'react';\nimport { FitAddon } from 'xterm-addon-fit';\nimport { Terminal } from 'xterm';\n\nfunction OutView(props) {\n  const el = useRef()\n\n  useEffect(() => {\n    setTimeout(() => {\n      const fitPlugin = new FitAddon()\n      const term = new Terminal({disableStdin: true})\n      term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')\n      term.loadAddon(fitPlugin)\n      term.setOption('theme', {background: '#fff', foreground: '#000', selection: '#999'})\n      term.attachCustomKeyEventHandler((arg) => {\n        if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {\n          document.execCommand('copy')\n          return false\n        }\n        return true\n      })\n      term.open(el.current)\n      fitPlugin.fit()\n      props.setTerm(term)\n    }, 100)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  return (\n    <div style={{padding: '8px 0 0 15px'}}>\n      <div ref={el} style={{height: 300}}/>\n    </div>\n  )\n}\n\nexport default OutView"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Rollback.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Select, Button, message } from 'antd';\nimport HostSelector from './HostSelector';\nimport { http, includes } from 'libs';\nimport store from './store';\nimport lds from 'lodash';\nimport moment from 'moment';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [visible, setVisible] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [host_ids, setHostIds] = useState([]);\n\n  useEffect(() => {\n    const {app_host_ids, host_ids} = store.record;\n    setHostIds(lds.clone(host_ids || app_host_ids));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleSubmit() {\n    if (host_ids.length === 0) {\n      return message.error('请至少选择一个要发布的主机')\n    }\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['host_ids'] = host_ids;\n    http.post('/api/deploy/request/ext1/rollback/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.rollbackVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  const {app_host_ids, deploy_id} = store.record;\n  return (\n    <Modal\n      visible\n      width={600}\n      maskClosable={false}\n      title=\"新建回滚发布申请\"\n      onCancel={() => store.rollbackVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 5}} wrapperCol={{span: 17}}>\n        <Form.Item required name=\"name\" label=\"申请标题\">\n          <Input placeholder=\"请输入申请标题\"/>\n        </Form.Item>\n        <Form.Item required name=\"request_id\" label=\"选择版本\" tooltip=\"可选择回滚版本与发布配置中的版本数量配置相关。\">\n          <Select\n            showSearch\n            placeholder=\"请选择回滚至哪个版本\"\n            filterOption={(input, option) => includes(option.props.children, input)}>\n            {store.records.filter(x => x.repository_id && x.deploy_id === deploy_id && ['3', '-3'].includes(x.status)).map((item, index) => (\n              <Select.Option key={item.id} value={item.id} record={item} disabled={index === 0}>\n                <div style={{display: 'flex', justifyContent: 'space-between'}}>\n                  <span>{`${item.name} (${item.version})`}</span>\n                  <span style={{color: '#999', fontSize: 12}}>创建于 {moment(item.created_at).fromNow()}</span>\n                </div>\n              </Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item required label=\"目标主机\" tooltip=\"可以通过创建多个发布申请单，选择主机分批发布。\">\n          {host_ids.length > 0 && (\n            <span style={{marginRight: 16}}>已选择 {host_ids.length} 台（可选{app_host_ids.length}）</span>\n          )}\n          <Button type=\"link\" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input placeholder=\"请输入备注信息\"/>\n        </Form.Item>\n      </Form>\n      {visible && <HostSelector\n        host_ids={host_ids}\n        app_host_ids={app_host_ids}\n        onCancel={() => setVisible(false)}\n        onOk={ids => setHostIds(ids)}/>}\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/deploy/request/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { BranchesOutlined, BuildOutlined, TagOutlined, PlusOutlined, TagsOutlined } from '@ant-design/icons';\nimport { Radio, Modal, Popover, Tag, Popconfirm, Tooltip, message } from 'antd';\nimport { http, hasPermission } from 'libs';\nimport { Action, AuthButton, TableCard } from 'components';\nimport S from './index.module.less';\nimport store from './store';\nimport moment from 'moment';\n\nfunction DeployConfirm() {\n  return (\n    <div>\n      <div>确认发布方式</div>\n      <div style={{color: '#999', fontSize: 12}}>补偿：仅发布上次发布失败的主机。</div>\n      <div style={{color: '#999', fontSize: 12}}>全量：再次发布所有主机。</div>\n    </div>\n  )\n}\n\nfunction ComTable() {\n  const columns = [{\n    title: '申请标题',\n    className: S.min180,\n    render: info => (\n      <div>\n        {info.type === '2' && <Tooltip title=\"回滚发布\"><Tag color=\"#f50\">R</Tag></Tooltip>}\n        {info.type === '3' && <Tooltip title=\"Webhook触发\"><Tag color=\"#87d068\">A</Tag></Tooltip>}\n        {info.plan && <Tooltip title={`定时发布（${info.plan}）`}> <Tag color=\"#108ee9\">P</Tag></Tooltip>}\n        {info.name}\n      </div>\n    )\n  }, {\n    title: '应用',\n    className: S.min120,\n    dataIndex: 'app_name',\n  }, {\n    title: '发布环境',\n    className: S.min120,\n    dataIndex: 'env_name',\n  }, {\n    title: '版本',\n    className: S.min155,\n    render: info => {\n      if (info['app_extend'] === '1') {\n        const [ext1] = info.extra || info.rep_extra;\n        switch (ext1) {\n          case 'branch':\n            return <div><BranchesOutlined/> {info.version}</div>\n          case 'tag':\n            return <div><TagOutlined/> {info.version}</div>\n          default:\n            return <div><TagsOutlined/> {info.version}</div>\n        }\n      } else {\n        return (\n          <div><BuildOutlined/> {info.version}</div>\n        )\n      }\n    }\n  }, {\n    title: '申请人',\n    className: S.min120,\n    dataIndex: 'created_by_user',\n    hide: true\n  }, {\n    title: '申请时间',\n    className: S.min120,\n    dataIndex: 'created_at',\n    sorter: (a, b) => a['created_at'].localeCompare(b['created_at']),\n    render: v => <Tooltip title={v}>{v ? moment(v).fromNow() : null}</Tooltip>,\n    hide: true\n  }, {\n    title: '审核人',\n    className: S.min120,\n    dataIndex: 'approve_by_user',\n    hide: true\n  }, {\n    title: '审核时间',\n    className: S.min120,\n    dataIndex: 'approve_at',\n    hide: true\n  }, {\n    title: '发布人',\n    className: S.min120,\n    dataIndex: 'do_by_user',\n    hide: true\n  }, {\n    title: '发布时间',\n    className: S.min120,\n    dataIndex: 'do_at',\n  }, {\n    title: '备注',\n    className: S.min120,\n    dataIndex: 'desc',\n  }, {\n    title: '状态',\n    fixed: 'right',\n    className: S.min120,\n    render: info => {\n      if (info.status === '-1' && info.reason) {\n        return <Popover title=\"驳回原因:\" content={info.reason}>\n          <Tag color=\"#f50\">{info['status_alias']}</Tag>\n        </Popover>\n      } else if (info.status === '1' && info.reason) {\n        return <Popover title=\"审核意见:\" content={info.reason}>\n          <Tag color=\"#87d068\">{info['status_alias']}</Tag>\n        </Popover>\n      } else if (info.status === '2') {\n        return <Tag color=\"orange\">{info['status_alias']}</Tag>\n      } else if (info.status === '3') {\n        return <Tag color=\"green\">{info['status_alias']}</Tag>\n      } else if (info.status === '-3') {\n        return <Tag color=\"red\">{info['status_alias']}</Tag>\n      } else {\n        return <Tag color=\"blue\">{info['status_alias']}</Tag>\n      }\n    }\n  }, {\n    title: '操作',\n    fixed: 'right',\n    className: hasPermission('deploy.request.do|deploy.request.edit|deploy.request.approve|deploy.request.del') ? S.min180 : 'none',\n    render: info => {\n      switch (info.status) {\n        case '-3':\n          return <Action>\n            <Action.Button auth=\"deploy.request.do\" onClick={() => store.readConsole(info)}>查看</Action.Button>\n            <DoAction info={info}/>\n            {info.visible_rollback && (\n              <Action.Button auth=\"deploy.request.do\" onClick={() => store.rollback(info)}>回滚</Action.Button>\n            )}\n          </Action>;\n        case '3':\n          return <Action>\n            <Action.Button auth=\"deploy.request.do\" onClick={() => store.readConsole(info)}>查看</Action.Button>\n            {info.visible_rollback && (\n              <Action.Button auth=\"deploy.request.do\" onClick={() => store.rollback(info)}>回滚</Action.Button>\n            )}\n          </Action>;\n        case '-1':\n          return <Action>\n            <Action.Button auth=\"deploy.request.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n            <Action.Button auth=\"deploy.request.del\" onClick={() => handleDelete(info)}>删除</Action.Button>\n          </Action>;\n        case '0':\n          return <Action>\n            <Action.Button auth=\"deploy.request.approve\" onClick={() => store.showApprove(info)}>审核</Action.Button>\n            <Action.Button auth=\"deploy.request.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n            <Action.Button auth=\"deploy.request.del\" onClick={() => handleDelete(info)}>删除</Action.Button>\n          </Action>;\n        case '1':\n          return <Action>\n            <DoAction info={info}/>\n            <Action.Button auth=\"deploy.request.del\" onClick={() => handleDelete(info)}>删除</Action.Button>\n          </Action>;\n        case '2':\n          return <Action>\n            <Action.Button auth=\"deploy.request.do\" onClick={() => store.readConsole(info)}>查看</Action.Button>\n          </Action>;\n        default:\n          return null\n      }\n    }\n  }];\n\n  function DoAction(props) {\n    const {host_ids, fail_host_ids} = props.info;\n    return (\n      <Popconfirm\n        title={<DeployConfirm/>}\n        okText=\"全量\"\n        cancelText=\"补偿\"\n        cancelButtonProps={{disabled: [0, host_ids.length].includes(fail_host_ids.length)}}\n        onConfirm={e => handleDeploy(e, props.info, 'all')}\n        onCancel={e => handleDeploy(e, props.info, 'fail')}>\n        <Action.Button auth=\"deploy.request.do\">发布</Action.Button>\n      </Popconfirm>\n    )\n  }\n\n  function handleDelete(info) {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${info['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/deploy/request/', {params: {id: info.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  }\n\n  function handleDeploy(e, info, mode) {\n    info.mode = mode\n    store.showConsole(info);\n  }\n\n  return (\n    <TableCard\n      tKey=\"dr\"\n      rowKey={row => row.key || row.id}\n      title=\"申请列表\"\n      columns={columns}\n      scroll={{x: 1500}}\n      tableLayout=\"auto\"\n      loading={store.isFetching}\n      dataSource={store.dataSource}\n      onReload={store.fetchRecords}\n      actions={[\n        <AuthButton\n          auth=\"deploy.request.add\"\n          type=\"primary\"\n          icon={<PlusOutlined/>}\n          onClick={() => store.addVisible = true}>新建申请</AuthButton>,\n        <Radio.Group value={store.f_status} onChange={e => store.f_status = e.target.value}>\n          <Radio.Button value=\"all\">全部({store.counter['all'] || 0})</Radio.Button>\n          <Radio.Button value=\"0\">待审核({store.counter['0'] || 0})</Radio.Button>\n          <Radio.Button value=\"1\">待发布({store.counter['1'] || 0})</Radio.Button>\n          <Radio.Button value=\"3\">发布成功({store.counter['3'] || 0})</Radio.Button>\n          <Radio.Button value=\"-3\">发布异常({store.counter['-3'] || 0})</Radio.Button>\n          <Radio.Button value=\"99\">其他({store.counter['99'] || 0})</Radio.Button>\n        </Radio.Group>\n      ]}\n      pagination={{\n        showSizeChanger: true,\n        showLessItems: true,\n        showTotal: total => `共 ${total} 条`,\n        pageSizeOptions: ['10', '20', '50', '100']\n      }}/>\n  )\n}\n\nexport default observer(ComTable)\n"
  },
  {
    "path": "spug_web/src/pages/deploy/request/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { DeleteOutlined } from '@ant-design/icons';\nimport { Select, DatePicker, Space } from 'antd';\nimport { SearchForm, AuthDiv, AuthButton, Breadcrumb, AppSelector } from 'components';\nimport Ext1Form from './Ext1Form';\nimport Ext2Form from './Ext2Form';\nimport Approve from './Approve';\nimport ComTable from './Table';\nimport Ext1Console from './Ext1Console';\nimport Ext2Console from './Ext2Console';\nimport BatchDelete from './BatchDelete';\nimport Rollback from './Rollback';\nimport { includes } from 'libs';\nimport envStore from 'pages/config/environment/store';\nimport appStore from 'pages/config/app/store';\nimport store from './store';\nimport moment from 'moment';\nimport styles from './index.module.less';\n\nfunction Index() {\n  useEffect(() => {\n    store.fetchRecords()\n    if (envStore.records.length === 0) envStore.fetchRecords()\n    if (appStore.records.length === 0) appStore.fetchRecords()\n    return () => store.leaveConsole()\n  }, [])\n\n  return (\n    <AuthDiv auth=\"deploy.request.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>应用发布</Breadcrumb.Item>\n        <Breadcrumb.Item>发布申请</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={6} title=\"发布环境\">\n          <Select\n            allowClear\n            showSearch\n            value={store.f_env_id}\n            filterOption={(i, o) => includes(o.children, i)}\n            onChange={v => store.f_env_id = v}\n            placeholder=\"请选择\">\n            {envStore.records.map(item => (\n              <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={6} title=\"应用名称\">\n          <Select\n            allowClear\n            showSearch\n            value={store.f_app_id}\n            filterOption={(i, o) => includes(o.children, i)}\n            onChange={v => store.f_app_id = v}\n            placeholder=\"请选择\">\n            {appStore.records.map(item => (\n              <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={8} title=\"申请时间\">\n          <DatePicker.RangePicker\n            value={store.f_s_date ? [moment(store.f_s_date), moment(store.f_e_date)] : undefined}\n            onChange={store.updateDate}/>\n        </SearchForm.Item>\n        <SearchForm.Item span={4} style={{textAlign: 'right'}}>\n          <AuthButton\n            auth=\"deploy.request.del\"\n            type=\"danger\"\n            icon={<DeleteOutlined/>}\n            onClick={() => store.batchVisible = true}>批量删除</AuthButton>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      <AppSelector\n        visible={store.addVisible}\n        onCancel={() => store.addVisible = false}\n        onSelect={store.confirmAdd}/>\n      {store.ext1Visible && <Ext1Form/>}\n      {store.ext2Visible && <Ext2Form/>}\n      {store.batchVisible && <BatchDelete/>}\n      {store.approveVisible && <Approve/>}\n      {store.rollbackVisible && <Rollback/>}\n      {store.tabs.length > 0 && (\n        <Space className={styles.miniConsole}>\n          {store.tabs.map(item => item.id ?\n            item.app_extend === '1' ? (\n              <Ext1Console key={item.id} request={item}/>\n            ) : (\n              <Ext2Console key={item.id} request={item}/>\n            ) : null)}\n        </Space>\n      )}\n    </AuthDiv>\n  )\n}\n\nexport default observer(Index)"
  },
  {
    "path": "spug_web/src/pages/deploy/request/index.module.less",
    "content": ".approve {\n  :global(.ant-switch) {\n    background: #faad14;\n  }\n\n  :global(.ant-switch-checked) {\n    background: #389e0d;\n  }\n}\n\n.miniConsole {\n  position: fixed;\n  bottom: 12px;\n  right: 24px;\n  align-items: flex-end;\n  z-index: 999;\n\n  .item {\n    width: 180px;\n    box-shadow: 0 0 4px rgba(0, 0, 0, .3);\n    border-radius: 5px;\n\n    :global(.ant-progress-text) {\n      text-align: center;\n    }\n\n    .header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      font-size: 13px;\n      margin-bottom: 4px;\n\n      .title {\n        width: 120px;\n        overflow: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n      }\n\n      .icon {\n        font-size: 16px;\n        color: rgba(0, 0, 0, .45);\n      }\n\n      .icon:hover {\n        color: #000;\n      }\n    }\n  }\n}\n\n.console {\n  .miniIcon {\n    position: absolute;\n    top: 0;\n    right: 0;\n    display: block;\n    width: 56px;\n    height: 56px;\n    line-height: 56px;\n    text-align: center;\n    cursor: pointer;\n    color: rgba(0, 0, 0, .45);\n    margin-right: 56px;\n\n    :hover {\n      color: #000;\n    }\n  }\n\n  .header {\n    flex: 1;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n\n    .title {\n      width: 200px;\n      overflow: hidden;\n      white-space: nowrap;\n      text-overflow: ellipsis;\n      font-weight: 600;\n    }\n\n    .step {\n      flex: 1;\n      margin-right: 16px;\n    }\n\n    .codeIcon {\n      font-size: 22px;\n      color: #1890ff;\n    }\n  }\n}\n\n.collapse {\n  :global(.ant-collapse-content-box) {\n    padding: 0;\n  }\n\n  :global(.ant-collapse-header-text) {\n    flex: 1\n  }\n}\n\n.upload {\n  :global(.ant-upload-btn) {\n    padding: 0 !important;\n  }\n}\n\n.uploadHide {\n  :global(.ant-upload-drag) {\n    display: none;\n  }\n  :global(.ant-upload-list-item) {\n    margin: 0;\n  }\n}\n\n.min120 {\n  min-width: 120px;\n}\n\n.min155 {\n  min-width: 155px;\n}\n\n.min180 {\n  min-width: 180px;\n}"
  },
  {
    "path": "spug_web/src/pages/deploy/request/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from \"mobx\";\nimport http from 'libs/http';\nimport moment from 'moment';\nimport lds from 'lodash';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable counter = {};\n  @observable tabs = [];\n  @observable isFetching = false;\n  @observable addVisible = false;\n  @observable ext1Visible = false;\n  @observable ext2Visible = false;\n  @observable batchVisible = false;\n  @observable approveVisible = false;\n  @observable rollbackVisible = false;\n\n  @observable f_status = 'all';\n  @observable f_app_id;\n  @observable f_env_id;\n  @observable f_s_date;\n  @observable f_e_date;\n\n  @computed get dataSource() {\n    let data = this.records;\n    if (this.f_app_id) data = data.filter(x => x.app_id === this.f_app_id)\n    if (this.f_env_id) data = data.filter(x => x.env_id === this.f_env_id)\n    if (this.f_s_date) data = data.filter(x => {\n      const date = x.created_at.substr(0, 10);\n      return date >= this.f_s_date && date <= this.f_e_date\n    })\n    if (this.f_status !== 'all') {\n      if (this.f_status === '99') {\n        data = data.filter(x => ['-1', '2'].includes(x.status))\n      } else {\n        data = data.filter(x => x.status === this.f_status)\n      }\n    }\n    return data\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/deploy/request/')\n      .then(res => this.records = res)\n      .then(this._updateCounter)\n      .finally(() => this.isFetching = false)\n  };\n\n  fetchInfo = (id) => {\n    http.get('/api/deploy/request/info/', {params: {id}})\n      .then(res => {\n        for (let item of this.records) {\n          if (item.id === id) {\n            Object.assign(item, res, {key: Date.now()})\n            break\n          }\n        }\n      })\n      .then(this._updateCounter)\n  }\n\n  _updateCounter = () => {\n    const counter = {'all': 0, '-3': 0, '0': 0, '1': 0, '3': 0, '99': 0};\n    for (let item of this.records) {\n      counter['all'] += 1;\n      if (['-1', '2'].includes(item['status'])) {\n        counter['99'] += 1\n      } else {\n        counter[item['status']] += 1\n      }\n    }\n    this.counter = counter\n  };\n\n  loadDeploys = () => {\n    this.isLoading = true;\n    http.get('/api/app/deploy/')\n      .then(res => this.deploys = res)\n      .finally(() => this.isLoading = false)\n  };\n\n  updateDate = (data) => {\n    if (data && data.length === 2) {\n      this.f_s_date = data[0].format('YYYY-MM-DD');\n      this.f_e_date = data[1].format('YYYY-MM-DD')\n    } else {\n      this.f_s_date = null;\n      this.f_e_date = null\n    }\n  };\n\n  confirmAdd = (deploy) => {\n    const {id, host_ids, require_upload} = deploy;\n    this.record = {deploy_id: id, app_host_ids: host_ids, require_upload};\n    if (deploy.extend === '1') {\n      this.ext1Visible = true\n    } else {\n      this.ext2Visible = true\n    }\n    this.addVisible = false\n  };\n\n  rollback = (info) => {\n    this.record = lds.pick(info, ['deploy_id', 'host_ids']);\n    this.record.app_host_ids = info.host_ids;\n    this.record.name = `${info.name} - 回滚`;\n    this.rollbackVisible = true\n  }\n\n  showForm = (info) => {\n    this.record = info;\n    if (info.plan) this.record.plan = moment(info.plan);\n    if (info['app_extend'] === '1') {\n      this.ext1Visible = true\n    } else {\n      this.ext2Visible = true\n    }\n  };\n\n  showApprove = (info) => {\n    this.record = info;\n    this.approveVisible = true;\n  };\n\n  showConsole = (info, isClose) => {\n    const index = lds.findIndex(this.tabs, x => x.id === info.id);\n    if (isClose) {\n      if (index !== -1) {\n        this.tabs[index] = {}\n      }\n      this.fetchInfo(info.id)\n    } else if (index === -1) {\n      this.tabs.push(info)\n    }\n  };\n\n  readConsole = (info) => {\n    const index = lds.findIndex(this.tabs, x => x.id === info.id);\n    if (index === -1) {\n      info = Object.assign({}, info, {mode: 'read'})\n      this.tabs.push(info)\n    }\n  };\n\n  leaveConsole = () => {\n    this.tabs = []\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/exec/task/Output.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useRef, useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { PageHeader } from 'antd';\nimport {\n  LoadingOutlined,\n  CheckCircleOutlined,\n  ExclamationCircleOutlined,\n  CodeOutlined,\n  ClockCircleOutlined,\n} from '@ant-design/icons';\nimport { FitAddon } from 'xterm-addon-fit';\nimport { Terminal } from 'xterm';\nimport style from './index.module.less';\nimport { http, X_TOKEN } from 'libs';\nimport store from './store';\nimport gStore from 'gStore';\n\nlet gCurrent;\n\nfunction OutView(props) {\n  const el = useRef()\n  const [term] = useState(new Terminal());\n  const [fitPlugin] = useState(new FitAddon());\n  const [current, setCurrent] = useState(Object.keys(store.outputs)[0])\n\n  useEffect(() => {\n    store.tag = ''\n    gCurrent = current\n    term.setOption('disableStdin', true)\n    term.setOption('fontSize', gStore.terminal.fontSize)\n    term.setOption('fontFamily', gStore.terminal.fontFamily)\n    term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})\n    term.attachCustomKeyEventHandler((arg) => {\n      if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {\n        document.execCommand('copy')\n        return false\n      }\n      return true\n    })\n    term.loadAddon(fitPlugin)\n    term.open(el.current)\n    fitPlugin.fit()\n    term.write('\\x1b[36m### WebSocket connecting ...\\x1b[0m')\n    const resize = () => fitPlugin.fit();\n    window.addEventListener('resize', resize)\n\n    return () => window.removeEventListener('resize', resize);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${store.token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => {\n      const message = '\\r\\x1b[K\\x1b[36m### Waiting for scheduling ...\\x1b[0m'\n      for (let key of Object.keys(store.outputs)) {\n        store.outputs[key].data = message\n      }\n      term.write(message)\n      socket.send('ok');\n      fitPlugin.fit()\n      const formData = fitPlugin.proposeDimensions()\n      formData.token = store.token\n      http.patch('/api/exec/do/', formData)\n    }\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send('ping')\n      } else {\n        _handleData(e.data)\n      }\n    }\n    socket.onclose = () => {\n      for (let key of Object.keys(store.outputs)) {\n        if (store.outputs[key].status === -2) {\n          store.outputs[key].status = -1\n        }\n        store.outputs[key].data += '\\r\\n\\x1b[31mWebsocket connection failed!\\x1b[0m'\n        term.write('\\r\\n\\x1b[31mWebsocket connection failed!\\x1b[0m')\n      }\n    }\n    return () => socket && socket.close()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function _handleData(message) {\n    const {key, data, status} = JSON.parse(message);\n    if (status !== undefined) {\n      store.outputs[key].status = status;\n    }\n    if (data) {\n      store.outputs[key].data += data\n      if (String(key) === gCurrent) term.write(data)\n    }\n  }\n\n  function handleSwitch(key) {\n    setCurrent(key)\n    gCurrent = key\n    term.clear()\n    term.write(store.outputs[key].data)\n  }\n\n  function openTerminal(key) {\n    window.open(`/ssh?id=${key}`)\n  }\n\n  const {tag, items, counter} = store\n  return (\n    <div className={style.output}>\n      <div className={style.side}>\n        <PageHeader onBack={props.onBack} title=\"执行详情\"/>\n        <div className={style.tags}>\n          <div\n            className={`${style.item} ${tag === '0' ? style.pendingOn : style.pending}`}\n            onClick={() => store.updateTag('0')}>\n            <ClockCircleOutlined/>\n            <div>{counter['0']}</div>\n          </div>\n          <div\n            className={`${style.item} ${tag === '1' ? style.successOn : style.success}`}\n            onClick={() => store.updateTag('1')}>\n            <CheckCircleOutlined/>\n            <div>{counter['1']}</div>\n          </div>\n          <div\n            className={`${style.item} ${tag === '2' ? style.failOn : style.fail}`}\n            onClick={() => store.updateTag('2')}>\n            <ExclamationCircleOutlined/>\n            <div>{counter['2']}</div>\n          </div>\n        </div>\n\n        <div className={style.list}>\n          {items.map(([key, item]) => (\n            <div key={key} className={[style.item, key === current ? style.active : ''].join(' ')}\n                 onClick={() => handleSwitch(key)}>\n              {item.status === -2 ? (\n                <LoadingOutlined style={{color: '#1890ff'}}/>\n              ) : item.status === 0 ? (\n                <CheckCircleOutlined style={{color: '#52c41a'}}/>\n              ) : (\n                <ExclamationCircleOutlined style={{color: 'red'}}/>\n              )}\n              <div className={style.text}>{item.title}</div>\n            </div>\n          ))}\n        </div>\n      </div>\n      <div className={style.body}>\n        <div className={style.header}>\n          <div className={style.title}>{store.outputs[current].title}</div>\n          <CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>\n        </div>\n        <div className={style.termContainer}>\n          <div ref={el} className={style.term}/>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default observer(OutView)"
  },
  {
    "path": "spug_web/src/pages/exec/task/Parameter.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Modal, Form, Input, Select, message } from 'antd';\n\n\nfunction Render(props) {\n  switch (props.type) {\n    case 'string':\n      return <Input value={props.value} onChange={props.onChange} placeholder=\"请输入\"/>\n    case 'password':\n      return <Input.Password value={props.value} onChange={props.onChange} placeholder=\"请输入\"/>\n    case 'select':\n      const options = props.options.split('\\n').map(x => x.split(':'))\n      return (\n        <Select value={props.value} onChange={props.onChange} placeholder=\"请选择\">\n          {options.map((item, index) => item.length > 1 ? (\n            <Select.Option key={index} value={item[0]}>{item[1]}</Select.Option>\n          ) : (\n            <Select.Option key={index} value={item[0]}>{item[0]}</Select.Option>\n          ))}\n        </Select>\n      )\n    default:\n      return null\n  }\n}\n\nexport default function Parameter(props) {\n  const [form] = Form.useForm();\n\n  function handleSubmit() {\n    const formData = form.getFieldsValue();\n    for (let item of props.parameters.filter(x => x.required)) {\n      if (!formData[item.variable]) {\n        return message.error(`${item.name} 是必填项。`)\n      }\n    }\n    props.onOk(formData);\n    props.onCancel()\n  }\n\n  return (\n    <Modal\n      visible\n      width={600}\n      maskClosable={false}\n      title=\"执行任务\"\n      onCancel={props.onCancel}\n      okText=\"立即执行\"\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={props.parameter} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        {props.parameters.map(item => (\n          <Form.Item required={item.required} key={item.variable} name={item.variable} label={item.name}\n                     tooltip={item.desc} initialValue={item.default}>\n            <Render type={item.type} options={item.options}/>\n          </Form.Item>\n        ))}\n      </Form>\n    </Modal>\n  )\n}"
  },
  {
    "path": "spug_web/src/pages/exec/task/TemplateSelector.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the MIT License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { SyncOutlined } from '@ant-design/icons';\nimport { Modal, Table, Input, Button, Select } from 'antd';\nimport { SearchForm } from 'components';\nimport store from '../template/store';\n\n@observer\nclass TemplateSelector extends React.Component {\n  constructor(props) {\n    super(props);\n\n    this.state = {\n      selectedRows: [],\n    }\n  }\n\n  componentDidMount() {\n    if (store.records.length === 0) {\n      store.fetchRecords()\n    }\n  }\n\n  handleClick = (record) => {\n    this.setState({selectedRows: [record]});\n  };\n\n  handleSubmit = () => {\n    if (this.state.selectedRows.length > 0) {\n      const tpl = this.state.selectedRows[0]\n      this.props.onOk(tpl)\n    }\n    this.props.onCancel()\n  };\n\n  columns = [\n    {\n      title: '名称',\n      dataIndex: 'name',\n      ellipsis: true\n    }, {\n      title: '类型',\n      dataIndex: 'type',\n    }, {\n      title: '目标主机',\n      dataIndex: 'host_ids',\n      render: v => `${v.length}台`\n    }, {\n      title: '内容',\n      dataIndex: 'body',\n      ellipsis: true\n    }, {\n      title: '备注',\n      dataIndex: 'desc',\n      ellipsis: true\n    }];\n\n  render() {\n    const {selectedRows} = this.state;\n    return (\n      <Modal\n        visible\n        width={1000}\n        title=\"选择执行模板\"\n        onCancel={this.props.onCancel}\n        onOk={this.handleSubmit}\n        maskClosable={false}>\n        <SearchForm>\n          <SearchForm.Item span={8} title=\"模板类别\">\n            <Select allowClear placeholder=\"请选择\" value={store.f_type} onChange={v => store.f_type = v}>\n              {store.types.map(item => (\n                <Select.Option value={item} key={item}>{item}</Select.Option>\n              ))}\n            </Select>\n          </SearchForm.Item>\n          <SearchForm.Item span={8} title=\"模板名称\">\n            <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n          </SearchForm.Item>\n          <SearchForm.Item span={8}>\n            <Button type=\"primary\" icon={<SyncOutlined/>} onClick={store.fetchRecords}>刷新</Button>\n          </SearchForm.Item>\n        </SearchForm>\n        <Table\n          rowKey=\"id\"\n          rowSelection={{\n            selectedRowKeys: selectedRows.map(item => item.id),\n            type: 'radio',\n            onChange: (_, selectedRows) => this.setState({selectedRows})\n          }}\n          dataSource={store.dataSource}\n          loading={store.isFetching}\n          onRow={record => {\n            return {\n              onClick: () => this.handleClick(record)\n            }\n          }}\n          columns={this.columns}/>\n      </Modal>\n    )\n  }\n}\n\nexport default TemplateSelector\n"
  },
  {
    "path": "spug_web/src/pages/exec/task/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { PlusOutlined, ThunderboltOutlined, BulbOutlined, QuestionCircleOutlined } from '@ant-design/icons';\nimport { Form, Button, Radio, Tooltip } from 'antd';\nimport { ACEditor, AuthDiv, Breadcrumb } from 'components';\nimport HostSelector from 'pages/host/Selector';\nimport TemplateSelector from './TemplateSelector';\nimport Parameter from './Parameter';\nimport Output from './Output';\nimport { http, cleanCommand } from 'libs';\nimport moment from 'moment';\nimport store from './store';\nimport gStore from 'gStore';\nimport style from './index.module.less';\n\nfunction TaskIndex() {\n  const [loading, setLoading] = useState(false)\n  const [interpreter, setInterpreter] = useState('sh')\n  const [command, setCommand] = useState('')\n  const [template_id, setTemplateId] = useState()\n  const [histories, setHistories] = useState([])\n  const [parameters, setParameters] = useState([])\n  const [visible, setVisible] = useState(false)\n\n  useEffect(() => {\n    if (!loading) {\n      http.get('/api/exec/do/')\n        .then(res => setHistories(res))\n    }\n  }, [loading])\n\n  useEffect(() => {\n    if (!command) {\n      setParameters([])\n    }\n  }, [command])\n\n  useEffect(() => {\n    gStore.fetchUserSettings()\n    return () => {\n      store.host_ids = []\n      if (store.showConsole) {\n        store.switchConsole()\n      }\n    }\n  }, [])\n\n  function handleSubmit(params) {\n    if (!params && parameters.length > 0) {\n      return setVisible(true)\n    }\n    setLoading(true)\n    const formData = {interpreter, template_id, params, host_ids: store.host_ids, command: cleanCommand(command)}\n    http.post('/api/exec/do/', formData)\n      .then(store.switchConsole)\n      .finally(() => setLoading(false))\n  }\n\n  function handleTemplate(tpl) {\n    if (tpl.host_ids.length > 0) store.host_ids = tpl.host_ids\n    setTemplateId(tpl.id)\n    setInterpreter(tpl.interpreter)\n    setCommand(tpl.body)\n    setParameters(tpl.parameters)\n  }\n\n  function handleClick(item) {\n    setTemplateId(item.template_id)\n    setInterpreter(item.interpreter)\n    setCommand(item.command)\n    setParameters(item.parameters || [])\n    store.host_ids = item.host_ids\n  }\n\n  return (\n    <AuthDiv auth=\"exec.task.do\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>批量执行</Breadcrumb.Item>\n        <Breadcrumb.Item>执行任务</Breadcrumb.Item>\n      </Breadcrumb>\n      <div className={style.index} hidden={store.showConsole}>\n        <Form layout=\"vertical\" className={style.left}>\n          <Form.Item required label=\"目标主机\">\n            <HostSelector type=\"button\" value={store.host_ids} onChange={ids => store.host_ids = ids}/>\n          </Form.Item>\n\n          <Form.Item required label=\"执行命令\" style={{position: 'relative'}}>\n            <Radio.Group\n              buttonStyle=\"solid\"\n              style={{marginBottom: 12}}\n              value={interpreter}\n              onChange={e => setInterpreter(e.target.value)}>\n              <Radio.Button value=\"sh\" style={{width: 80, textAlign: 'center'}}>Shell</Radio.Button>\n              <Radio.Button value=\"python\" style={{width: 80, textAlign: 'center'}}>Python</Radio.Button>\n            </Radio.Group>\n            <a href=\"https://ops.spug.cc/docs/batch-exec\" target=\"_blank\" rel=\"noopener noreferrer\"\n               className={style.tips}><BulbOutlined/> 使用全局变量？</a>\n            <Button style={{float: 'right'}} icon={<PlusOutlined/>} onClick={store.switchTemplate}>从执行模版中选择</Button>\n            <ACEditor className={style.editor} mode={interpreter} value={command} width=\"100%\" onChange={setCommand}/>\n          </Form.Item>\n          <Button loading={loading} icon={<ThunderboltOutlined/>} type=\"primary\"\n                  onClick={() => handleSubmit()}>开始执行</Button>\n        </Form>\n\n        <div className={style.right}>\n          <div className={style.title}>\n            执行记录\n            <Tooltip title=\"多次相同的执行记录将会合并展示，每天自动清理，保留最近30条记录。\">\n              <QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>\n            </Tooltip>\n          </div>\n          <div className={style.inner}>\n            {histories.map((item, index) => (\n              <div key={index} className={style.item} onClick={() => handleClick(item)}>\n                <div className={style[item.interpreter]}>{item.interpreter.substr(0, 2)}</div>\n                <div className={style.number}>{item.host_ids.length}</div>\n                {item.template_name ? (\n                  <div className={style.tpl}>{item.template_name}</div>\n                ) : (\n                  <div className={style.command}>{item.command}</div>\n                )}\n                <div className={style.desc}>{moment(item.updated_at).format('MM.DD HH:mm')}</div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n      {store.showTemplate && <TemplateSelector onCancel={store.switchTemplate} onOk={handleTemplate}/>}\n      {store.showConsole && <Output onBack={store.switchConsole}/>}\n      {visible && <Parameter parameters={parameters} onCancel={() => setVisible(false)} onOk={v => handleSubmit(v)}/>}\n    </AuthDiv>\n  )\n}\n\nexport default observer(TaskIndex)\n"
  },
  {
    "path": "spug_web/src/pages/exec/task/index.module.less",
    "content": ".index {\n  display: flex;\n  height: calc(100vh - 218px);\n  min-height: 420px;\n  background-color: #fff;\n  overflow: hidden;\n\n  .left {\n    padding: 24px;\n    width: 60%;\n    border-right: 1px solid #dfdfdf;\n\n    .tips {\n      position: absolute;\n      top: 10px;\n      left: 180px;\n      color: #999;\n    }\n\n    .tips:hover {\n      color: #777;\n    }\n\n    .editor {\n      height: calc(100vh - 482px) !important;\n      min-height: 152px;\n    }\n  }\n\n  .right {\n    width: 40%;\n    max-width: 600px;\n    display: flex;\n    flex-direction: column;\n    background-color: #fafafa;\n    padding: 24px 24px 0 24px;\n\n    .title {\n      font-weight: 500;\n      margin-bottom: 12px;\n    }\n\n    .inner {\n      flex: 1;\n      overflow: auto;\n    }\n\n    .item {\n      display: flex;\n      align-items: center;\n      border-radius: 2px;\n      padding: 8px 12px;\n      cursor: pointer;\n      margin-bottom: 12px;\n\n      .sh {\n        width: 20px;\n        height: 20px;\n        line-height: 20px;\n        text-align: center;\n        color: #fff;\n        font-weight: 500;\n        border-radius: 2px;\n        background-color: #1890ff;\n      }\n\n      .python {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        color: #fff;\n        width: 20px;\n        height: 20px;\n        font-weight: 500;\n        border-radius: 2px;\n        background-color: #dca900;\n      }\n\n      .number {\n        width: 24px;\n        text-align: center;\n        margin-left: 12px;\n        border-radius: 2px;\n        font-weight: 500;\n        background-color: #dfdfdf;\n      }\n\n      .command {\n        flex: 1;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        margin: 0 12px;\n      }\n\n      .tpl {\n        flex: 1;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        margin: 0 12px;\n        background-color: #d2e7fd;\n        padding: 0 8px;\n        border-radius: 2px;\n      }\n\n      .desc {\n        color: #999;\n      }\n    }\n\n    .item:hover {\n      border-color: #1890ff;\n      background-color: #e6f7ff;\n    }\n  }\n}\n\n.output {\n  display: flex;\n  background-color: #fff;\n  height: calc(100vh - 218px);\n  overflow: hidden;\n\n  .side {\n    display: flex;\n    flex-direction: column;\n    width: 300px;\n    border-right: 1px solid #dfdfdf;\n\n    .tags {\n      padding: 0 24px 24px;\n      display: flex;\n      justify-content: space-between;\n\n      .item {\n        width: 70px;\n        display: flex;\n        align-items: center;\n        justify-content: space-around;\n        border-radius: 35px;\n        padding: 2px 8px;\n        cursor: pointer;\n        background-color: #f3f3f3;\n        color: #666;\n        user-select: none;\n      }\n\n      .pendingOn {\n        background-color: #1890ff;\n        color: #fff;\n      }\n\n      .pending {\n        color: #1890ff;\n      }\n\n      .pending:hover {\n        background-color: #1890ff;\n        opacity: 0.7;\n        color: #fff;\n      }\n\n      .successOn {\n        background-color: #52c41a;\n        color: #fff;\n      }\n\n      .success {\n        color: #52c41a;\n      }\n\n      .success:hover {\n        background-color: #52c41a;\n        opacity: 0.7;\n        color: #fff;\n      }\n\n      .failOn {\n        background-color: red;\n        color: #fff;\n      }\n\n      .fail {\n        color: red;\n      }\n\n      .fail:hover {\n        background-color: red;\n        opacity: 0.6;\n        color: #fff;\n      }\n    }\n\n    .list {\n      flex: 1;\n      overflow: auto;\n      padding-bottom: 8px;\n\n      .item {\n        display: flex;\n        align-items: center;\n        padding: 8px 24px;\n        cursor: pointer;\n\n        &.active {\n          background: #e6f7ff;\n        }\n\n        :global(.anticon) {\n          margin-right: 4px;\n        }\n\n        .text {\n          white-space: nowrap;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          user-select: none;\n        }\n      }\n\n      .item:hover {\n        background: #e6f7ff;\n      }\n    }\n  }\n\n  .body {\n    display: flex;\n    flex-direction: column;\n    width: calc(100% - 300px);\n    padding: 22px;\n\n    .header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 12px;\n\n      .icon {\n        font-size: 18px;\n        color: #1890ff;\n        cursor: pointer;\n      }\n\n      .title {\n        font-weight: 500;\n      }\n    }\n\n\n    .termContainer {\n      background-color: #2b2b2b;\n      padding: 8px 0 4px 12px;\n      border-radius: 4px;\n\n      .term {\n        width: 100%;\n        height: calc(100vh - 300px);\n      }\n    }\n  }\n}"
  },
  {
    "path": "spug_web/src/pages/exec/task/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from \"mobx\";\nimport hostStore from 'pages/host/store';\n\nclass Store {\n  @observable outputs = {};\n  @observable tag = '';\n  @observable host_ids = [];\n  @observable token = null;\n  @observable showConsole = false;\n  @observable showTemplate = false;\n\n  @computed get items() {\n    const items = Object.entries(this.outputs)\n    if (this.tag === '') {\n      return items\n    } else if (this.tag === '0') {\n      return items.filter(([_, x]) => x.status === -2)\n    } else if (this.tag === '1') {\n      return items.filter(([_, x]) => x.status === 0)\n    } else {\n      return items.filter(([_, x]) => ![-2, 0].includes(x.status))\n    }\n  }\n\n  @computed get counter() {\n    const counter = {'0': 0, '1': 0, '2': 0}\n    for (let item of Object.values(this.outputs)) {\n      if (item.status === -2) {\n        counter['0'] += 1\n      } else if (item.status === 0) {\n        counter['1'] += 1\n      } else {\n        counter['2'] += 1\n      }\n    }\n    return counter\n  }\n\n  updateTag = (tag) => {\n    if (tag === this.tag) {\n      this.tag = ''\n    } else {\n      this.tag = tag\n    }\n  }\n\n  switchTemplate = () => {\n    this.showTemplate = !this.showTemplate\n  };\n\n  switchConsole = (token) => {\n    if (this.showConsole) {\n      this.showConsole = false;\n      this.outputs = {}\n    } else {\n      for (let id of this.host_ids) {\n        const host = hostStore.idMap[id];\n        this.outputs[host.id] = {\n          title: `${host.name}(${host.hostname}:${host.port})`,\n          data: '\\x1b[36m### WebSocket connecting ...\\x1b[0m',\n          status: -2\n        }\n      }\n      this.token = token;\n      this.showConsole = true\n    }\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/exec/template/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { ExclamationCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';\nimport { Modal, Form, Input, Select, Button, Radio, Table, Tooltip, message } from 'antd';\nimport { ACEditor } from 'components';\nimport HostSelector from 'pages/host/Selector';\nimport Parameter from './Parameter';\nimport { http, cleanCommand } from 'libs';\nimport lds from 'lodash';\nimport S from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [body, setBody] = useState(S.record.body);\n  const [parameter, setParameter] = useState();\n  const [parameters, setParameters] = useState([]);\n\n  useEffect(() => {\n    setParameters(S.record.parameters)\n  }, [])\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = S.record.id;\n    formData['body'] = cleanCommand(body);\n    formData['host_ids'] = S.record.host_ids;\n    formData['parameters'] = parameters;\n    http.post('/api/exec/template/', formData)\n      .then(res => {\n        message.success('操作成功');\n        S.formVisible = false;\n        S.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleAddZone() {\n    let type;\n    Modal.confirm({\n      icon: <ExclamationCircleOutlined/>,\n      title: '添加模板类型',\n      content: (\n        <Form layout=\"vertical\" style={{marginTop: 24}}>\n          <Form.Item required label=\"模板类型\">\n            <Input onChange={e => type = e.target.value}/>\n          </Form.Item>\n        </Form>\n      ),\n      onOk: () => {\n        if (type) {\n          S.types.push(type);\n          form.setFieldsValue({type})\n        }\n      },\n    })\n  }\n\n  function updateParameter(data) {\n    if (data.id) {\n      const index = lds.findIndex(parameters, {id: data.id})\n      parameters[index] = data\n    } else {\n      data.id = parameters.length + 1\n      parameters.push(data)\n    }\n    setParameters([...parameters])\n    setParameter(null)\n  }\n\n  function delParameter(index) {\n    parameters.splice(index, 1)\n    setParameters([...parameters])\n  }\n\n  const info = S.record;\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={S.record.id ? '编辑模板' : '新建模板'}\n      onCancel={() => S.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={info} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required label=\"模板类型\" style={{marginBottom: 0}}>\n          <Form.Item name=\"type\" style={{display: 'inline-block', width: 'calc(75%)', marginRight: 8}}>\n            <Select placeholder=\"请选择模板类型\">\n              {S.types.map(item => (\n                <Select.Option value={item} key={item}>{item}</Select.Option>\n              ))}\n            </Select>\n          </Form.Item>\n          <Form.Item style={{display: 'inline-block', width: 'calc(25%-8px)'}}>\n            <Button type=\"link\" onClick={handleAddZone}>添加类型</Button>\n          </Form.Item>\n        </Form.Item>\n        <Form.Item required name=\"name\" label=\"模板名称\">\n          <Input placeholder=\"请输入模板名称\"/>\n        </Form.Item>\n        <Form.Item required name=\"interpreter\" label=\"脚本语言\">\n          <Radio.Group>\n            <Radio.Button value=\"sh\">Shell</Radio.Button>\n            <Radio.Button value=\"python\">Python</Radio.Button>\n          </Radio.Group>\n        </Form.Item>\n        <Form.Item required label=\"模板内容\" shouldUpdate={(p, c) => p.interpreter !== c.interpreter}>\n          {({getFieldValue}) => (\n            <ACEditor\n              mode={getFieldValue('interpreter')}\n              value={body}\n              onChange={val => setBody(val)}\n              height=\"250px\"/>\n          )}\n        </Form.Item>\n        <Form.Item label=\"参数化\">\n          {parameters.length > 0 && (\n            <Table pagination={false} bordered rowKey=\"id\" size=\"small\" dataSource={parameters}>\n              <Table.Column title=\"参数名\" dataIndex=\"name\"\n                            render={(_, row) => <Tooltip title={row.desc}>{row.name}</Tooltip>}/>\n              <Table.Column title=\"变量名\" dataIndex=\"variable\"/>\n              <Table.Column title=\"操作\" width={90} render={(item, _, index) => [\n                <Button key=\"1\" type=\"link\" icon={<EditOutlined/>} onClick={() => setParameter(item)}/>,\n                <Button danger key=\"2\" type=\"link\" icon={<DeleteOutlined/>} onClick={() => delParameter(index)}/>\n              ]}>\n              </Table.Column>\n            </Table>\n          )}\n          <Button type=\"link\" style={{padding: 0}} onClick={() => setParameter({})}>添加参数</Button>\n        </Form.Item>\n        <Form.Item label=\"目标主机\">\n          <HostSelector nullable value={info.host_ids} onChange={ids => info.host_ids = ids}/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入模板备注信息\"/>\n        </Form.Item>\n      </Form>\n      {parameter ? (\n        <Parameter\n          parameter={parameter}\n          parameters={parameters}\n          onCancel={() => setParameter(null)}\n          onOk={updateParameter}/>\n      ) : null}\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/exec/template/Parameter.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Modal, Form, Input, Radio, Switch, message } from 'antd';\nimport S from './store';\nimport lds from 'lodash';\n\nexport default function Parameter(props) {\n  const [form] = Form.useForm();\n\n  function handleSubmit() {\n    const formData = form.getFieldsValue();\n    formData.id = props.parameter.id\n    if (!formData.name) return message.error('请输入参数名')\n    if (!formData.variable) return message.error('请输入变量名')\n    if (!formData.type) return message.error('请选择参数类型')\n    if (formData.type === 'select' && !formData.options) return message.error('请输入可选项')\n    const tmp = lds.find(props.parameters, {variable: formData.variable})\n    if (tmp && tmp.id !== formData.id) return message.error('变量名重复')\n    props.onOk(formData)\n  }\n\n  return (\n    <Modal\n      visible\n      width={600}\n      maskClosable={false}\n      title=\"编辑参数\"\n      onCancel={props.onCancel}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={props.parameter} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"参数名\" tooltip=\"参数的简短名称。\">\n          <Input placeholder=\"请输入参数名称\"/>\n        </Form.Item>\n        <Form.Item required name=\"variable\" label=\"变量名\"\n                   tooltip=\"在脚本使用的变量名称，固定前缀_SPUG_ + 输入的变量名，例如变量名name，则最终生成环境变量为 _SPUG_name\">\n          <Input placeholder=\"请输入变量名\"/>\n        </Form.Item>\n        <Form.Item required name=\"type\" label=\"参数类型\" tooltip=\"不同类型展示的形式不同。\">\n          <Radio.Group style={{width: '100%'}}>\n            {Object.entries(S.ParameterTypes).map(([key, val]) => (\n              <Radio.Button key={key} value={key}>{val}</Radio.Button>\n            ))}\n          </Radio.Group>\n        </Form.Item>\n        <Form.Item noStyle shouldUpdate>\n          {({getFieldValue}) =>\n            ['select'].includes(getFieldValue('type')) ? (\n              <Form.Item required name=\"options\" label=\"可选项\" tooltip=\"每项单独一行，每行可以用英文冒号分割前边是值后边是显示的内容。\">\n                <Input.TextArea autoSize={{minRows: 3, maxRows: 5}} placeholder=\"每行一个选项，例如：&#13;&#10;test:测试环境&#13;&#10;prod:生产环境\"/>\n              </Form.Item>\n            ) : null\n          }\n        </Form.Item>\n        <Form.Item name=\"required\" valuePropName=\"checked\" label=\"必填\" tooltip=\"该参数是否为必填项\">\n          <Switch checkedChildren=\"是\" unCheckedChildren=\"否\"/>\n        </Form.Item>\n        <Form.Item name=\"default\" label=\"默认值\">\n          <Input placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"提示信息\" tooltip=\"会展示在参数的输入框下方。\">\n          <Input placeholder=\"请输入该参数的帮助提示信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n}"
  },
  {
    "path": "spug_web/src/pages/exec/template/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { http, hasPermission } from 'libs';\nimport { Action, TableCard, AuthButton } from \"components\";\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/exec/template/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"et\"\n        title=\"模板列表\"\n        rowKey=\"id\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"exec.template.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"模版名称\" dataIndex=\"name\"/>\n        <Table.Column title=\"模版类型\" dataIndex=\"type\"/>\n        <Table.Column ellipsis title=\"模版内容\" dataIndex=\"body\"/>\n        <Table.Column ellipsis title=\"描述信息\" dataIndex=\"desc\"/>\n        {hasPermission('exec.template.edit|exec.template.del') && (\n          <Table.Column title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"exec.template.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button danger auth=\"exec.template.del\" onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/exec/template/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input, Select } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"exec.template.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>批量执行</Breadcrumb.Item>\n        <Breadcrumb.Item>模版管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"模板类型\">\n          <Select allowClear value={store.f_type} onChange={v => store.f_type = v} placeholder=\"请选择\">\n            {store.types.map(item => (\n              <Select.Option value={item} key={item}>{item}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={8} title=\"模版名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/exec/template/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from \"mobx\";\nimport { http, includes } from 'libs';\n\nclass Store {\n  ParameterTypes = {\n    'string': '文本框',\n    'password': '密码框',\n    'select': '下拉选择'\n  }\n  @observable records = [];\n  @observable types = [];\n  @observable record = {parameters: []};\n  @observable isFetching = false;\n  @observable formVisible = false;\n\n  @observable f_name;\n  @observable f_type;\n\n  get dataSource() {\n    let data = this.records\n    if (this.f_name) data = data.filter(x => includes(x.name, this.f_name))\n    if (this.f_type) data = data.filter(x => includes(x.type, this.f_type))\n    return data\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/exec/template/')\n      .then(({types, templates}) => {\n        this.records = templates;\n        this.types = types\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {interpreter: 'sh', host_ids: [], parameters: []}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/exec/transfer/Output.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useRef, useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { PageHeader } from 'antd';\nimport {\n  LoadingOutlined,\n  CheckCircleOutlined,\n  ExclamationCircleOutlined,\n  CodeOutlined,\n  ClockCircleOutlined,\n} from '@ant-design/icons';\nimport { FitAddon } from 'xterm-addon-fit';\nimport { Terminal } from 'xterm';\nimport style from './index.module.less';\nimport { X_TOKEN, http } from 'libs';\nimport store from './store';\nimport gStore from 'gStore';\n\nlet gCurrent;\n\nfunction OutView(props) {\n  const el = useRef()\n  const [term] = useState(new Terminal());\n  const [fitPlugin] = useState(new FitAddon());\n  const [current, setCurrent] = useState(Object.keys(store.outputs)[0])\n\n  useEffect(() => {\n    store.tag = ''\n    gCurrent = current\n    term.setOption('disableStdin', true)\n    term.setOption('fontSize', 14)\n    term.setOption('lineHeight', 1.2)\n    term.setOption('fontFamily', gStore.terminal.fontFamily)\n    term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})\n    term.attachCustomKeyEventHandler((arg) => {\n      if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {\n        document.execCommand('copy')\n        return false\n      }\n      return true\n    })\n    term.loadAddon(fitPlugin)\n    term.open(el.current)\n    fitPlugin.fit()\n    term.write('\\x1b[36m### WebSocket connecting ...\\x1b[0m')\n    const resize = () => fitPlugin.fit();\n    window.addEventListener('resize', resize)\n\n    return () => window.removeEventListener('resize', resize);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${props.token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => {\n      const message = '\\r\\x1b[K\\x1b[36m### Waiting for scheduling ...\\x1b[0m'\n      for (let key of Object.keys(store.outputs)) {\n        store.outputs[key].data = message\n      }\n      term.write(message)\n      socket.send('ok');\n      fitPlugin.fit()\n      http.patch('/api/exec/transfer/', {token: props.token})\n    }\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send('ping')\n      } else {\n        _handleData(e.data)\n      }\n    }\n    socket.onclose = () => {\n      for (let key of Object.keys(store.outputs)) {\n        if (store.outputs[key].status === -2) {\n          store.outputs[key].status = -1\n        }\n        store.outputs[key].data += '\\r\\n\\x1b[31mWebsocket connection failed!\\x1b[0m'\n        term.write('\\r\\n\\x1b[31mWebsocket connection failed!\\x1b[0m')\n      }\n    }\n    return () => socket && socket.close()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function _handleData(message) {\n    const {key, data, status} = JSON.parse(message);\n    if (status !== undefined) {\n      store.outputs[key].status = status;\n    }\n    if (data) {\n      store.outputs[key].data += data\n      if (String(key) === gCurrent) term.write(data)\n    }\n  }\n\n  function handleSwitch(key) {\n    setCurrent(key)\n    gCurrent = key\n    term.clear()\n    term.write(store.outputs[key].data)\n  }\n\n  function openTerminal(key) {\n    window.open(`/ssh?id=${key}`)\n  }\n\n  const {tag, items, counter} = store\n  return (\n    <div className={style.output}>\n      <div className={style.side}>\n        <PageHeader onBack={props.onBack} title=\"执行详情\"/>\n        <div className={style.tags}>\n          <div\n            className={`${style.item} ${tag === '0' ? style.pendingOn : style.pending}`}\n            onClick={() => store.updateTag('0')}>\n            <ClockCircleOutlined/>\n            <div>{counter['0']}</div>\n          </div>\n          <div\n            className={`${style.item} ${tag === '1' ? style.successOn : style.success}`}\n            onClick={() => store.updateTag('1')}>\n            <CheckCircleOutlined/>\n            <div>{counter['1']}</div>\n          </div>\n          <div\n            className={`${style.item} ${tag === '2' ? style.failOn : style.fail}`}\n            onClick={() => store.updateTag('2')}>\n            <ExclamationCircleOutlined/>\n            <div>{counter['2']}</div>\n          </div>\n        </div>\n\n        <div className={style.list}>\n          {items.map(([key, item]) => (\n            <div key={key} className={[style.item, key === current ? style.active : ''].join(' ')}\n                 onClick={() => handleSwitch(key)}>\n              {item.status === -2 ? (\n                <LoadingOutlined style={{color: '#1890ff'}}/>\n              ) : item.status === 0 ? (\n                <CheckCircleOutlined style={{color: '#52c41a'}}/>\n              ) : (\n                <ExclamationCircleOutlined style={{color: 'red'}}/>\n              )}\n              <div className={style.text}>{item.title}</div>\n            </div>\n          ))}\n        </div>\n      </div>\n      <div className={style.body}>\n        <div className={style.header}>\n          <div className={style.title}>{store.outputs[current].title}</div>\n          <CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>\n        </div>\n        <div className={style.termContainer}>\n          <div ref={el} className={style.term}/>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default observer(OutView)"
  },
  {
    "path": "spug_web/src/pages/exec/transfer/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport {\n  ThunderboltOutlined,\n  QuestionCircleOutlined,\n  UploadOutlined,\n  CloudServerOutlined,\n  BulbOutlined,\n} from '@ant-design/icons';\nimport { Form, Button, Tooltip, Space, Card, Table, Input, Upload, message } from 'antd';\nimport { AuthDiv, Breadcrumb } from 'components';\nimport HostSelector from 'pages/host/Selector';\nimport Output from './Output';\nimport { http, uniqueId } from 'libs';\nimport moment from 'moment';\nimport store from './store';\nimport style from './index.module.less';\n\nfunction TransferIndex() {\n  const [loading, setLoading] = useState(false)\n  const [files, setFiles] = useState([])\n  const [dir, setDir] = useState('')\n  const [hosts, setHosts] = useState([])\n  const [percent, setPercent] = useState()\n  const [token, setToken] = useState()\n  const [histories, setHistories] = useState([])\n\n  useEffect(() => {\n    if (!loading) {\n      http.get('/api/exec/transfer/')\n        .then(res => setHistories(res))\n    }\n  }, [loading])\n\n  function _handleProgress(e) {\n    const data = e.loaded / e.total * 100\n    if (!percent && data === 100) return\n    setPercent(String(data).replace(/(\\d+\\.\\d).*/, '$1'))\n  }\n\n  function handleSubmit() {\n    const formData = new FormData();\n    if (files.length === 0) return message.error('请添加数据源')\n    if (!dir) return message.error('请输入目标路径')\n    if (hosts.length === 0) return message.error('请选择目标主机')\n    const data = {dst_dir: dir, host_ids: hosts.map(x => x.id)}\n    for (let index in files) {\n      const item = files[index]\n      if (item.type === 'host') {\n        data.host = JSON.stringify([item.host_id, item.path])\n      } else {\n        formData.append(`file${index}`, item.path)\n      }\n    }\n    formData.append('data', JSON.stringify(data))\n    setLoading(true)\n    http.post('/api/exec/transfer/', formData, {timeout: 600000, onUploadProgress: _handleProgress})\n      .then(res => {\n        const tmp = {}\n        for (let host of hosts) {\n          tmp[host.id] = {\n            title: `${host.name}(${host.hostname}:${host.port})`,\n            data: '\\x1b[36m### WebSocket connecting ...\\x1b[0m',\n            status: -2\n          }\n        }\n        store.outputs = tmp\n        setToken(res)\n      })\n      .finally(() => {\n        setLoading(false)\n        setPercent()\n      })\n  }\n\n  function makeFile(row) {\n    setFiles([{\n      id: uniqueId(),\n      type: 'host',\n      name: row.name,\n      path: '',\n      host_id: row.id\n    }])\n  }\n\n  function handleUpload(_, fileList) {\n    const tmp = files.length > 0 && files[0].type === 'upload' ? [...files] : []\n    for (let file of fileList) {\n      tmp.push({id: uniqueId(), type: 'upload', name: '本地上传', path: file})\n    }\n    setFiles(tmp)\n    return Upload.LIST_IGNORE\n  }\n\n  function handleRemove(index) {\n    files.splice(index, 1)\n    setFiles([...files])\n  }\n\n  function handleCloseOutput() {\n    setToken()\n    if (!store.counter['0'] && !store.counter['2']) {\n      setFiles([])\n    }\n  }\n\n  return (<AuthDiv auth=\"exec.transfer.do\">\n    <Breadcrumb>\n      <Breadcrumb.Item>首页</Breadcrumb.Item>\n      <Breadcrumb.Item>批量执行</Breadcrumb.Item>\n      <Breadcrumb.Item>文件分发</Breadcrumb.Item>\n    </Breadcrumb>\n    <div className={style.index} hidden={token}>\n      <div className={style.left}>\n        <Card type=\"inner\" title={`数据源${files.length ? `（${files.length}）` : ''}`} extra={(<Space size={24}>\n          <Upload multiple beforeUpload={handleUpload}><Space\n            className=\"btn\"><UploadOutlined/>上传本地文件</Space></Upload>\n          <HostSelector onlyOne mode=\"rows\" onChange={row => makeFile(row)}>\n            <Space className=\"btn\"><CloudServerOutlined/>添加主机文件</Space>\n          </HostSelector>\n        </Space>)}>\n          <Table rowKey=\"id\" className={style.table} showHeader={false} pagination={false} size=\"small\"\n                 dataSource={files}>\n            <Table.Column title=\"文件来源\" dataIndex=\"name\"/>\n            <Table.Column title=\"文件名称/路径\" render={info => info.type === 'upload' ? info.path.name : (\n              <Input onChange={e => info.path = e.target.value} placeholder=\"请输入要同步的目录路径\"/>)}/>\n            <Table.Column title=\"操作\" render={(_, __, index) => (\n              <Button danger type=\"link\" onClick={() => handleRemove(index)}>移除</Button>)}/>\n          </Table>\n        </Card>\n        <Card type=\"inner\" title=\"分发目标\" style={{margin: '24px 0'}} bodyStyle={{paddingBottom: 0}} extra={(\n          <Tooltip className={style.tips}\n                   title=\"文件分发功能依赖rsync，大部分linux发行版默认都已安装，如未安装可通过「批量执行/执行任务」进行批量安装。\">\n            <BulbOutlined/> 小提示\n          </Tooltip>\n        )}>\n          <Form>\n            <Form.Item required label=\"目标路径\">\n              <Input value={dir} onChange={e => setDir(e.target.value)} placeholder=\"请输入目标路径\"/>\n            </Form.Item>\n            <Form.Item required label=\"目标主机\">\n              <HostSelector type=\"button\" mode=\"rows\" value={hosts.map(x => x.id)} onChange={rows => setHosts(rows)}/>\n            </Form.Item>\n          </Form>\n        </Card>\n\n        <Button loading={loading} icon={<ThunderboltOutlined/>} type=\"primary\" onClick={() => handleSubmit()}>\n          {percent ? `上传中 ${percent}%` : '开始执行'}\n        </Button>\n      </div>\n\n      <div className={style.right}>\n        <div className={style.title}>\n          分发记录\n          <Tooltip title=\"每天自动清理，保留最近30条记录。\">\n            <QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>\n          </Tooltip>\n        </div>\n        <div className={style.inner}>\n          {histories.map((item, index) => (<div key={index} className={style.item}>\n            {item.host_id ? (\n              <CloudServerOutlined className={style.host}/>\n            ) : (\n              <UploadOutlined className={style.upload}/>\n            )}\n            <div className={style[item.interpreter]}>{item.interpreter}</div>\n            <div className={style.number}>{item.host_ids.length}</div>\n            <div className={style.command}>{item.dst_dir}</div>\n            <div className={style.desc}>{moment(item.updated_at).format('MM.DD HH:mm')}</div>\n          </div>))}\n        </div>\n      </div>\n    </div>\n    {token ? <Output token={token} onBack={handleCloseOutput}/> : null}\n  </AuthDiv>)\n}\n\nexport default observer(TransferIndex)\n"
  },
  {
    "path": "spug_web/src/pages/exec/transfer/index.module.less",
    "content": ".index {\n  display: flex;\n  height: calc(100vh - 218px);\n  min-height: 500px;\n  background-color: #fff;\n  overflow: hidden;\n\n  .left {\n    padding: 24px;\n    width: 60%;\n    border-right: 1px solid #dfdfdf;\n\n    .table {\n      max-height: calc(100vh - 600px);\n      overflow: auto;\n    }\n\n    .area {\n      cursor: pointer;\n      width: 200px;\n      height: 32px;\n    }\n\n    .tips {\n      font-size: 12px;\n      color: #999;\n    }\n\n    :global(.ant-table-tbody) {\n      tr:last-child {\n        td {\n          border: none;\n        }\n      }\n    }\n\n    :global(.ant-empty-normal) {\n      margin: 12px 0;\n    }\n  }\n\n  .right {\n    width: 40%;\n    max-width: 600px;\n    display: flex;\n    flex-direction: column;\n    background-color: #fafafa;\n    padding: 24px 24px 0 24px;\n\n    .title {\n      font-weight: 500;\n      margin-bottom: 12px;\n    }\n\n    .inner {\n      flex: 1;\n      overflow: auto;\n    }\n\n    .item {\n      display: flex;\n      align-items: center;\n      border-radius: 2px;\n      padding: 8px 12px;\n      margin-bottom: 12px;\n\n      .host {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        width: 20px;\n        height: 20px;\n        color: #fff;\n        border-radius: 2px;\n        background-color: #1890ff;\n      }\n\n      .upload {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        color: #fff;\n        width: 20px;\n        height: 20px;\n        border-radius: 2px;\n        background-color: #dca900;\n      }\n\n      .number {\n        width: 24px;\n        text-align: center;\n        margin-left: 12px;\n        border-radius: 2px;\n        font-weight: 500;\n        background-color: #dfdfdf;\n      }\n\n      .command {\n        flex: 1;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        margin: 0 12px;\n      }\n\n      .desc {\n        color: #999;\n      }\n    }\n\n    .item:hover {\n      border-color: #1890ff;\n      background-color: #e6f7ff;\n    }\n  }\n}\n\n.output {\n  display: flex;\n  background-color: #fff;\n  height: calc(100vh - 218px);\n  overflow: hidden;\n\n  .side {\n    display: flex;\n    flex-direction: column;\n    width: 300px;\n    border-right: 1px solid #dfdfdf;\n\n    .tags {\n      padding: 0 24px 24px;\n      display: flex;\n      justify-content: space-between;\n\n      .item {\n        width: 70px;\n        display: flex;\n        align-items: center;\n        justify-content: space-around;\n        border-radius: 35px;\n        padding: 2px 8px;\n        cursor: pointer;\n        background-color: #f3f3f3;\n        color: #666;\n        user-select: none;\n      }\n\n      .pendingOn {\n        background-color: #1890ff;\n        color: #fff;\n      }\n\n      .pending {\n        color: #1890ff;\n      }\n\n      .pending:hover {\n        background-color: #1890ff;\n        opacity: 0.7;\n        color: #fff;\n      }\n\n      .successOn {\n        background-color: #52c41a;\n        color: #fff;\n      }\n\n      .success {\n        color: #52c41a;\n      }\n\n      .success:hover {\n        background-color: #52c41a;\n        opacity: 0.7;\n        color: #fff;\n      }\n\n      .failOn {\n        background-color: red;\n        color: #fff;\n      }\n\n      .fail {\n        color: red;\n      }\n\n      .fail:hover {\n        background-color: red;\n        opacity: 0.6;\n        color: #fff;\n      }\n    }\n\n    .list {\n      flex: 1;\n      overflow: auto;\n      padding-bottom: 8px;\n\n      .item {\n        display: flex;\n        align-items: center;\n        padding: 8px 24px;\n        cursor: pointer;\n\n        &.active {\n          background: #e6f7ff;\n        }\n\n        :global(.anticon) {\n          margin-right: 4px;\n        }\n\n        .text {\n          white-space: nowrap;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          user-select: none;\n        }\n      }\n\n      .item:hover {\n        background: #e6f7ff;\n      }\n    }\n  }\n\n  .body {\n    display: flex;\n    flex-direction: column;\n    width: calc(100% - 300px);\n    padding: 22px;\n\n    .header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 12px;\n\n      .icon {\n        font-size: 18px;\n        color: #1890ff;\n        cursor: pointer;\n      }\n\n      .title {\n        font-weight: 500;\n      }\n    }\n\n\n    .termContainer {\n      background-color: #2b2b2b;\n      padding: 8px 0 4px 12px;\n      border-radius: 4px;\n\n      .term {\n        width: 100%;\n        height: calc(100vh - 300px);\n      }\n    }\n  }\n}"
  },
  {
    "path": "spug_web/src/pages/exec/transfer/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from \"mobx\";\n\nclass Store {\n  @observable outputs = {};\n  @observable tag = '';\n\n  @computed get items() {\n    const items = Object.entries(this.outputs)\n    if (this.tag === '') {\n      return items\n    } else if (this.tag === '0') {\n      return items.filter(([_, x]) => x.status === -2)\n    } else if (this.tag === '1') {\n      return items.filter(([_, x]) => x.status === 0)\n    } else {\n      return items.filter(([_, x]) => ![-2, 0].includes(x.status))\n    }\n  }\n\n  @computed get counter() {\n    const counter = {'0': 0, '1': 0, '2': 0}\n    for (let item of Object.values(this.outputs)) {\n      if (item.status === -2) {\n        counter['0'] += 1\n      } else if (item.status === 0) {\n        counter['1'] += 1\n      } else {\n        counter['2'] += 1\n      }\n    }\n    return counter\n  }\n\n  updateTag = (tag) => {\n    if (tag === this.tag) {\n      this.tag = ''\n    } else {\n      this.tag = tag\n    }\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/home/Nav.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Avatar, Card, Col, Row, Modal } from 'antd';\nimport { LeftSquareOutlined, RightSquareOutlined, EditOutlined, PlusOutlined, CloseOutlined } from '@ant-design/icons';\nimport { AuthButton } from 'components';\nimport NavForm from './NavForm';\nimport { http } from 'libs';\nimport styles from './index.module.less';\n\nfunction NavIndex(props) {\n  const [isEdit, setIsEdit] = useState(false);\n  const [records, setRecords] = useState([]);\n  const [record, setRecord] = useState();\n\n  useEffect(() => {\n    fetchRecords()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function fetchRecords() {\n    http.get('/api/home/navigation/')\n      .then(res => setRecords(res))\n  }\n\n  function handleSubmit() {\n    fetchRecords();\n    setRecord(null)\n  }\n\n  function handleSort(info, sort) {\n    http.patch('/api/home/navigation/', {id: info.id, sort})\n      .then(() => fetchRecords())\n  }\n\n  function handleDelete(item) {\n    Modal.confirm({\n      title: '操作确认',\n      content: `确定要删除【${item.title}】？`,\n      onOk: () => http.delete('/api/home/navigation/', {params: {id: item.id}})\n        .then(fetchRecords)\n    })\n  }\n\n  return (\n    <Card\n      title=\"便捷导航\"\n      className={styles.nav}\n      bodyStyle={{paddingBottom: 0, minHeight: 166}}\n      extra={<AuthButton auth=\"admin\" type=\"link\"\n                         onClick={() => setIsEdit(!isEdit)}>{isEdit ? '完成' : '编辑'}</AuthButton>}>\n      {isEdit ? (\n        <Row gutter={24}>\n          <Col span={6} style={{marginBottom: 24}}>\n            <div\n              className={styles.add}\n              onClick={() => setRecord({links: [{}]})}>\n              <PlusOutlined/>\n              <span>新建</span>\n            </div>\n          </Col>\n          {records.map(item => (\n            <Col key={item.id} span={6} style={{marginBottom: 24}}>\n              <Card hoverable actions={[\n                <LeftSquareOutlined onClick={() => handleSort(item, 'up')}/>,\n                <RightSquareOutlined onClick={() => handleSort(item, 'down')}/>,\n                <EditOutlined onClick={() => setRecord(item)}/>\n              ]}>\n                <Card.Meta\n                  avatar={<Avatar src={item.logo}/>}\n                  title={item.title}\n                  description={item.desc}/>\n                <CloseOutlined className={styles.icon} onClick={() => handleDelete(item)}/>\n              </Card>\n            </Col>\n          ))}\n        </Row>\n      ) : (\n        <Row gutter={24}>\n          {records.map(item => (\n            <Col key={item.id} span={6} style={{marginBottom: 24}}>\n              <Card\n                hoverable\n                actions={item.links.map(x => <a href={x.url} rel=\"noopener noreferrer\" target=\"_blank\">{x.name}</a>)}>\n                <Card.Meta\n                  avatar={<Avatar size=\"large\" src={item.logo}/>}\n                  title={item.title}\n                  description={item.desc}/>\n              </Card>\n            </Col>\n          ))}\n        </Row>\n      )}\n      {record ? <NavForm record={record} onCancel={() => setRecord(null)} onOk={handleSubmit}/> : null}\n    </Card>\n  )\n}\n\nexport default NavIndex"
  },
  {
    "path": "spug_web/src/pages/home/NavForm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Form, Input, Modal, Button, Upload, Avatar, message } from 'antd';\nimport { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';\nimport { http } from 'libs';\nimport styles from './index.module.less';\nimport lds from 'lodash';\n\nfunction NavForm(props) {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [record, setRecord] = useState(props.record);\n  const [fileList, setFileList] = useState([]);\n\n  useEffect(() => {\n    if (props.record.logo) {\n      setFileList([{uid: 0, thumbUrl: props.record.logo}])\n    }\n  }, [props.record])\n\n  function handleSubmit() {\n    const formData = form.getFieldsValue();\n    const links = record.links.filter(x => x.name && x.url);\n    if (links.length === 0) return message.error('请设置至少一条导航链接');\n    if (fileList.length === 0) return message.error('请上传导航logo');\n    formData.id = record.id;\n    formData.links = links;\n    formData.logo = fileList[0].thumbUrl;\n    setLoading(true);\n    http.post('/api/home/navigation/', formData)\n      .then(() => {\n        props.onOk();\n      }, () => setLoading(false))\n  }\n\n  function add() {\n    record.links.push({});\n    setRecord(lds.cloneDeep(record))\n  }\n\n  function remove(index) {\n    record.links.splice(index, 1);\n    setRecord(lds.cloneDeep(record))\n  }\n\n  function changeLink(e, index, key) {\n    record.links[index][key] = e.target.value;\n    setRecord(lds.cloneDeep(record))\n  }\n\n  function beforeUpload(file) {\n    if (file.size / 1024 > 100) {\n      message.error('图片将直接存储至数据库，请上传小于100KB的图片');\n      setTimeout(() => setFileList([]))\n    }\n    return false\n  }\n\n  return (\n    <Modal\n      visible\n      title={`${record.id ? '编辑' : '新建'}链接`}\n      onCancel={props.onCancel}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={record} labelCol={{span: 5}} wrapperCol={{span: 18}}>\n        <Form.Item required label=\"导航图标\">\n          <Upload\n            accept=\"image/*\"\n            listType=\"picture-card\"\n            fileList={fileList}\n            beforeUpload={beforeUpload}\n            showUploadList={{showPreviewIcon: false}}\n            onChange={({fileList}) => setFileList(fileList)}>\n            {fileList.length === 0 && (\n              <div>\n                <PlusOutlined/>\n                <div style={{marginTop: 8}}>点击上传</div>\n              </div>\n            )}\n          </Upload>\n          <div className={styles.imgExample}>\n            {['gitlab', 'gitee', 'grafana', 'prometheus', 'wiki'].map(item => (\n              <Avatar\n                key={item}\n                src={`/resource/${item}.png`}\n                onClick={() => setFileList([{uid: 0, thumbUrl: `/resource/${item}.png`}])}/>\n            ))}\n          </div>\n        </Form.Item>\n        <Form.Item required name=\"title\" label=\"导航标题\">\n          <Input placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item required name=\"desc\" label=\"导航描述\">\n          <Input placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item required label=\"导航链接\" style={{marginBottom: 0}}>\n          {record.links.map((item, index) => (\n            <div key={index} style={{display: 'flex', alignItems: 'center', marginBottom: 12}}>\n              <Form.Item style={{display: 'inline-block', margin: 0, width: 100}}>\n                <Input value={item.name} onChange={e => changeLink(e, index, 'name')} placeholder=\"链接名称\"/>\n              </Form.Item>\n              <Form.Item style={{display: 'inline-block', width: 210, margin: '0 8px'}}>\n                <Input value={item.url} onChange={e => changeLink(e, index, 'url')} placeholder=\"请输入链接地址\"/>\n              </Form.Item>\n              {record.links.length > 1 && (\n                <MinusCircleOutlined className={styles.minusIcon} onClick={() => remove(index)}/>\n              )}\n            </div>\n          ))}\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 18, offset: 5}}>\n          <Button type=\"dashed\" onClick={add} style={{width: 318}} icon={<PlusOutlined/>}>\n            添加链接（推荐最多三个）\n          </Button>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n}\n\nexport default NavForm\n"
  },
  {
    "path": "spug_web/src/pages/home/Notice.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { Card, List, Modal, Form, Input, Switch, Divider, Typography } from 'antd';\nimport { DownSquareOutlined, PlusOutlined, UpSquareOutlined, SoundOutlined, DeleteOutlined } from '@ant-design/icons';\nimport { AuthButton } from 'components';\nimport { http } from 'libs';\nimport styles from './index.module.less';\n\nfunction NoticeIndex(props) {\n  const id = localStorage.getItem('id');\n  const [form] = Form.useForm();\n  const [fetching, setFetching] = useState(true);\n  const [loading, setLoading] = useState(false);\n  const [isEdit, setIsEdit] = useState(false);\n  const [records, setRecords] = useState([]);\n  const [record, setRecord] = useState();\n  const [notice, setNotice] = useState();\n\n  useEffect(() => {\n    fetchRecords()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function fetchRecords() {\n    setFetching(true);\n    http.get('/api/home/notice/')\n      .then(res => {\n        setRecords(res);\n        for (let item of res) {\n          if (item.is_stress && !item.read_ids.includes(id)) {\n            setNotice(item)\n          }\n        }\n      })\n      .finally(() => setFetching(false))\n  }\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = record.id;\n    http.post('/api/home/notice/', formData)\n      .then(() => {\n        fetchRecords()\n        setRecord(null)\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function showForm(info) {\n    setRecord(info);\n    setTimeout(() => form.resetFields())\n  }\n\n  function handleSort(e, info, sort) {\n    e.stopPropagation();\n    http.patch('/api/home/notice/', {id: info.id, sort})\n      .then(() => fetchRecords())\n  }\n\n  function handleRead() {\n    if (!notice.read_ids.includes(id)) {\n      const formData = {id: notice.id, read: 1};\n      http.patch('/api/home/notice/', formData)\n        .then(() => fetchRecords())\n    }\n    setNotice(null);\n  }\n\n  function handleDelete(item) {\n    Modal.confirm({\n      title: '操作确认',\n      content: `确定要删除系统公告【${item.title}】？`,\n      onOk: () => http.delete('/api/home/notice/', {params: {id: item.id}})\n        .then(fetchRecords)\n    })\n  }\n\n  return (\n    <Card\n      title=\"系统公告\"\n      loading={fetching}\n      className={styles.notice}\n      extra={<AuthButton auth=\"admin\" type=\"link\"\n                         onClick={() => setIsEdit(!isEdit)}>{isEdit ? '完成' : '编辑'}</AuthButton>}>\n      {isEdit ? (\n        <List>\n          <div className={styles.add} onClick={() => showForm({})}><PlusOutlined/>新建公告</div>\n          {records.map(item => (\n            <List.Item key={item.id}>\n              <div className={styles.item}>\n                <UpSquareOutlined onClick={e => handleSort(e, item, 'up')}/>\n                <Divider type=\"vertical\"/>\n                <DownSquareOutlined onClick={e => handleSort(e, item, 'down')}/>\n                <div className={styles.title} style={{marginLeft: 24}} onClick={() => showForm(item)}>{item.title}</div>\n                <DeleteOutlined style={{color: 'red', marginLeft: 12}} onClick={() => handleDelete(item)}/>\n              </div>\n            </List.Item>\n          ))}\n        </List>\n      ) : (\n        <List>\n          {records.map(item => (\n            <List.Item key={item.id} className={styles.item} onClick={() => setNotice(item)}>\n              {!item.read_ids.includes(id) && <SoundOutlined style={{color: '#ff4d4f', marginRight: 4}}/>}\n              <span className={styles.title}>{item.title}</span>\n              <span className={styles.date}>{item.created_at.substr(0, 10)}</span>\n            </List.Item>\n          ))}\n          {records.length === 0 && (\n            <div style={{marginTop: 12, color: '#999'}}>暂无公告信息</div>\n          )}\n        </List>\n      )}\n      <Modal\n        title=\"编辑公告\"\n        visible={record}\n        onCancel={() => setRecord(null)}\n        confirmLoading={loading}\n        onOk={handleSubmit}>\n        <Form form={form} initialValues={record} labelCol={{span: 5}} wrapperCol={{span: 18}}>\n          <Form.Item name=\"is_stress\" valuePropName=\"checked\" tooltip=\"自动弹窗强提醒，仅能设置一条公告。\" label=\"弹窗提醒\">\n            <Switch checkedChildren=\"开启\" unCheckedChildren=\"关闭\"/>\n          </Form.Item>\n          <Form.Item required name=\"title\" label=\"公告标题\">\n            <Input placeholder=\"请输入\"/>\n          </Form.Item>\n          <Form.Item required name=\"content\" tooltip=\"\" label=\"公告内容\">\n            <Input.TextArea placeholder=\"请输入\"/>\n          </Form.Item>\n        </Form>\n      </Modal>\n      {notice ? (\n        <Modal title={notice.title} visible={notice} footer={null} onCancel={handleRead}>\n          <Typography>\n            {notice.content.split('\\n').map((item, index) => (\n              <Typography.Paragraph key={index}>{item}</Typography.Paragraph>\n            ))}\n          </Typography>\n        </Modal>\n      ) : null}\n    </Card>\n  )\n}\n\nexport default NoticeIndex"
  },
  {
    "path": "spug_web/src/pages/home/Todo.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Card, List } from 'antd';\n\nfunction TodoIndex(props) {\n  return (\n    <Card title=\"待办事项\" bodyStyle={{height: 234, padding: '0 24px'}}>\n      <List/>\n    </Card>\n  )\n}\n\nexport default TodoIndex"
  },
  {
    "path": "spug_web/src/pages/home/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Row, Col } from 'antd';\nimport { Breadcrumb } from 'components';\nimport NoticeIndex from './Notice';\nimport TodoIndex from './Todo';\nimport NavIndex from './Nav';\n\nfunction HomeIndex() {\n  return (\n    <div>\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>工作台</Breadcrumb.Item>\n      </Breadcrumb>\n      <Row gutter={12}>\n        <Col span={16}>\n          <TodoIndex/>\n        </Col>\n        <Col span={8}>\n          <NoticeIndex/>\n        </Col>\n      </Row>\n      <NavIndex/>\n    </div>\n  )\n}\n\nexport default HomeIndex"
  },
  {
    "path": "spug_web/src/pages/home/index.module.less",
    "content": ".notice {\n  :global(.ant-card-body) {\n    height: 234px;\n    padding: 0 24px;\n    overflow: auto;\n  }\n\n  button {\n    padding-right: 0;\n  }\n\n  .title {\n    flex: 1;\n    cursor: pointer;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .title:hover {\n    color: #1890ff\n  }\n\n  .badge {\n    overflow: hidden;\n  }\n\n  .date {\n    display: inline-block;\n    font-size: 12px;\n    color: #999;\n  }\n\n  .add {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    margin-top: 8px;\n    height: 35px;\n    border-radius: 2px;\n    border: 1px dashed #d9d9d9;\n    font-size: 12px;\n    cursor: pointer;\n  }\n\n  .add:hover {\n    border: 1px dashed #1890ff;\n    color: #1890ff;\n  }\n\n  .item {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    display: flex;\n    width: 100%;\n    align-items: center;\n\n    :global(.anticon) {\n      cursor: pointer;\n      color: #1890ff;\n    }\n  }\n}\n\n.nav {\n  margin-top: 12px;\n\n  button {\n    padding-right: 0;\n  }\n\n  .add {\n    cursor: pointer;\n    height: 167px;\n    border: 1px dashed #d9d9d9;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n  }\n\n  .add:hover {\n    border: 1px dashed #1890ff;\n    color: #1890ff;\n  }\n\n  :global(.ant-card) {\n    height: 167px;\n    background-color: #fdfdfd;\n\n    :global(.ant-card-actions) {\n      background-color: #fafafa;\n    }\n\n    :global(.ant-card-meta-description) {\n      height: 44px;\n      display: -webkit-box;\n      text-overflow: ellipsis;\n      overflow: hidden;\n      -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical;\n    }\n  }\n\n  .icon {\n    position: absolute;\n    top: 4px;\n    right: 4px;\n    width: 32px;\n    height: 32px;\n    padding: 8px;\n    font-size: 16px;\n    color: rgba(0, 0, 0, .45);\n    cursor: pointer;\n  }\n\n  .icon:hover {\n    color: #ff4d4f;\n  }\n}\n\n.minusIcon {\n  font-size: 26px;\n  color: #a6a6a6;\n}\n\n.minusIcon:hover {\n  color: #ff4d4f;\n}\n\n.imgExample {\n  position: absolute;\n  top: 62px;\n  left: 130px;\n\n  :global(.ant-avatar) {\n    cursor: pointer;\n    margin-right: 12px;\n  }\n}"
  },
  {
    "path": "spug_web/src/pages/host/BatchSync.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Button, Radio } from 'antd';\nimport Sync from './Sync';\nimport { http } from 'libs';\nimport store from './store';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [password, setPassword] = useState();\n  const [range, setRange] = useState('2');\n  const [hosts, setHosts] = useState();\n  const [token, setToken] = useState();\n\n  function handleSubmit() {\n    setLoading(true);\n    http.post('/api/host/valid/', {password, range})\n      .then(res => {\n        setHosts(res.hosts);\n        setToken(res.token);\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function handleClose() {\n    store.showSync();\n    store.fetchRecords()\n  }\n\n  const unVerifiedLength = store.records.filter(x => !x.is_verified).length;\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title=\"批量验证（同步）\"\n      okText=\"导入\"\n      onCancel={handleClose}\n      footer={null}>\n      <Form hidden={token} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item name=\"password\" label=\"默认密码\" tooltip=\"会被用于未验证主机的验证。\">\n          <Input.Password value={password} onChange={e => setPassword(e.target.value)}/>\n        </Form.Item>\n        <Form.Item label=\"选择主机\" tooltip=\"要批量验证/同步哪些主机，全部主机或仅未验证主机。\" extra=\"将会覆盖已有的扩展信息（CPU、内存、磁盘等）。\">\n          <Radio.Group\n            value={range}\n            onChange={e => setRange(e.target.value)}\n            options={[\n              {label: `全部（${store.records.length}）`, value: '1'},\n              {label: `未验证（${unVerifiedLength}）`, value: '2'}\n            ]}\n            optionType=\"button\"/>\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 14, offset: 6}}>\n          <Button loading={loading} type=\"primary\" onClick={handleSubmit}>提交验证</Button>\n        </Form.Item>\n      </Form>\n\n      {token && hosts ? (\n        <Sync token={token} hosts={hosts}/>\n      ) : null}\n    </Modal>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/CloudImport.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, Select, Button, Steps, Cascader, Radio, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\nimport styles from './index.module.less';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [step, setStep] = useState(0);\n  const [ak, setAK] = useState();\n  const [ac, setAC] = useState();\n  const [regionId, setRegionId] = useState();\n  const [groupId, setGroupId] = useState([]);\n  const [regions, setRegions] = useState([]);\n  const [username, setUsername] = useState('root');\n  const [port, setPort] = useState('22');\n  const [host_type, setHostType] = useState('private');\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = {\n      ak,\n      ac,\n      type: store.cloudImport,\n      region_id: regionId,\n      group_id: groupId[groupId.length - 1],\n      username,\n      port,\n      host_type\n    };\n    http.post('/api/host/import/cloud/', formData, {timeout: 120000})\n      .then(res => {\n        message.success(`已同步/导入 ${res} 台主机`);\n        store.cloudImport = null;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function fetchRegions() {\n    setLoading(true);\n    http.get('/api/host/import/region/', {params: {ak, ac, type: store.cloudImport}})\n      .then(res => {\n        setRegions(res)\n        setStep(1)\n      })\n      .finally(() => setLoading(false))\n  }\n\n  const helpUrl = store.cloudImport === 'ali' ? 'https://help.aliyun.com/document_detail/175967.html' : 'https://console.cloud.tencent.com/capi';\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title=\"批量导入\"\n      footer={null}\n      onCancel={() => store.cloudImport = null}>\n      <Steps current={step} className={styles.steps}>\n        <Steps.Step key={0} title=\"访问凭据\"/>\n        <Steps.Step key={1} title=\"导入确认\"/>\n      </Steps>\n      <Form labelCol={{span: 8}} wrapperCol={{span: 14}}>\n        <Form.Item hidden={step === 1} required label=\"AccessKey ID\">\n          <Input value={ak} onChange={e => setAK(e.target.value)} placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item hidden={step === 1} required label=\"AccessKey Secret\" extra={(\n          <a href={helpUrl} target=\"_blank\" rel=\"noopener noreferrer\">如何获取AccessKey ？</a>\n        )}>\n          <Input value={ac} onChange={e => setAC(e.target.value)} placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item hidden={step === 0} required label=\"选择区域\" tooltip=\"选择导入指定区域的主机。\">\n          <Select placeholder=\"请选择\" value={regionId} onChange={setRegionId}>\n            {regions.map(item => (\n              <Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item hidden={step === 0} required label=\"选择分组\" tooltip=\"将主机导入指定分组。\">\n          <Cascader\n            value={groupId}\n            onChange={setGroupId}\n            options={store.treeData}\n            fieldNames={{label: 'title'}}\n            placeholder=\"请选择\"/>\n        </Form.Item>\n        <Form.Item hidden={step === 0} label=\"基础信息\" tooltip=\"以下信息用于进行SSH验证，导入完成后通过点击批量验证按钮进行批量验证并同步主机扩展信息。\"/>\n        <Form.Item hidden={step === 0} labelCol={{span: 10}} wrapperCol={{span: 12}} label=\"用户名\">\n          <Input value={username} onChange={e => setUsername(e.target.value)} placeholder=\"默认SSH登录的账户名\"/>\n        </Form.Item>\n        <Form.Item hidden={step === 0} labelCol={{span: 10}} wrapperCol={{span: 12}} label=\"端口号\">\n          <Input value={port} onChange={e => setPort(e.target.value)} placeholder=\"默认SSH端口号\"/>\n        </Form.Item>\n        <Form.Item hidden={step === 0} labelCol={{span: 10}} wrapperCol={{span: 12}} label=\"连接地址\"\n                   extra=\"将根据选择进行自动匹配获取。\">\n          <Radio.Group value={host_type} onChange={e => setHostType(e.target.value)}>\n            <Radio value=\"public\">公网地址</Radio>\n            <Radio value=\"private\">私网地址</Radio>\n          </Radio.Group>\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 14, offset: 8}}>\n          {step === 0 ? (\n            <Button type=\"primary\" loading={loading} disabled={!ak || !ac} onClick={fetchRegions}>下一步</Button>\n          ) : ([\n            <Button\n              key=\"1\"\n              type=\"primary\"\n              loading={loading}\n              disabled={!regionId || !groupId}\n              onClick={handleSubmit}>同步导入</Button>,\n            <Button key=\"2\" style={{marginLeft: 24}} onClick={() => setStep(0)}>上一步</Button>\n          ])}\n        </Form.Item>\n      </Form>\n    </Modal>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/Detail.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect, useRef } from 'react';\nimport { observer } from 'mobx-react';\nimport { Drawer, Descriptions, List, Button, Input, Select, DatePicker, Tag, message } from 'antd';\nimport { EditOutlined, SaveOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';\nimport { AuthButton } from 'components';\nimport { http } from 'libs';\nimport store from './store';\nimport lds from 'lodash';\nimport moment from 'moment';\nimport styles from './index.module.less';\n\nexport default observer(function () {\n  const [edit, setEdit] = useState(false);\n  const [host, setHost] = useState(store.record);\n  const diskInput = useRef();\n  const sipInput = useRef();\n  const gipInput = useRef();\n  const [tag, setTag] = useState();\n  const [inputVisible, setInputVisible] = useState(null);\n  const [loading, setLoading] = useState(false);\n  const [fetching, setFetching] = useState(false);\n\n  useEffect(() => {\n    if (store.detailVisible) {\n      setHost(lds.cloneDeep(store.record))\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [store.detailVisible])\n\n  useEffect(() => {\n    if (inputVisible === 'disk') {\n      diskInput.current.focus()\n    } else if (inputVisible === 'sip') {\n      sipInput.current.focus()\n    } else if (inputVisible === 'gip') {\n      gipInput.current.focus()\n    }\n  }, [inputVisible])\n\n  function handleSubmit() {\n    setLoading(true)\n    if (host.created_time) host.created_time = moment(host.created_time).format('YYYY-MM-DD')\n    if (host.expired_time) host.expired_time = moment(host.expired_time).format('YYYY-MM-DD')\n    http.post('/api/host/extend/', {host_id: host.id, ...host})\n      .then(res => {\n        Object.assign(host, res);\n        setEdit(false);\n        setHost(lds.cloneDeep(host));\n        store.fetchRecords()\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function handleFetch() {\n    setFetching(true);\n    http.get('/api/host/extend/', {params: {host_id: host.id}})\n      .then(res => {\n        Object.assign(host, res);\n        setHost(lds.cloneDeep(host));\n        message.success('同步成功')\n      })\n      .finally(() => setFetching(false))\n  }\n\n  function handleChange(e, key) {\n    host[key] = e && e.target ? e.target.value : e;\n    if (['created_time', 'expired_time'].includes(key) && e) {\n      host[key] = e.format('YYYY-MM-DD')\n    }\n    setHost({...host})\n  }\n\n  function handleClose() {\n    store.detailVisible = false;\n    setEdit(false)\n  }\n\n  function handleTagConfirm(key) {\n    if (tag) {\n      if (key === 'disk') {\n        const value = Number(tag);\n        if (lds.isNaN(value)) return message.error('请输入数字');\n        host.disk ? host.disk.push(value) : host.disk = [value]\n      } else if (key === 'sip') {\n        host.private_ip_address ? host.private_ip_address.push(tag) : host.private_ip_address = [tag]\n      } else if (key === 'gip') {\n        host.public_ip_address ? host.public_ip_address.push(tag) : host.public_ip_address = [tag]\n      }\n      setHost(lds.cloneDeep(host))\n    }\n    setTag(undefined);\n    setInputVisible(false)\n  }\n\n  function handleTagRemove(key, index) {\n    if (key === 'disk') {\n      host.disk.splice(index, 1)\n    } else if (key === 'sip') {\n      host.private_ip_address.splice(index, 1)\n    } else if (key === 'gip') {\n      host.public_ip_address.splice(index, 1)\n    }\n    setHost(lds.cloneDeep(host))\n  }\n\n  return (\n    <Drawer\n      width={550}\n      title={host.name}\n      placement=\"right\"\n      onClose={handleClose}\n      visible={store.detailVisible}>\n      <Descriptions\n        bordered\n        size=\"small\"\n        labelStyle={{width: 150}}\n        title={<span style={{fontWeight: 500}}>基本信息</span>}\n        column={1}>\n        <Descriptions.Item label=\"主机名称\">{host.name}</Descriptions.Item>\n        <Descriptions.Item label=\"连接地址\">{host.username}@{host.hostname}</Descriptions.Item>\n        <Descriptions.Item label=\"连接端口\">{host.port}</Descriptions.Item>\n        <Descriptions.Item label=\"独立密钥\">{host.pkey ? '是' : '否'}</Descriptions.Item>\n        <Descriptions.Item label=\"描述信息\">{host.desc}</Descriptions.Item>\n        <Descriptions.Item label=\"所属分组\">\n          <List>\n            {lds.get(host, 'group_ids', []).map(g_id => (\n              <List.Item key={g_id} style={{padding: '6px 0'}}>{store.groups[g_id]}</List.Item>\n            ))}\n          </List>\n        </Descriptions.Item>\n      </Descriptions>\n      <Descriptions\n        bordered\n        size=\"small\"\n        column={1}\n        className={edit ? styles.hostExtendEdit : null}\n        labelStyle={{width: 150}}\n        style={{marginTop: 24}}\n        extra={edit ? ([\n          <Button key=\"1\" type=\"link\" loading={fetching} icon={<SyncOutlined/>} onClick={handleFetch}>同步</Button>,\n          <Button key=\"2\" type=\"link\" loading={loading} icon={<SaveOutlined/>} onClick={handleSubmit}>保存</Button>\n        ]) : (\n          <AuthButton auth=\"host.host.edit\" type=\"link\" icon={<EditOutlined/>} onClick={() => setEdit(true)}>编辑</AuthButton>\n        )}\n        title={<span style={{fontWeight: 500}}>扩展信息</span>}>\n        <Descriptions.Item label=\"实例ID\">\n          {edit ? (\n            <Input value={host.instance_id} onChange={e => handleChange(e, 'instance_id')} placeholder=\"选填\"/>\n          ) : host.instance_id}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"操作系统\">\n          {edit ? (\n            <Input value={host.os_name} onChange={e => handleChange(e, 'os_name')}\n                   placeholder=\"例如：Ubuntu Server 16.04.1 LTS\"/>\n          ) : host.os_name}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"CPU\">\n          {edit ? (\n            <Input suffix=\"核\" style={{width: 100}} value={host.cpu} onChange={e => handleChange(e, 'cpu')}\n                   placeholder=\"数字\"/>\n          ) : host.cpu ? `${host.cpu}核` : null}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"内存\">\n          {edit ? (\n            <Input suffix=\"GB\" style={{width: 100}} value={host.memory} onChange={e => handleChange(e, 'memory')}\n                   placeholder=\"数字\"/>\n          ) : host.memory ? `${host.memory}GB` : null}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"磁盘\">\n          {lds.get(host, 'disk', []).map((item, index) => (\n            <Tag visible closable={edit} key={index} onClose={() => handleTagRemove('disk', index)}>{item}GB</Tag>\n          ))}\n          {edit && (inputVisible === 'disk' ? (\n            <Input\n              ref={diskInput}\n              type=\"text\"\n              size=\"small\"\n              value={tag}\n              className={styles.tagNumberInput}\n              onChange={e => setTag(e.target.value)}\n              onBlur={() => handleTagConfirm('disk')}\n              onPressEnter={() => handleTagConfirm('disk')}\n            />\n          ) : (\n            <Tag className={styles.tagAdd} onClick={() => setInputVisible('disk')}><PlusOutlined/> 新建</Tag>\n          ))}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"内网IP\">\n          {lds.get(host, 'private_ip_address', []).map((item, index) => (\n            <Tag visible closable={edit} key={index} onClose={() => handleTagRemove('sip', index)}>{item}</Tag>\n          ))}\n          {edit && (inputVisible === 'sip' ? (\n            <Input\n              ref={sipInput}\n              type=\"text\"\n              size=\"small\"\n              value={tag}\n              className={styles.tagInput}\n              onChange={e => setTag(e.target.value)}\n              onBlur={() => handleTagConfirm('sip')}\n              onPressEnter={() => handleTagConfirm('sip')}\n            />\n          ) : (\n            <Tag className={styles.tagAdd} onClick={() => setInputVisible('sip')}><PlusOutlined/> 新建</Tag>\n          ))}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"公网IP\">\n          {lds.get(host, 'public_ip_address', []).map((item, index) => (\n            <Tag visible closable={edit} key={index} onClose={() => handleTagRemove('gip', index)}>{item}</Tag>\n          ))}\n          {edit && (inputVisible === 'gip' ? (\n            <Input\n              ref={gipInput}\n              type=\"text\"\n              size=\"small\"\n              value={tag}\n              className={styles.tagInput}\n              onChange={e => setTag(e.target.value)}\n              onBlur={() => handleTagConfirm('gip')}\n              onPressEnter={() => handleTagConfirm('gip')}\n            />\n          ) : (\n            <Tag className={styles.tagAdd} onClick={() => setInputVisible('gip')}><PlusOutlined/> 新建</Tag>\n          ))}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"实例计费方式\">\n          {edit ? (\n            <Select\n              style={{width: 150}}\n              value={host.instance_charge_type}\n              placeholder=\"请选择\"\n              onChange={v => handleChange(v, 'instance_charge_type')}>\n              <Select.Option value=\"PrePaid\">包年包月</Select.Option>\n              <Select.Option value=\"PostPaid\">按量计费</Select.Option>\n              <Select.Option value=\"Other\">其他</Select.Option>\n            </Select>\n          ) : host.instance_charge_type_alias}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"网络计费方式\">\n          {edit ? (\n            <Select\n              style={{width: 150}}\n              value={host.internet_charge_type}\n              placeholder=\"请选择\"\n              onChange={v => handleChange(v, 'internet_charge_type')}>\n              <Select.Option value=\"PayByBandwidth\">按带宽计费</Select.Option>\n              <Select.Option value=\"PayByTraffic\">按流量计费</Select.Option>\n              <Select.Option value=\"Other\">其他</Select.Option>\n            </Select>\n          ) : host.internet_charge_type_alisa}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"创建时间\">\n          {edit ? (\n            <DatePicker\n              value={host.created_time ? moment(host.created_time) : undefined}\n              onChange={v => handleChange(v, 'created_time')}/>\n          ) : host.created_time}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"到期时间\">\n          {edit ? (\n            <DatePicker\n              value={host.expired_time ? moment(host.expired_time) : undefined}\n              onChange={v => handleChange(v, 'expired_time')}/>\n          ) : host.expired_time}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"更新时间\">{host.updated_at}</Descriptions.Item>\n      </Descriptions>\n    </Drawer>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/host/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { ExclamationCircleOutlined, UploadOutlined } from '@ant-design/icons';\nimport { Modal, Form, Input, TreeSelect, Button, Upload, Alert, message } from 'antd';\nimport { http, X_TOKEN } from 'libs';\nimport store from './store';\nimport styles from './index.module.less';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [uploading, setUploading] = useState(false);\n  const [fileList, setFileList] = useState([]);\n\n  useEffect(() => {\n    if (store.record.pkey) {\n      setFileList([{uid: '0', name: '独立密钥', data: store.record.pkey}])\n    }\n  }, [])\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    const file = fileList[0];\n    if (file && file.data) formData['pkey'] = file.data;\n    http.post('/api/host/', formData)\n      .then(res => {\n        if (res === 'auth fail') {\n          setLoading(false)\n          if (formData.pkey) {\n            message.error('独立密钥认证失败')\n          } else {\n            const onChange = v => formData.password = v;\n            Modal.confirm({\n              icon: <ExclamationCircleOutlined/>,\n              title: '首次验证请输入密码',\n              content: <ConfirmForm username={formData.username} onChange={onChange}/>,\n              onOk: () => handleConfirm(formData),\n            })\n          }\n        } else {\n          message.success('验证成功');\n          store.formVisible = false;\n          store.fetchRecords();\n          store.fetchExtend(res.id)\n        }\n      }, () => setLoading(false))\n  }\n\n  function handleConfirm(formData) {\n    if (formData.password) {\n      return http.post('/api/host/', formData)\n        .then(res => {\n          message.success('验证成功');\n          store.formVisible = false;\n          store.fetchRecords();\n          store.fetchExtend(res.id)\n        })\n    }\n    message.error('请输入授权密码')\n  }\n\n  const ConfirmForm = (props) => (\n    <Form layout=\"vertical\" style={{marginTop: 24}}>\n      <Form.Item required label=\"授权密码\" extra={`用户 ${props.username} 的密码， 该密码仅做首次验证使用，不会存储该密码。`}>\n        <Input.Password onChange={e => props.onChange(e.target.value)}/>\n      </Form.Item>\n    </Form>\n  )\n\n  function handleUploadChange(v) {\n    if (v.fileList.length === 0) {\n      setFileList([])\n    }\n  }\n\n  function handleUpload(file, fileList) {\n    setUploading(true);\n    const formData = new FormData();\n    formData.append('file', file);\n    http.post('/api/host/parse/', formData)\n      .then(res => {\n        file.data = res;\n        setFileList([file])\n      })\n      .finally(() => setUploading(false))\n    return false\n  }\n\n  const info = store.record;\n  return (\n    <Modal\n      visible\n      width={700}\n      maskClosable={false}\n      title={store.record.id ? '编辑主机' : '新建主机'}\n      okText=\"验证\"\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} labelCol={{span: 5}} wrapperCol={{span: 17}} initialValues={info}>\n        <Form.Item required name=\"group_ids\" label=\"主机分组\">\n          <TreeSelect\n            multiple\n            treeNodeLabelProp=\"name\"\n            treeData={store.treeData}\n            showCheckedStrategy={TreeSelect.SHOW_CHILD}\n            placeholder=\"请选择分组\"/>\n        </Form.Item>\n        <Form.Item required name=\"name\" label=\"主机名称\">\n          <Input placeholder=\"请输入主机名称\"/>\n        </Form.Item>\n        <Form.Item required label=\"连接地址\" style={{marginBottom: 0}}>\n          <Form.Item name=\"username\" className={styles.formAddress1} style={{width: 'calc(30%)'}}>\n            <Input addonBefore=\"ssh\" placeholder=\"用户名\"/>\n          </Form.Item>\n          <Form.Item name=\"hostname\" className={styles.formAddress2} style={{width: 'calc(40%)'}}>\n            <Input addonBefore=\"@\" placeholder=\"主机名/IP\"/>\n          </Form.Item>\n          <Form.Item name=\"port\" className={styles.formAddress3} style={{width: 'calc(30%)'}}>\n            <Input addonBefore=\"-p\" placeholder=\"端口\"/>\n          </Form.Item>\n        </Form.Item>\n        <Form.Item label=\"独立密钥\" extra=\"默认使用全局密钥，如果上传了独立密钥（私钥）则优先使用该密钥。\">\n          <Upload name=\"file\" fileList={fileList} headers={{'X-Token': X_TOKEN}} beforeUpload={handleUpload}\n                  onChange={handleUploadChange}>\n            {fileList.length === 0 ? <Button loading={uploading} icon={<UploadOutlined/>}>点击上传</Button> : null}\n          </Upload>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入主机备注信息\"/>\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 17, offset: 5}}>\n          <Alert showIcon type=\"info\" message=\"首次验证时需要输入登录用户名对应的密码，该密码会用于配置SSH密钥认证，不会存储该密码。\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/Group.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Input, Card, Tree, Dropdown, Menu, Switch, Tooltip, Spin, Modal } from 'antd';\nimport {\n  FolderOutlined,\n  FolderAddOutlined,\n  FolderOpenOutlined,\n  EditOutlined,\n  DeleteOutlined,\n  CopyOutlined,\n  CloseOutlined,\n  ScissorOutlined,\n  LoadingOutlined,\n  QuestionCircleOutlined\n} from '@ant-design/icons';\nimport { AuthFragment } from 'components';\nimport { hasPermission, http } from 'libs';\nimport styles from './index.module.less';\nimport store from './store';\nimport lds from 'lodash';\n\nexport default observer(function () {\n  const [isReady, setIsReady] = useState(false);\n  const [loading, setLoading] = useState();\n  const [visible, setVisible] = useState(false);\n  const [draggable, setDraggable] = useState(false);\n  const [action, setAction] = useState('');\n  const [expands, setExpands] = useState([]);\n  const [bakTreeData, setBakTreeData] = useState();\n\n  useEffect(() => {\n    if (loading === false) store.fetchGroups()\n  }, [loading])\n\n  useEffect(() => {\n    if (!isReady) {\n      const length = store.treeData.length\n      if (length > 0 && length < 5) {\n        const tmp = store.treeData.filter(x => x.children.length)\n        setExpands(tmp.map(x => x.key))\n        setIsReady(true)\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [store.treeData])\n\n  const menus = (\n    <Menu onClick={() => setVisible(false)}>\n      <Menu.Item key=\"0\" icon={<FolderOutlined/>} onClick={handleAddRoot}>新建根分组</Menu.Item>\n      <Menu.Item key=\"1\" icon={<FolderAddOutlined/>} onClick={handleAdd}>新建子分组</Menu.Item>\n      <Menu.Item key=\"2\" icon={<EditOutlined/>} onClick={() => setAction('edit')}>重命名</Menu.Item>\n      <Menu.Divider/>\n      <Menu.Item key=\"3\" icon={<CopyOutlined/>} onClick={() => store.showSelector(true)}>添加主机</Menu.Item>\n      <Menu.Item key=\"4\" icon={<ScissorOutlined/>} onClick={() => store.showSelector(false)}>移动主机</Menu.Item>\n      <Menu.Item key=\"5\" icon={<CloseOutlined/>} danger onClick={handleRemoveHosts}>删除主机</Menu.Item>\n      <Menu.Divider/>\n      <Menu.Item key=\"6\" icon={<DeleteOutlined/>} danger onClick={handleRemove}>删除此分组</Menu.Item>\n    </Menu>\n  )\n\n  function handleSubmit() {\n    if (store.group.title) {\n      setLoading(true);\n      const {key, parent_id, title} = store.group;\n      http.post('/api/host/group/', {id: key || undefined, parent_id, name: title})\n        .then(() => setAction(''))\n        .finally(() => setLoading(false))\n    } else {\n      if (store.group.key === 0) store.rawTreeData = bakTreeData\n      setAction('')\n    }\n  }\n\n  function handleRemoveHosts() {\n    const group = store.group;\n    Modal.confirm({\n      title: '操作确认',\n      content: `批量删除【${group.title}】分组内的 ${store.counter[group.key].size} 个主机？`,\n      onOk: () => http.delete('/api/host/', {params: {group_id: group.key}})\n        .then(store.fetchRecords)\n    })\n  }\n\n  function handleRemove() {\n    setAction('del');\n    setLoading(true);\n    http.delete('/api/host/group/', {params: {id: store.group.key}})\n      .finally(() => {\n        setAction('');\n        setLoading(false)\n      })\n  }\n\n  function handleAddRoot() {\n    setBakTreeData(lds.cloneDeep(store.rawTreeData));\n    const current = {key: 0, parent_id: 0, title: '', children: []};\n    store.rawTreeData.unshift(current);\n    store.rawTreeData = lds.cloneDeep(store.rawTreeData);\n    store.group = current;\n    setAction('edit')\n  }\n\n  function handleAdd() {\n    setBakTreeData(lds.cloneDeep(store.rawTreeData));\n    const current = {key: 0, parent_id: store.group.key, title: '', children: []};\n    const node = _find_node(store.rawTreeData, store.group.key)\n    node.children.unshift(current)\n    store.rawTreeData = lds.cloneDeep(store.rawTreeData);\n    if (!expands.includes(store.group.key)) setExpands([store.group.key, ...expands]);\n    store.group = current;\n    setAction('edit')\n  }\n\n  function _find_node(list, key) {\n    let node = lds.find(list, {key})\n    if (node) return node\n    for (let item of list) {\n      node = _find_node(item.children, key)\n      if (node) return node\n    }\n  }\n\n  function handleDrag(v) {\n    setLoading(true);\n    const pos = v.node.pos.split('-');\n    const dropPosition = v.dropPosition - Number(pos[pos.length - 1]);\n    http.patch('/api/host/group/', {s_id: v.dragNode.key, d_id: v.node.key, action: dropPosition})\n      .then(() => setLoading(false))\n  }\n\n  function handleRightClick(v) {\n    if (hasPermission('admin')) {\n      store.group = v.node;\n      setVisible(true)\n    }\n  }\n\n  function handleExpand(keys, {_, node}) {\n    if (node.children.length > 0) {\n      setExpands(keys)\n    }\n  }\n\n  function treeRender(nodeData) {\n    if (action === 'edit' && nodeData.key === store.group.key) {\n      return <Input\n        autoFocus\n        size=\"small\"\n        style={{width: 'calc(100% - 24px)'}}\n        defaultValue={nodeData.title}\n        placeholder=\"请输入\"\n        suffix={loading ? <LoadingOutlined/> : <span/>}\n        onClick={e => e.stopPropagation()}\n        onBlur={handleSubmit}\n        onChange={e => store.group.title = e.target.value}\n        onPressEnter={handleSubmit}/>\n    } else if (action === 'del' && nodeData.key === store.group.key) {\n      return <LoadingOutlined style={{marginLeft: '4px'}}/>\n    } else {\n      const length = store.counter[nodeData.key]?.size\n      return (\n        <div className={styles.treeNode}>\n          {expands.includes(nodeData.key) ? <FolderOpenOutlined/> : <FolderOutlined/>}\n          <div className={styles.title}>{nodeData.title}</div>\n          {length ? <div className={styles.number}>{length}</div> : null}\n        </div>\n      )\n    }\n  }\n\n  const treeData = store.treeData;\n  return (\n    <Card\n      title=\"分组列表\"\n      className={styles.group}\n      extra={(\n        <AuthFragment auth=\"admin\">\n          <Switch\n            checked={draggable}\n            onChange={setDraggable}\n            checkedChildren=\"排版\"\n            unCheckedChildren=\"浏览\"/>\n          <Tooltip title=\"排版模式下，可通过拖拽分组实现快速排序，右键点击分组进行分组管理。\">\n            <QuestionCircleOutlined style={{marginLeft: 8, color: '#999'}}/>\n          </Tooltip>\n        </AuthFragment>)}>\n      <Spin spinning={store.grpFetching}>\n        <Dropdown\n          overlay={menus}\n          visible={visible}\n          trigger={['contextMenu']}\n          onVisibleChange={v => v || setVisible(v)}>\n          <Tree.DirectoryTree\n            showIcon={false}\n            autoExpandParent\n            expandAction=\"doubleClick\"\n            draggable={draggable}\n            treeData={treeData}\n            titleRender={treeRender}\n            expandedKeys={expands}\n            selectedKeys={[store.group.key]}\n            onSelect={(_, {node}) => store.group = node}\n            onExpand={handleExpand}\n            onDrop={handleDrag}\n            onRightClick={handleRightClick}\n          />\n        </Dropdown>\n      </Spin>\n      {treeData.length === 1 && treeData[0].children.length === 0 && (\n        <div style={{color: '#999', marginTop: 20, textAlign: 'center'}}>右键点击分组进行分组管理哦~</div>\n      )}\n      {store.records && treeData.length === 0 && (\n        <div style={{color: '#999'}}>你还没有可访问的主机分组，请联系管理员分配主机权限。</div>\n      )}\n    </Card>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/IPAddress.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\n\nfunction IPAddress(props) {\n  const style = {\n    background: '#ffe7ba',\n    borderRadius: 4,\n    color: '#333',\n    fontSize: 10,\n    marginRight: 4,\n    padding: '0 8px'\n  }\n\n  const style2 = {\n    background: '#bae7ff',\n    borderRadius: 4,\n    color: '#333',\n    fontSize: 10,\n    marginRight: 4,\n    padding: '0 8px'\n  }\n  return (props.ip && props.ip.length > 0) ? (\n    <div style={{width: 150, display: 'flex', alignItems: 'center'}}>\n      {props.isPublic ? <span style={style}>公</span> : <span style={style2}>内</span>}\n      <span>{props.ip[0]}</span>\n    </div>\n  ) : null\n}\n\nexport default IPAddress\n"
  },
  {
    "path": "spug_web/src/pages/host/Import.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Upload, Button, Tooltip, Divider, Cascader, message } from 'antd';\nimport { UploadOutlined } from '@ant-design/icons';\nimport Sync from './Sync';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [fileList, setFileList] = useState([]);\n  const [groupId, setGroupId] = useState([]);\n  const [summary, setSummary] = useState({});\n  const [token, setToken] = useState();\n  const [hosts, setHosts] = useState();\n\n  function handleSubmit() {\n    if (groupId.length === 0) return message.error('请选择要导入的分组');\n    setLoading(true);\n    const formData = new FormData();\n    formData.append('file', fileList[0]);\n    formData.append('group_id', groupId[groupId.length - 1]);\n    http.post('/api/host/import/', formData, {timeout: 120000})\n      .then(res => {\n        setToken(res.token)\n        setHosts(res.hosts)\n        setSummary(res.summary)\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function handleUpload(v) {\n    if (v.fileList.length === 0) {\n      setFileList([])\n    } else {\n      setFileList([v.file])\n    }\n  }\n\n  function handleClose() {\n    store.importVisible = false;\n    store.fetchRecords()\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title=\"批量导入\"\n      okText=\"导入\"\n      onCancel={handleClose}\n      footer={null}>\n      <Form hidden={token} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item label=\"模板下载\" extra=\"请下载使用该模板填充数据后导入\">\n          <a href=\"/resource/主机导入模板.xlsx\">主机导入模板.xlsx</a>\n        </Form.Item>\n        <Form.Item required label=\"选择分组\">\n          <Cascader\n            value={groupId}\n            onChange={setGroupId}\n            options={store.treeData}\n            fieldNames={{label: 'title'}}\n            placeholder=\"请选择\"/>\n        </Form.Item>\n        <Form.Item required label=\"导入数据\" extra=\"Spug使用密钥认证连接服务器，导入或输入的密码仅作首次验证使用，不会存储。\">\n          <Upload\n            name=\"file\"\n            accept=\".xls, .xlsx\"\n            fileList={fileList}\n            beforeUpload={() => false}\n            onChange={handleUpload}>\n            {fileList.length === 0 && (\n              <Button><UploadOutlined/> 点击上传</Button>\n            )}\n          </Upload>\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 14, offset: 6}}>\n          <Button loading={loading} disabled={!fileList.length} type=\"primary\" onClick={handleSubmit}>导入主机</Button>\n        </Form.Item>\n      </Form>\n\n      {token && hosts ? (\n        <div>\n          <Divider>导入结果</Divider>\n          <div style={{display: 'flex', justifyContent: 'space-around'}}>\n            <div>成功：{summary.success}</div>\n            <div>失败：{summary.fail > 0 ? (\n              <Tooltip style={{color: '#1890ff'}} title={(\n                <div>\n                  {summary.skip.map(x => <div key={x}>第 {x} 行，重复的服务器信息</div>)}\n                  {summary.repeat.map(x => <div key={x}>第 {x} 行，重复的主机名称</div>)}\n                  {summary.invalid.map(x => <div key={x}>第 {x} 行，无效的数据</div>)}\n                </div>\n              )}><span style={{color: '#1890ff'}}>{summary.fail}</span></Tooltip>\n            ) : 0}</div>\n          </div>\n          {Object.keys(hosts).length > 0 && (\n            <>\n              <Divider>验证及同步</Divider>\n              <Sync token={token} hosts={hosts} style={{maxHeight: 'calc(100vh - 400px)'}}/>\n            </>\n          )}\n        </div>\n      ) : null}\n    </Modal>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/Selector.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Row, Col, Tree, Table, Button, Space, Input, Alert } from 'antd';\nimport { FolderOpenOutlined, FolderOutlined, PlusOutlined } from '@ant-design/icons';\nimport IPAddress from './IPAddress';\nimport hStore from './store';\nimport store from './store2';\nimport styles from './selector.module.less';\n\nfunction HostSelector(props) {\n  const [visible, setVisible] = useState(false)\n  const [isReady, setIsReady] = useState(false)\n  const [loading, setLoading] = useState(false);\n  const [selectedRowKeys, setSelectedRowKeys] = useState([]);\n  const [expands, setExpands] = useState([]);\n\n  useEffect(() => {\n    store.onlySelf = props.onlySelf;\n    hStore.initial().then(() => {\n      store.rawRecords = hStore.rawRecords;\n      store.rawTreeData = hStore.rawTreeData;\n      store.group = store.treeData[0] || {}\n    })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    if (!isReady) {\n      const length = store.treeData.length\n      if (length > 0 && length < 5) {\n        const tmp = store.treeData.filter(x => x.children.length)\n        setExpands(tmp.map(x => x.key))\n        setIsReady(true)\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [store.treeData])\n\n  useEffect(() => {\n    setSelectedRowKeys([...props.value])\n  }, [props.value])\n\n  useEffect(() => {\n    if (props.onlySelf) {\n      setSelectedRowKeys([])\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [store.group])\n\n  function handleClickRow(record) {\n    let tmp = new Set(selectedRowKeys)\n    if (!tmp.delete(record.id)) {\n      if (props.onlyOne) tmp.clear()\n      tmp.add(record.id)\n    }\n    setSelectedRowKeys([...tmp])\n  }\n\n  function handleSubmit() {\n    if (props.mode === 'ids') {\n      props.onChange(props.onlyOne ? selectedRowKeys[0] : selectedRowKeys)\n      handleClose()\n    } else if (props.mode === 'rows') {\n      const value = store.rawRecords.filter(x => selectedRowKeys.includes(x.id))\n      props.onChange(props.onlyOne ? value[0] : value)\n      handleClose()\n    } else if (props.mode === 'group') {\n      setLoading(true)\n      props.onChange(store.group, selectedRowKeys)\n        .then(handleClose, () => setLoading(false))\n    }\n  }\n\n  function handleExpand(keys, {_, node}) {\n    if (node.children.length > 0) {\n      setExpands(keys)\n    }\n  }\n\n  function handleSelectAll(selected) {\n    let tmp = new Set(selectedRowKeys)\n    for (let item of store.dataSource) {\n      if (selected) {\n        tmp.add(item.id)\n      } else {\n        tmp.delete(item.id)\n      }\n    }\n    setSelectedRowKeys([...tmp])\n  }\n\n  function treeRender(nodeData) {\n    const length = store.counter[nodeData.key]?.size\n    return (\n      <div className={styles.treeNode}>\n        {expands.includes(nodeData.key) ? <FolderOpenOutlined/> : <FolderOutlined/>}\n        <div className={styles.title}>{nodeData.title}</div>\n        {length ? <div className={styles.number}>{length}</div> : null}\n      </div>\n    )\n  }\n\n  function handleClose() {\n    setSelectedRowKeys([])\n    setLoading(false)\n    setVisible(false)\n    if (props.onCancel) {\n      props.onCancel()\n    }\n  }\n\n  return (\n    <div className={styles.selector}>\n      {props.mode !== 'group' && (\n        props.children ? (\n          <div onClick={() => setVisible(true)}>{props.children}</div>\n        ) : (\n          props.type === 'button' ? (\n            props.value.length > 0 ? (\n              <Alert\n                type=\"info\"\n                className={styles.area}\n                message={<div>已选择 <b style={{fontSize: 18, color: '#1890ff'}}>{props.value.length}</b> 台主机</div>}\n                onClick={() => setVisible(true)}/>\n            ) : (\n              <Button icon={<PlusOutlined/>} onClick={() => setVisible(true)}>\n                添加目标主机\n              </Button>\n            )) : (\n            <div style={{display: 'flex', alignItems: 'center'}}>\n              {props.value.length > 0 && <span style={{marginRight: 16}}>已选择 {props.value.length} 台</span>}\n              <Button type=\"link\" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>\n            </div>\n          )\n        )\n      )}\n\n      <Modal\n        visible={props.mode === 'group' || visible}\n        width={1000}\n        className={styles.modal}\n        title={props.title || '主机列表'}\n        onOk={handleSubmit}\n        okButtonProps={{disabled: selectedRowKeys.length === 0 && !props.nullable}}\n        confirmLoading={loading}\n        onCancel={handleClose}>\n        <Row>\n          <Col span={6} style={{borderRight: '8px solid #f0f0f0', paddingRight: 12}}>\n            <div className={styles.gTitle}>分组列表</div>\n            <Tree.DirectoryTree\n              showIcon={false}\n              autoExpandParent\n              expandAction=\"doubleClick\"\n              selectedKeys={[store.group.key]}\n              expandedKeys={expands}\n              treeData={store.treeData}\n              titleRender={treeRender}\n              onExpand={handleExpand}\n              onSelect={(_, {node}) => store.group = node}\n            />\n          </Col>\n          <Col span={18} style={{paddingLeft: 12}}>\n            <div style={{display: 'flex', justifyContent: 'space-between', marginBottom: 12}}>\n              <Input allowClear style={{width: 260}} placeholder=\"输入名称/IP检索\" value={store.f_word}\n                     onChange={e => store.f_word = e.target.value}/>\n              <Space hidden={selectedRowKeys.length === 0}>\n                <div>已选择 {selectedRowKeys.length} 台主机</div>\n                <Button type=\"link\" style={{paddingRight: 0}} onClick={() => setSelectedRowKeys([])}>取消选择</Button>\n              </Space>\n            </div>\n            <Table\n              rowKey=\"id\"\n              dataSource={store.dataSource}\n              pagination={false}\n              scroll={{y: 480}}\n              onRow={record => {\n                return {\n                  onClick: () => handleClickRow(record)\n                }\n              }}\n              rowSelection={{\n                selectedRowKeys,\n                hideSelectAll: props.onlyOne,\n                onSelect: handleClickRow,\n                onSelectAll: handleSelectAll\n              }}>\n              <Table.Column ellipsis width={170} title=\"主机名称\" dataIndex=\"name\"/>\n              <Table.Column width={320} title=\"IP地址\" render={info => (\n                <Space>\n                  <IPAddress ip={info.public_ip_address} isPublic/>\n                  <IPAddress ip={info.private_ip_address}/>\n                </Space>\n              )}/>\n              <Table.Column title=\"备注信息\" dataIndex=\"desc\"/>\n            </Table>\n          </Col>\n        </Row>\n      </Modal>\n    </div>\n  )\n}\n\nHostSelector.defaultProps = {\n  value: [],\n  type: 'text',\n  mode: 'ids',\n  onlyOne: false,\n  nullable: false,\n  onChange: () => null\n}\n\nexport default observer(HostSelector)"
  },
  {
    "path": "spug_web/src/pages/host/Sync.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Form } from 'antd';\nimport { LoadingOutlined } from '@ant-design/icons';\nimport { X_TOKEN } from 'libs';\nimport styles from './index.module.less';\n\nexport default function (props) {\n  const [hosts, setHosts] = useState(props.hosts);\n\n  useEffect(() => {\n    let index = 0;\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/host/${props.token}/?x-token=${X_TOKEN}`);\n    socket.onopen = () => socket.send(String(index));\n    socket.onmessage = e => {\n      if (e.data === 'pong') {\n        socket.send(String(index))\n      } else {\n        index += 1;\n        const {key, status, message} = JSON.parse(e.data);\n        hosts[key]['status'] = status;\n        hosts[key]['message'] = message;\n        setHosts({...hosts})\n      }\n    }\n    return () => socket && socket.close()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  return (\n    <Form labelCol={{span: 8}} wrapperCol={{span: 14}} className={styles.batchSync} style={props.style}>\n      {Object.entries(hosts).map(([key, item]) => (\n        <Form.Item key={key} label={item.name} extra={item.message}>\n          {item.status === 'ok' && <span style={{color: \"#52c41a\"}}>成功</span>}\n          {item.status === 'fail' && <span style={{color: \"red\"}}>失败</span>}\n          {item.status === undefined && <LoadingOutlined/>}\n        </Form.Item>\n      ))}\n    </Form>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/host/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, Dropdown, Button, Menu, Avatar, Tooltip, Space, Tag, Radio, Input, message } from 'antd';\nimport { PlusOutlined, DownOutlined, SyncOutlined, FormOutlined } from '@ant-design/icons';\nimport { Action, TableCard, AuthButton, AuthFragment } from 'components';\nimport IPAddress from './IPAddress';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\nimport icons from './icons';\nimport moment from 'moment';\n\nfunction ComTable() {\n  function handleDelete(text) {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/host/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  }\n\n  function handleImport(menu) {\n    if (menu.key === 'excel') {\n      store.importVisible = true\n    } else if (menu.key === 'form') {\n      store.showForm({group_ids: [store.group.value]})\n    } else {\n      store.cloudImport = menu.key\n    }\n  }\n\n  function ExpTime(props) {\n    if (!props.value) return null\n    let value = moment(props.value)\n    const days = value.diff(moment(), 'days')\n    if (days > 30) {\n      return <span>剩余 <b style={{color: '#389e0d'}}>{days}</b> 天</span>\n    } else if (days > 7) {\n      return <span>剩余 <b style={{color: '#faad14'}}>{days}</b> 天</span>\n    } else if (days >= 0) {\n      return <span>剩余 <b style={{color: '#d9363e'}}>{days}</b> 天</span>\n    } else {\n      return <span>过期 <b style={{color: '#d9363e'}}>{Math.abs(days)}</b> 天</span>\n    }\n  }\n\n  return (\n    <TableCard\n      tKey=\"hi\"\n      rowKey=\"id\"\n      title={<Input allowClear value={store.f_word} placeholder=\"输入名称/IP检索\" style={{maxWidth: 250}}\n                    onChange={e => store.f_word = e.target.value}/>}\n      loading={store.isFetching}\n      dataSource={store.dataSource}\n      onReload={store.fetchRecords}\n      actions={[\n        <AuthFragment auth=\"host.host.add\">\n          <Dropdown overlay={(\n            <Menu onClick={handleImport}>\n              <Menu.Item key=\"form\">\n                <Space>\n                  <FormOutlined style={{fontSize: 16, marginRight: 4, color: '#1890ff'}}/>\n                  <span>新建主机</span>\n                </Space>\n              </Menu.Item>\n              <Menu.Item key=\"excel\">\n                <Space>\n                  <Avatar shape=\"square\" size={20} src={icons.excel}/>\n                  <span>Excel</span>\n                </Space>\n              </Menu.Item>\n              <Menu.Item key=\"ali\">\n                <Space>\n                  <Avatar shape=\"square\" size={20} src={icons.alibaba}/>\n                  <span>阿里云</span>\n                </Space>\n              </Menu.Item>\n              <Menu.Item key=\"tencent\">\n                <Space>\n                  <Avatar shape=\"square\" size={20} src={icons.tencent}/>\n                  <span>腾讯云</span>\n                </Space>\n              </Menu.Item>\n            </Menu>\n          )}>\n            <Button type=\"primary\" icon={<PlusOutlined/>}>新建 <DownOutlined/></Button>\n          </Dropdown>\n        </AuthFragment>,\n        <AuthButton\n          auth=\"host.host.add\"\n          type=\"primary\"\n          icon={<SyncOutlined/>}\n          onClick={() => store.showSync()}>验证</AuthButton>,\n        <Radio.Group value={store.f_status} onChange={e => store.f_status = e.target.value}>\n          <Radio.Button value=\"\">全部</Radio.Button>\n          <Radio.Button value={false}>未验证</Radio.Button>\n        </Radio.Group>\n      ]}\n      pagination={{\n        showSizeChanger: true,\n        showLessItems: true,\n        hideOnSinglePage: true,\n        showTotal: total => `共 ${total} 条`,\n        pageSizeOptions: ['10', '20', '50', '100']\n      }}>\n      <Table.Column\n        showSorterTooltip={false}\n        title=\"主机名称\"\n        render={info => <Action.Button onClick={() => store.showDetail(info)}>{info.name}</Action.Button>}\n        sorter={(a, b) => a.name.localeCompare(b.name)}/>\n      <Table.Column title=\"IP地址\" render={info => (\n        <div>\n          <IPAddress ip={info.public_ip_address} isPublic/>\n          <IPAddress ip={info.private_ip_address}/>\n        </div>\n      )}/>\n      <Table.Column title=\"配置信息\" render={info => (\n        <Space>\n          <Tooltip title={info.os_name}>\n            <Avatar shape=\"square\" size={16} src={icons[info.os_type]}/>\n          </Tooltip>\n          <span>{info.cpu}核 {info.memory}GB</span>\n        </Space>\n      )}/>\n      <Table.Column hide title=\"到期信息\" dataIndex=\"expired_time\" render={v => <ExpTime value={v}/>}/>\n      <Table.Column hide title=\"备注信息\" dataIndex=\"desc\"/>\n      <Table.Column\n        title=\"状态\"\n        dataIndex=\"is_verified\"\n        render={v => v ? <Tag color=\"green\">已验证</Tag> : <Tag color=\"orange\">未验证</Tag>}/>\n      {hasPermission('host.host.edit|host.host.del|host.host.console') && (\n        <Table.Column width={160} title=\"操作\" render={info => (\n          <Action>\n            <Action.Button auth=\"host.host.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n            <Action.Button danger auth=\"host.host.del\" onClick={() => handleDelete(info)}>删除</Action.Button>\n          </Action>\n        )}/>\n      )}\n    </TableCard>\n  )\n}\n\nexport default observer(ComTable)\n"
  },
  {
    "path": "spug_web/src/pages/host/icons/index.js",
    "content": "import iconExcel from './excel.png';\nimport iconCentos from './centos.png';\nimport iconAlibaba from './alibaba.png';\nimport iconCoreos from './coreos.png';\nimport iconDebian from './debian.png';\nimport iconFreebsd from './freebsd.png';\nimport iconSuse from './suse.png';\nimport iconTencent from './tencent.png';\nimport iconUbuntu from './ubuntu.png';\nimport iconWindows from './windows.png';\nimport iconFedora from './fedora.png';\nimport iconLinux from './linux.png';\n\nexport default {\n  excel: iconExcel,\n  alibaba: iconAlibaba,\n  centos: iconCentos,\n  coreos: iconCoreos,\n  debian: iconDebian,\n  freebsd: iconFreebsd,\n  suse: iconSuse,\n  tencent: iconTencent,\n  ubuntu: iconUbuntu,\n  fedora: iconFedora,\n  windows: iconWindows,\n  unknown: iconLinux,\n}"
  },
  {
    "path": "spug_web/src/pages/host/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Row, Col } from 'antd';\nimport { CodeOutlined } from '@ant-design/icons';\nimport { AuthDiv, Breadcrumb, AuthButton } from 'components';\nimport Group from './Group';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport ComImport from './Import';\nimport CloudImport from './CloudImport';\nimport BatchSync from './BatchSync';\nimport Detail from './Detail';\nimport Selector from './Selector';\nimport store from './store';\n\nexport default observer(function () {\n  useEffect(() => {\n    store.initial()\n  }, [])\n\n  function openTerminal() {\n    window.open('/ssh')\n  }\n\n  return (\n    <AuthDiv auth=\"host.host.view\">\n      <Breadcrumb extra={<AuthButton auth=\"host.console.view|host.console.list\" type=\"primary\" icon={<CodeOutlined/>}\n                                     onClick={openTerminal}>Web 终端</AuthButton>}>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>主机管理</Breadcrumb.Item>\n      </Breadcrumb>\n\n      <Row gutter={12}>\n        <Col span={6}>\n          <Group/>\n        </Col>\n        <Col span={18}>\n          <ComTable/>\n        </Col>\n      </Row>\n\n      <Detail/>\n      {store.formVisible && <ComForm/>}\n      {store.importVisible && <ComImport/>}\n      {store.cloudImport && <CloudImport/>}\n      {store.syncVisible && <BatchSync/>}\n      {store.selectorVisible &&\n        <Selector\n          mode=\"group\"\n          onlySelf={!store.addByCopy}\n          onCancel={() => store.selectorVisible = false}\n          onChange={store.updateGroup}\n        />}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/host/index.module.less",
    "content": ".steps {\n  width: 350px;\n  margin: 0 auto 30px;\n}\n\n.tagAdd {\n  background: #fff;\n  border-style: dashed;\n}\n\n.tagNumberInput {\n  width: 78px;\n  margin-right: 8px;\n  vertical-align: top;\n}\n\n.tagInput {\n  width: 140px;\n  margin-right: 8px;\n  vertical-align: top;\n}\n\n.hostExtendEdit {\n  :global(.ant-descriptions-item-content) {\n    padding: 4px 16px !important;\n  }\n}\n\n.formAddress1 {\n  display: inline-block;\n\n  :global(.ant-input) {\n    border-radius: 0;\n  }\n}\n\n.formAddress2 {\n  display: inline-block;\n\n  :global(.ant-input-group-addon) {\n    border-left: none;\n    border-radius: 0;\n  }\n\n  :global(.ant-input) {\n    border-radius: 0;\n  }\n}\n\n.formAddress3 {\n  display: inline-block;\n\n  :global(.ant-input-group-addon) {\n    border-left: none;\n    border-radius: 0;\n  }\n}\n\n.group {\n  height: 100%;\n}\n\n.treeNode {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n\n  .title {\n    margin-left: 8px;\n    flex: 1;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    word-break: break-all;\n    display: -webkit-box;\n    -webkit-line-clamp: 1;\n    -webkit-box-orient: vertical;\n  }\n\n  .number {\n    width: 30px;\n    text-align: right;\n  }\n}\n\n.batchSync {\n  max-height: calc(100vh - 300px);\n  overflow: auto;\n\n  :global(.ant-form-item) {\n    margin-bottom: 4px;\n  }\n\n  :global(.ant-form-item-extra) {\n    padding-top: 0;\n  }\n}"
  },
  {
    "path": "spug_web/src/pages/host/selector.module.less",
    "content": ".modal {\n  :global(.ant-modal-footer) {\n    border-top: none\n  }\n\n  .gTitle {\n    height: 44px;\n    line-height: 44px;\n    padding-left: 12px;\n    font-weight: bold;\n    margin-bottom: 12px;\n    background: #fafafa;\n  }\n}\n\n.area {\n  cursor: pointer;\n  width: 200px;\n  height: 32px;\n}\n\n.treeNode {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n\n  .title {\n    margin-left: 8px;\n    flex: 1;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    word-break: break-all;\n    display: -webkit-box;\n    -webkit-line-clamp: 1;\n    -webkit-box-orient: vertical;\n  }\n\n  .number {\n    width: 30px;\n    text-align: right;\n  }\n}\n\n\n\n\n"
  },
  {
    "path": "spug_web/src/pages/host/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed, toJS } from 'mobx';\nimport { message } from 'antd';\nimport { http, includes } from 'libs';\n\nclass Store {\n  @observable rawTreeData = [];\n  @observable rawRecords = [];\n  @observable groups = {};\n  @observable group = {};\n  @observable record = {};\n  @observable idMap = {};\n  @observable addByCopy = true;\n  @observable grpFetching = true;\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable importVisible = false;\n  @observable syncVisible = false;\n  @observable cloudImport = null;\n  @observable detailVisible = false;\n  @observable selectorVisible = false;\n\n  @observable f_word;\n  @observable f_status = '';\n\n  @computed get records() {\n    let records = this.rawRecords;\n    if (this.f_word) {\n      records = records.filter(x => {\n        if (includes(x.name, this.f_word)) return true\n        if (x.public_ip_address && includes(x.public_ip_address[0], this.f_word)) return true\n        return !!(x.private_ip_address && includes(x.private_ip_address[0], this.f_word));\n      });\n    }\n    return records\n  }\n\n  @computed get dataSource() {\n    let records = [];\n    if (this.group.key) {\n      const host_ids = this.counter[this.group.key]\n      records = this.records.filter(x => host_ids && host_ids.has(x.id));\n    }\n    if (this.f_status !== '') records = records.filter(x => this.f_status === x.is_verified);\n    return records\n  }\n\n  @computed get counter() {\n    const counter = {}\n    for (let host of this.records) {\n      for (let id of host.group_ids) {\n        if (counter[id]) {\n          counter[id].add(host.id)\n        } else {\n          counter[id] = new Set([host.id])\n        }\n      }\n    }\n    for (let item of this.rawTreeData) {\n      this._handler_counter(item, counter)\n    }\n    return counter\n  }\n\n  @computed get treeData() {\n    let treeData = toJS(this.rawTreeData)\n    if (this.f_word) {\n      treeData = this._handle_filter_group(treeData)\n    }\n    return treeData\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/host/')\n      .then(res => {\n        const tmp = {};\n        this.rawRecords = res;\n        this.rawRecords.map(item => tmp[item.id] = item);\n        this.idMap = tmp;\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  fetchExtend = (id) => {\n    http.put('/api/host/', {id})\n      .then(() => this.fetchRecords())\n  }\n\n  fetchGroups = () => {\n    this.grpFetching = true;\n    return http.get('/api/host/group/')\n      .then(res => {\n        this.groups = res.groups;\n        this.rawTreeData = res.treeData\n      })\n      .finally(() => this.grpFetching = false)\n  }\n\n  initial = () => {\n    if (this.rawRecords.length > 0) return Promise.resolve()\n    this.isFetching = true;\n    this.grpFetching = true;\n    return http.all([http.get('/api/host/'), http.get('/api/host/group/')])\n      .then(http.spread((res1, res2) => {\n        this.rawRecords = res1;\n        this.rawRecords.map(item => this.idMap[item.id] = item);\n        this.groups = res2.groups;\n        this.rawTreeData = res2.treeData;\n        this.group = this.treeData[0] || {};\n      }))\n      .finally(() => {\n        this.isFetching = false;\n        this.grpFetching = false\n      })\n  }\n\n  updateGroup = (group, host_ids) => {\n    const form = {host_ids, s_group_id: group.key, t_group_id: this.group.key, is_copy: this.addByCopy};\n    return http.patch('/api/host/', form)\n      .then(() => {\n        message.success('操作成功');\n        this.fetchRecords()\n      })\n  }\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n\n  showSync = () => {\n    this.syncVisible = !this.syncVisible\n  }\n\n  showDetail = (info) => {\n    this.record = info;\n    this.detailVisible = true;\n  }\n\n  showSelector = (addByCopy) => {\n    this.addByCopy = addByCopy;\n    this.selectorVisible = true;\n  }\n\n  _handler_counter = (item, counter) => {\n    if (!counter[item.key]) counter[item.key] = new Set()\n    for (let child of item.children) {\n      this._handler_counter(child, counter)\n      counter[child.key].forEach(x => counter[item.key].add(x))\n    }\n  }\n\n  _handle_filter_group = (treeData) => {\n    const data = []\n    for (let item of treeData) {\n      const host_ids = this.counter[item.key]\n      if (host_ids.size > 0 || item.key === this.group.key) {\n        item.children = this._handle_filter_group(item.children)\n        data.push(item)\n      }\n    }\n    return data\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/host/store2.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed, toJS } from 'mobx';\nimport { includes } from 'libs';\n\nclass Store {\n  @observable rawTreeData = [];\n  @observable rawRecords = [];\n  @observable group = {};\n  @observable onlySelf = false;\n\n  @observable f_word;\n\n  @computed get records() {\n    let records = this.rawRecords;\n    if (this.f_word) {\n      records = records.filter(x => {\n        if (includes(x.name, this.f_word)) return true\n        if (x.public_ip_address && includes(x.public_ip_address[0], this.f_word)) return true\n        return !!(x.private_ip_address && includes(x.private_ip_address[0], this.f_word));\n      });\n    }\n    return records\n  }\n\n  @computed get dataSource() {\n    let records = [];\n    if (this.group.key) {\n      const host_ids = this.counter[this.group.key]\n      records = this.records.filter(x => host_ids && host_ids.has(x.id));\n    }\n    return records\n  }\n\n  @computed get counter() {\n    const counter = {}\n    for (let host of this.records) {\n      for (let id of host.group_ids) {\n        if (counter[id]) {\n          counter[id].add(host.id)\n        } else {\n          counter[id] = new Set([host.id])\n        }\n      }\n    }\n    if (!this.onlySelf) {\n      for (let item of this.rawTreeData) {\n        this._handler_counter(item, counter)\n      }\n    }\n    return counter\n  }\n\n  @computed get treeData() {\n    let treeData = toJS(this.rawTreeData)\n    if (this.f_word) {\n      treeData = this._handle_filter_group(treeData)\n    }\n    return treeData\n  }\n\n  _handler_counter = (item, counter) => {\n    if (!counter[item.key]) counter[item.key] = new Set()\n    for (let child of item.children) {\n      this._handler_counter(child, counter)\n      counter[child.key].forEach(x => counter[item.key].add(x))\n    }\n  }\n\n  _handle_filter_group = (treeData) => {\n    const data = []\n    for (let item of treeData) {\n      const host_ids = this.counter[item.key]\n      if (host_ids?.size > 0 || item.key === this.group.key) {\n        item.children = this._handle_filter_group(item.children)\n        data.push(item)\n      }\n    }\n    return data\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/login/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Form, Input, Button, Tabs, Modal, message } from 'antd';\nimport { UserOutlined, LockOutlined, CopyrightOutlined, GithubOutlined, MailOutlined } from '@ant-design/icons';\nimport styles from './login.module.css';\nimport history from 'libs/history';\nimport { http, updatePermissions } from 'libs';\nimport logo from 'layout/logo-spug-txt.png';\nimport envStore from 'pages/config/environment/store';\nimport appStore from 'pages/config/app/store';\nimport requestStore from 'pages/deploy/request/store';\nimport execStore from 'pages/exec/task/store';\nimport hostStore from 'pages/host/store';\n\nexport default function () {\n  const [form] = Form.useForm();\n  const [counter, setCounter] = useState(0);\n  const [loading, setLoading] = useState(false);\n  const [loginType, setLoginType] = useState(localStorage.getItem('login_type') || 'default');\n  const [codeVisible, setCodeVisible] = useState(false);\n  const [codeLoading, setCodeLoading] = useState(false);\n\n  useEffect(() => {\n    envStore.records = [];\n    appStore.records = [];\n    requestStore.records = [];\n    requestStore.deploys = [];\n    hostStore.rawRecords = [];\n    execStore.hosts = [];\n  }, [])\n\n  useEffect(() => {\n    setTimeout(() => {\n      if (counter > 0) {\n        setCounter(counter - 1)\n      }\n    }, 1000)\n  }, [counter])\n\n  function handleSubmit() {\n    const formData = form.getFieldsValue();\n    if (codeVisible && !formData.captcha) return message.error('请输入验证码');\n    setLoading(true);\n    formData['type'] = loginType;\n    http.post('/api/account/login/', formData)\n      .then(data => {\n        if (data['required_mfa']) {\n          setCodeVisible(true);\n          setCounter(30);\n          setLoading(false)\n        } else if (!data['has_real_ip']) {\n          Modal.warning({\n            title: '安全警告',\n            className: styles.tips,\n            content: <div>\n              未能获取到访问者的真实IP，无法提供基于请求来源IP的合法性验证，详细信息请参考\n              <a target=\"_blank\"\n                 href=\"https://ops.spug.cc/docs/practice/\"\n                 rel=\"noopener noreferrer\">官方文档</a>。\n            </div>,\n            onOk: () => doLogin(data)\n          })\n        } else {\n          doLogin(data)\n        }\n      }, () => setLoading(false))\n  }\n\n  function doLogin(data) {\n    localStorage.setItem('id', data['id']);\n    localStorage.setItem('token', data['access_token']);\n    localStorage.setItem('nickname', data['nickname']);\n    localStorage.setItem('is_supper', data['is_supper']);\n    localStorage.setItem('permissions', JSON.stringify(data['permissions']));\n    localStorage.setItem('login_type', loginType);\n    updatePermissions();\n    if (history.location.state && history.location.state['from']) {\n      history.push(history.location.state['from'])\n    } else {\n      history.push('/home')\n    }\n  }\n\n  function handleCaptcha() {\n    setCodeLoading(true);\n    const formData = form.getFieldsValue(['username', 'password']);\n    formData['type'] = loginType;\n    http.post('/api/account/login/', formData)\n      .then(() => setCounter(30))\n      .finally(() => setCodeLoading(false))\n  }\n\n  return (\n    <div className={styles.container}>\n      <div className={styles.titleContainer}>\n        <div><img className={styles.logo} src={logo} alt=\"logo\"/></div>\n        <div className={styles.desc}>灵活、强大、易用的开源运维平台</div>\n      </div>\n      <div className={styles.formContainer}>\n        <Tabs activeKey={loginType} className={styles.tabs} onTabClick={v => setLoginType(v)}>\n          <Tabs.TabPane tab=\"普通登录\" key=\"default\"/>\n          <Tabs.TabPane tab=\"LDAP登录\" key=\"ldap\"/>\n        </Tabs>\n        <Form form={form}>\n          <Form.Item name=\"username\" className={styles.formItem}>\n            <Input\n              size=\"large\"\n              autoComplete=\"off\"\n              placeholder=\"请输入账户\"\n              prefix={<UserOutlined className={styles.icon}/>}/>\n          </Form.Item>\n          <Form.Item name=\"password\" className={styles.formItem}>\n            <Input\n              size=\"large\"\n              type=\"password\"\n              autoComplete=\"off\"\n              placeholder=\"请输入密码\"\n              onPressEnter={handleSubmit}\n              prefix={<LockOutlined className={styles.icon}/>}/>\n          </Form.Item>\n          <Form.Item hidden={!codeVisible} name=\"captcha\" className={styles.formItem}>\n            <div style={{display: 'flex'}}>\n              <Form.Item noStyle name=\"captcha\">\n                <Input\n                  size=\"large\"\n                  autoComplete=\"off\"\n                  placeholder=\"请输入验证码\"\n                  prefix={<MailOutlined className={styles.icon}/>}/>\n              </Form.Item>\n              {counter > 0 ? (\n                <Button disabled size=\"large\" style={{marginLeft: 8}}>{counter} 秒后重新获取</Button>\n              ) : (\n                <Button size=\"large\" loading={codeLoading} style={{marginLeft: 8}}\n                        onClick={handleCaptcha}>获取验证码</Button>\n              )}\n            </div>\n          </Form.Item>\n        </Form>\n\n        <Button\n          block\n          size=\"large\"\n          type=\"primary\"\n          className={styles.button}\n          loading={loading}\n          onClick={handleSubmit}>登录</Button>\n      </div>\n\n      <div className={styles.footerZone}>\n        <div className={styles.linksZone}>\n          <a className={styles.links} title=\"官网\" href=\"https://spug.cc\" target=\"_blank\"\n             rel=\"noopener noreferrer\">官网</a>\n          <a className={styles.links} title=\"Github\" href=\"https://github.com/openspug/spug\" target=\"_blank\"\n             rel=\"noopener noreferrer\"><GithubOutlined/></a>\n          <a title=\"文档\" href=\"https://ops.spug.cc/docs/about-spug/\" target=\"_blank\"\n             rel=\"noopener noreferrer\">文档</a>\n        </div>\n        <div style={{color: 'rgba(0, 0, 0, .45)'}}>Copyright <CopyrightOutlined/> {new Date().getFullYear()} By OpenSpug</div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/login/login.module.css",
    "content": ".container {\n    background-image: url(\"./bg.svg\");\n    background-repeat: no-repeat;\n    background-position: center 110px;\n    background-size: 100%;\n    background-color: #f0f2f5;\n    height: 100vh;\n    display: flex;\n    flex-direction: column;\n}\n\n.titleContainer {\n    padding-top: 70px;\n    text-align: center;\n    font-size: 33px;\n    font-weight: 600;\n}\n\n.titleContainer .logo {\n    height: 35px;\n    margin-right: 15px;\n}\n\n.titleContainer .desc {\n    margin-top: 12px;\n    margin-bottom: 40px;\n    color: rgba(0, 0, 0, .45);\n    font-size: 14px;\n    font-weight: 400;\n\n}\n\n.formContainer {\n    width: 368px;\n    margin: 0 auto;\n    flex: 1;\n}\n\n.formContainer .tabs {\n    margin-bottom: 10px;\n}\n\n.formContainer .formItem {\n    margin-bottom: 24px;\n}\n\n.formContainer .icon {\n    color: rgba(0, 0, 0, .25);\n    font-size: 14px;\n    margin-right: 4px;\n}\n\n.formContainer .button {\n    margin-top: 10px;\n}\n\n.footerZone {\n    width: 100%;\n    bottom: 0;\n    padding: 20px;\n    font-size: 14px;\n    text-align: center;\n    display: flex;\n    flex-direction: column;\n}\n\n.footerZone .linksZone {\n    margin-bottom: 7px;\n}\n\n.footerZone .links {\n    margin-right: 40px;\n}\n\n.tips {\n    top: 230px\n}\n"
  },
  {
    "path": "spug_web/src/pages/monitor/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, {useEffect} from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Steps } from 'antd';\nimport Step1 from './Step1';\nimport Step2 from './Step2';\nimport store from './store';\nimport styles from './index.module.less';\nimport groupStore from '../alarm/group/store';\n\nexport default observer(function () {\n  useEffect(() => {\n    if (groupStore.records.length === 0) {\n      groupStore.fetchRecords();\n    }\n  }, [])\n\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={store.record.id ? '编辑任务' : '新建任务'}\n      onCancel={() => store.formVisible = false}\n      footer={null}>\n      <Steps current={store.page} className={styles.steps}>\n        <Steps.Step key={0} title=\"创建任务\"/>\n        <Steps.Step key={1} title=\"设置规则\"/>\n      </Steps>\n      {store.page === 0 && <Step1/>}\n      {store.page === 1 && <Step2/>}\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/monitor/MonitorCard.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Card, Input, Select, Space, Tooltip, Spin, message } from 'antd';\nimport { FrownOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons';\nimport styles from './index.module.less';\nimport store from './store';\n\nconst StyleMap = {\n  '0': {background: '#99999933', border: '1px solid #999', color: '#999999'},\n  '1': {background: '#16a98733', border: '1px solid #16a987', color: '#16a987'},\n  '2': {background: '#ffba0033', border: '1px solid #ffba00', color: '#ffba00'},\n  '3': {background: '#f2655d33', border: '1px solid #f2655d', color: '#f2655d'},\n  '10': {background: '#99999919', border: '1px dashed #999999', color: '#999999'}\n}\n\nconst StatusMap = {\n  '1': '正常',\n  '2': '警告',\n  '3': '紧急',\n  '0': '未激活',\n  '10': '待调度'\n}\n\nfunction CardItem(props) {\n  const {status, type, group, desc, name, target, latest_run_time} = props.data\n  const title = (\n    <div>\n      <div>分组: {group}</div>\n      <div>类型: {type}</div>\n      <div>名称: {name}</div>\n      <div>目标: {target}</div>\n      <div>状态: {StatusMap[status]}</div>\n      <div>更新: {latest_run_time || '---'}</div>\n      <div>描述: {desc}</div>\n    </div>\n  )\n  return (\n    <Tooltip title={title}>\n      <div className={styles.card} style={StyleMap[status]}/>\n    </Tooltip>\n  )\n}\n\nfunction MonitorCard() {\n  const [autoReload, setAutoReload] = useState(false);\n  const [status, setStatus] = useState();\n\n  useEffect(() => {\n    store.fetchOverviews()\n\n    return () => store.autoReload = null\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleAutoReload() {\n    store.autoReload = !autoReload\n    message.info(autoReload ? '关闭自动刷新' : '开启自动刷新')\n    if (!autoReload) store.fetchOverviews()\n    setAutoReload(!autoReload)\n  }\n\n  const filteredRecords = store.ovDataSource.filter(x => !status || x.status === status)\n  return (\n    <Card title=\"总览\" style={{marginBottom: 24}} bodyStyle={{padding: '12px 24px'}} extra={(\n      <Space size=\"middle\">\n        <Space>\n          <div>分组：</div>\n          <Select allowClear style={{minWidth: 150}} value={store.f_group} onChange={v => store.f_group = v}\n                  placeholder=\"请选择\">\n            {store.groups.map(item => (\n              <Select.Option value={item} key={item}>{item}</Select.Option>\n            ))}\n          </Select>\n        </Space>\n        <Space>\n          <div>类型：</div>\n          <Select allowClear style={{width: 120}} value={store.f_type} onChange={v => store.f_type = v}\n                  placeholder=\"请选择\">\n            {store.types.map(item => <Select.Option key={item} value={item}>{item}</Select.Option>)}\n          </Select>\n        </Space>\n        <Space>\n          <div>名称：</div>\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </Space>\n      </Space>\n    )}>\n      <Spin spinning={store.ovFetching}>\n        <div className={styles.header}>\n          {Object.entries(StyleMap).map(([s, style]) => {\n            const count = store.ovDataSource.filter(x => x.status === s).length;\n            return count ? (\n              <Tooltip key={s} title={StatusMap[s]}>\n                <div\n                  className={styles.item}\n                  style={s === status ? style : {...style, background: '#fff'}}\n                  onClick={() => setStatus(s === status ? '' : s)}>\n                  {store.ovDataSource.filter(x => x.status === s).length}\n                </div>\n              </Tooltip>\n            ) : null\n          })}\n          <Tooltip title=\"自动刷新\">\n            <div className={styles.autoLoad} onClick={handleAutoReload}>\n              {autoReload ? <SyncOutlined spin style={{color: '#2563fc'}}/> : <ReloadOutlined/>}\n            </div>\n          </Tooltip>\n        </div>\n        {filteredRecords.length > 0 ? (\n          <Space wrap size={4}>\n            {filteredRecords.map(item => (\n              <CardItem key={item.id} data={item}/>\n            ))}\n          </Space>\n        ) : (\n          <div className={styles.notMatch}><FrownOutlined/></div>\n        )}\n      </Spin>\n    </Card>\n  )\n}\n\nexport default observer(MonitorCard)"
  },
  {
    "path": "spug_web/src/pages/monitor/Step1.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { ExclamationCircleOutlined } from '@ant-design/icons';\nimport { Modal, Form, Input, Select, Button, message } from 'antd';\nimport TemplateSelector from '../exec/task/TemplateSelector';\nimport HostSelector from 'pages/host/Selector';\nimport { LinkButton, ACEditor } from 'components';\nimport { http, cleanCommand } from 'libs';\nimport store from './store';\nimport lds from 'lodash';\n\nconst helpMap = {\n  '1': '返回HTTP状态码200-399则判定为正常，其他为异常。',\n  '4': '脚本执行退出状态码为 0 则判定为正常，其他为异常。'\n}\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [showTmp, setShowTmp] = useState(false);\n\n  function handleTest() {\n    setLoading(true)\n    const formData = lds.pick(store.record, ['type', 'targets', 'extra'])\n    http.post('/api/monitor/test/', formData, {timeout: 120000})\n      .then(res => {\n        if (res.is_success) {\n          Modal.success({content: res.message})\n        } else {\n          Modal.warning({content: res.message})\n        }\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function handleChangeType(v) {\n    store.record.type = v;\n    store.record.targets = [];\n    store.record.extra = undefined;\n  }\n\n  function handleAddGroup() {\n    Modal.confirm({\n      icon: <ExclamationCircleOutlined/>,\n      title: '添加监控分组',\n      content: (\n        <Form layout=\"vertical\" style={{marginTop: 24}}>\n          <Form.Item required label=\"监控分组\">\n            <Input onChange={e => store.record.group = e.target.value}/>\n\n          </Form.Item>\n        </Form>\n      ),\n      onOk: () => {\n        if (store.record.group) {\n          store.groups.push(store.record.group);\n        }\n      },\n    })\n  }\n\n  function canNext() {\n    const {type, targets, extra, group} = store.record;\n    const is_verify = name && group && targets.length;\n    if (['2', '3', '4'].includes(type)) {\n      return is_verify && extra\n    } else {\n      return is_verify\n    }\n  }\n\n  function toNext() {\n    const {type, extra} = store.record;\n    if (!Number(extra) > 0) {\n      if (type === '1' && extra) return message.error('请输入正确的响应时间')\n      if (type === '2') return message.error('请输入正确的端口号')\n    }\n    store.page += 1;\n  }\n\n  function getStyle(t) {\n    return t.includes(store.record.type) ? {} : {display: 'none'}\n  }\n\n  const {name, desc, type, targets, extra, group} = store.record;\n  return (\n    <Form labelCol={{span: 6}} wrapperCol={{span: 14}}>\n      <Form.Item required label=\"监控分组\" style={{marginBottom: 0}}>\n        <Form.Item style={{display: 'inline-block', width: 'calc(75%)', marginRight: 8}}>\n          <Select value={group} placeholder=\"请选择监控分组\" onChange={v => store.record.group = v}>\n            {store.groups.map(item => (\n              <Select.Option value={item} key={item}>{item}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item style={{display: 'inline-block', width: 'calc(25%-8px)'}}>\n          <Button type=\"link\" onClick={handleAddGroup}>添加分组</Button>\n        </Form.Item>\n      </Form.Item>\n      <Form.Item label=\"监控类型\" tooltip={helpMap[type]}>\n        <Select placeholder=\"请选择监控类型\" value={type} onChange={handleChangeType}>\n          <Select.Option value=\"1\">站点检测</Select.Option>\n          <Select.Option value=\"2\">端口检测</Select.Option>\n          <Select.Option value=\"5\">Ping检测</Select.Option>\n          <Select.Option value=\"3\">进程检测</Select.Option>\n          <Select.Option value=\"4\">自定义脚本</Select.Option>\n        </Select>\n      </Form.Item>\n      <Form.Item required label=\"监控名称\">\n        <Input value={name} onChange={e => store.record.name = e.target.value} placeholder=\"请输入监控名称\"/>\n      </Form.Item>\n      <Form.Item required label=\"监控地址\" style={getStyle(['1'])}>\n        <Select\n          mode=\"tags\"\n          value={targets}\n          tokenSeparators={[',', ' ']}\n          onChange={v => store.record.targets = v}\n          placeholder=\"http(s)://开头，支持多个地址，每输入完成一个后按回车确认\"\n          notFoundContent={null}/>\n      </Form.Item>\n      <Form.Item required label=\"监控地址\" style={getStyle(['2', '5'])}>\n        <Select\n          mode=\"tags\"\n          value={targets}\n          tokenSeparators={[',', ' ']}\n          onChange={v => store.record.targets = v}\n          placeholder=\"IP或域名，支持多个地址，每输入完成一个后按回车确认\"\n          notFoundContent={null}/>\n      </Form.Item>\n      <Form.Item required label=\"监控主机\" style={getStyle(['3', '4'])}>\n        <HostSelector value={targets} onChange={ids => store.record.targets = ids}/>\n      </Form.Item>\n      <Form.Item label=\"响应时间\" style={getStyle(['1'])}>\n        <Input suffix=\"ms\" value={extra} placeholder=\"最长响应时间（毫秒），不设置则默认10秒超时\"\n               onChange={e => store.record.extra = e.target.value}/>\n      </Form.Item>\n      <Form.Item required label=\"检测端口\" style={getStyle(['2'])}>\n        <Input value={extra} placeholder=\"请输入端口号\" onChange={e => store.record.extra = e.target.value}/>\n      </Form.Item>\n      <Form.Item required label=\"进程名称\" extra=\"执行 ps -ef 看到的进程名称。\" style={getStyle(['3'])}>\n        <Input value={extra} placeholder=\"请输入进程名称\" onChange={e => store.record.extra = e.target.value}/>\n      </Form.Item>\n      <Form.Item\n        required\n        label=\"脚本内容\"\n        style={getStyle(['4'])}\n        extra={<LinkButton onClick={() => setShowTmp(true)}>从模板添加</LinkButton>}>\n        <ACEditor\n          mode=\"sh\"\n          value={extra || ''}\n          width=\"100%\"\n          height=\"200px\"\n          onChange={e => store.record.extra = cleanCommand(e)}/>\n      </Form.Item>\n      <Form.Item label=\"备注信息\">\n        <Input.TextArea value={desc} onChange={e => store.record.desc = e.target.value} placeholder=\"请输入备注信息\"/>\n      </Form.Item>\n\n      <Form.Item wrapperCol={{span: 14, offset: 6}} style={{marginTop: 12}}>\n        <Button disabled={!canNext()} type=\"primary\" onClick={toNext}>下一步</Button>\n        <Button disabled={!canNext()} type=\"link\" loading={loading} onClick={handleTest}>执行测试</Button>\n        <span style={{color: '#888', fontSize: 12}}>Tips: 仅测试第一个监控地址</span>\n      </Form.Item>\n      {showTmp && <TemplateSelector onOk={({body}) => store.record.extra = body} onCancel={() => setShowTmp(false)}/>}\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/monitor/Step2.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { observer } from 'mobx-react';\nimport { Form, Select, Radio, Transfer, Checkbox, Button, message } from 'antd';\nimport { http } from 'libs';\nimport groupStore from '../alarm/group/store';\nimport store from './store';\nimport lds from 'lodash';\n\nconst modeOptions = [\n  {label: '微信', 'value': '1'},\n  {label: '短信', 'value': '2'},\n  {label: '电话', 'value': '6'},\n  {label: '邮件', 'value': '4'},\n  {label: '钉钉', 'value': '3'},\n  {label: '企业微信', 'value': '5'},\n  {label: '飞书', 'value': '7'},\n];\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    const {type, addr} = store.record;\n    if (type === '1' && addr) {\n      store.record.sitePrefix = addr.startsWith('http://') ? 'http://' : 'https://';\n      store.record.domain = store.record.addr.replace(store.record.sitePrefix, '')\n    }\n  }, [])\n\n  function handleSubmit() {\n    setLoading(true)\n    const formData = form.getFieldsValue();\n    Object.assign(formData, lds.pick(store.record, ['id', 'name', 'desc', 'targets', 'extra', 'type', 'group']))\n    formData['id'] = store.record.id;\n    http.post('/api/monitor/', formData)\n      .then(() => {\n        message.success('操作成功');\n        store.record = {};\n        store.formVisible = false;\n        store.fetchRecords();\n        store.fetchOverviews()\n      }, () => setLoading(false))\n  }\n\n  function canNext() {\n    const {notify_grp, notify_mode} = form.getFieldsValue();\n    return notify_grp && notify_grp.length && notify_mode && notify_mode.length;\n  }\n\n  const info = store.record;\n  return (\n    <Form form={form} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n      <Form.Item name=\"rate\" initialValue={info.rate || 5} label=\"监控频率\" tooltip=\"每隔N分钟检测一次\">\n        <Radio.Group>\n          <Radio value={1}>1分钟</Radio>\n          <Radio value={5}>5分钟</Radio>\n          <Radio value={15}>15分钟</Radio>\n          <Radio value={30}>30分钟</Radio>\n          <Radio value={60}>60分钟</Radio>\n        </Radio.Group>\n      </Form.Item>\n      <Form.Item name=\"threshold\" initialValue={info.threshold || 3} label=\"报警阈值\" tooltip=\"连续N次检测失败，则发送告警\">\n        <Radio.Group>\n          <Radio value={1}>1次</Radio>\n          <Radio value={2}>2次</Radio>\n          <Radio value={3}>3次</Radio>\n          <Radio value={4}>4次</Radio>\n          <Radio value={5}>5次</Radio>\n        </Radio.Group>\n      </Form.Item>\n      <Form.Item required name=\"notify_grp\" valuePropName=\"targetKeys\" initialValue={info.notify_grp} label=\"报警联系人组\"\n                 extra={<>去创建 <Link to=\"/alarm/contact\">报警联系人</Link> 和 <Link to=\"/alarm/group\">联系人组</Link>。</>}>\n        <Transfer\n          lazy={false}\n          rowKey={item => item.id}\n          titles={['已有联系组', '已选联系组']}\n          listStyle={{width: 199}}\n          dataSource={groupStore.records}\n          render={item => item.name}/>\n      </Form.Item>\n      <Form.Item required name=\"notify_mode\" initialValue={info.notify_mode} label=\"报警方式\">\n        <Checkbox.Group options={modeOptions}/>\n      </Form.Item>\n      <Form.Item name=\"quiet\" initialValue={info.quiet || 24 * 60} label=\"通道沉默\" extra=\"相同的告警信息，沉默期内只发送一次。\">\n        <Select placeholder=\"请选择\">\n          <Select.Option value={5}>5分钟</Select.Option>\n          <Select.Option value={10}>10分钟</Select.Option>\n          <Select.Option value={15}>15分钟</Select.Option>\n          <Select.Option value={30}>30分钟</Select.Option>\n          <Select.Option value={60}>60分钟</Select.Option>\n          <Select.Option value={3 * 60}>3小时</Select.Option>\n          <Select.Option value={6 * 60}>6小时</Select.Option>\n          <Select.Option value={12 * 60}>12小时</Select.Option>\n          <Select.Option value={24 * 60}>24小时</Select.Option>\n        </Select>\n      </Form.Item>\n      <Form.Item shouldUpdate wrapperCol={{span: 14, offset: 6}} style={{marginTop: 12}}>\n        {() => (\n          <React.Fragment>\n            <Button disabled={!canNext()} loading={loading} type=\"primary\" onClick={handleSubmit}>提交</Button>\n            <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n          </React.Fragment>\n        )}\n      </Form.Item>\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/monitor/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Table, Modal, Radio, Tag, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { Action, AuthButton, TableCard } from 'components';\nimport { http, hasPermission } from 'libs';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords();\n  }\n\n  handleActive = (text) => {\n    Modal.confirm({\n      title: '操作确认',\n      content: `确定要${text['is_active'] ? '禁用' : '启用'}【${text['name']}】?`,\n      onOk: () => {\n        return http.patch(`/api/monitor/`, {id: text.id, is_active: !text['is_active']})\n          .then(() => {\n            message.success('操作成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/monitor/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"mi\"\n        rowKey=\"id\"\n        title=\"监控任务\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"monitor.monitor.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>,\n          <Radio.Group value={store.f_active} onChange={e => store.f_active = e.target.value}>\n            <Radio.Button value=\"\">全部</Radio.Button>\n            <Radio.Button value=\"1\">已激活</Radio.Button>\n            <Radio.Button value=\"0\">未激活</Radio.Button>\n          </Radio.Group>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}>\n        <Table.Column title=\"监控分组\" dataIndex=\"group\"/>\n        <Table.Column title=\"监控名称\" dataIndex=\"name\"/>\n        <Table.Column title=\"类型\" dataIndex=\"type_alias\"/>\n        <Table.Column title=\"频率\" dataIndex=\"rate\" render={value => `${value}分钟`}/>\n        <Table.Column title=\"状态\" render={info => {\n          if (info.is_active) {\n            return <Tag color=\"blue\">已激活</Tag>\n          } else {\n            return <Tag color=\"red\">未激活</Tag>\n          }\n        }}/>\n        <Table.Column title=\"更新于\" dataIndex=\"latest_run_time_alias\"\n                      sorter={(a, b) => a.latest_run_time.localeCompare(b.latest_run_time)}/>\n        <Table.Column hide title=\"描述\" dataIndex=\"desc\"/>\n        {hasPermission('monitor.monitor.edit|monitor.monitor.del') && (\n          <Table.Column width={180} title=\"操作\" render={info => (\n            <Action>\n              <Action.Button auth=\"monitor.monitor.edit\"\n                             onClick={() => this.handleActive(info)}>{info['is_active'] ? '禁用' : '启用'}</Action.Button>\n              <Action.Button auth=\"monitor.monitor.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n              <Action.Button danger auth=\"monitor.monitor.del\"\n                             onClick={() => this.handleDelete(info)}>删除</Action.Button>\n            </Action>\n          )}/>\n        )}\n      </TableCard>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/monitor/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport MonitorCard from './MonitorCard';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"monitor.monitor.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>监控中心</Breadcrumb.Item>\n      </Breadcrumb>\n      <MonitorCard/>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/monitor/index.module.less",
    "content": ".steps {\n    width: 520px;\n    margin: 0 auto 30px;\n}\n\n.card {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    width: 16px;\n    height: 16px;\n    font-size: 12px;\n    color: #fff;\n    border-radius: 2px;\n}\n\n.header {\n    display: flex;\n    justify-content: flex-end;\n    align-items: center;\n    margin-bottom: 12px;\n    margin-top: -6px;\n\n    .item {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        min-width: 26px;\n        height: 20px;\n        margin-left: 12px;\n        border-radius: 10px;\n        padding: 0 8px;\n        color: #fff;\n        font-weight: bold;\n        cursor: pointer;\n    }\n\n    .autoLoad {\n        margin-left: 24px;\n        font-size: 18px;\n        color: #999999;\n    }\n}\n\n.notMatch {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    color: #999;\n\n    :global(.anticon) {\n        font-size: 18px;\n        margin-right: 8px;\n    }\n}\n\n"
  },
  {
    "path": "spug_web/src/pages/monitor/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport { http, includes } from 'libs';\nimport moment from 'moment';\nimport lds from 'lodash';\n\nclass Store {\n  autoReload = null;\n  @observable records = [];\n  @observable record = {};\n  @observable types = [];\n  @observable groups = [];\n  @observable overviews = [];\n  @observable page = 0;\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable ovFetching = false;\n\n  @observable f_name;\n  @observable f_type;\n  @observable f_active = '';\n  @observable f_group;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_active) records = records.filter(x => x.is_active === (this.f_active === '1'));\n    if (this.f_name) records = records.filter(x => includes(x.name, this.f_name));\n    if (this.f_type) records = records.filter(x => x.type_alias === this.f_type);\n    if (this.f_group) records = records.filter(x => x.group === this.f_group);\n    return records\n  }\n\n  @computed get ovDataSource() {\n    let records = this.overviews;\n    if (this.f_type) records = records.filter(x => x.type === this.f_type);\n    if (this.f_group) records = records.filter(x => x.group === this.f_group);\n    if (this.f_name) records = records.filter(x => includes(x.name, this.f_name));\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/monitor/')\n      .then(({groups, detections}) => {\n        const tmp = new Set();\n        detections.map(item => {\n          tmp.add(item['type_alias']);\n          const value = item['latest_run_time'];\n          item['latest_run_time_alias'] = value ? moment(value).fromNow() : null;\n          return null\n        });\n        this.types = Array.from(tmp);\n        this.records = detections;\n        this.groups = groups;\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  fetchOverviews = () => {\n    if (this.autoReload === false) return\n    this.ovFetching = true;\n    return http.get('/api/monitor/overview/')\n      .then(res => this.overviews = res)\n      .finally(() => {\n        this.ovFetching = false;\n        if (this.autoReload) setTimeout(this.fetchOverviews, 5000)\n      })\n  }\n\n  showForm = (info) => {\n    if (info) {\n      this.record = lds.cloneDeep(info)\n    } else if (this.record.id || !this.record.type) {\n      this.record = {type: '1', targets: []}\n    }\n    this.page = 0;\n    this.formVisible = true;\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/schedule/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Steps } from 'antd';\nimport Step1 from './Step1';\nimport Step2 from './Step2';\nimport Step3 from './Step3';\nimport store from './store';\nimport styles from './index.module.css';\nimport hostStore from '../host/store';\n\nexport default observer(function () {\n  useEffect(() => {\n    hostStore.initial()\n    store.targets = store.record.id ? store.record['targets'] : [undefined];\n  }, [])\n  return (\n    <Modal\n      visible\n      width={800}\n      maskClosable={false}\n      title={store.record.id ? '编辑任务' : '新建任务'}\n      onCancel={() => store.formVisible = false}\n      footer={null}>\n      <Steps current={store.page} className={styles.steps}>\n        <Steps.Step key={0} title=\"创建任务\"/>\n        <Steps.Step key={1} title=\"选择执行对象\"/>\n        <Steps.Step key={2} title=\"设置触发器\"/>\n      </Steps>\n      {store.page === 0 && <Step1/>}\n      {store.page === 1 && <Step2/>}\n      {store.page === 2 && <Step3/>}\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/schedule/Info.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Modal, Tabs, Spin } from 'antd';\nimport { StatisticsCard } from 'components';\nimport http from 'libs/http';\nimport store from './store';\nimport moment from 'moment';\n\nclass ComForm extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: true,\n      info: {}\n    }\n  }\n\n  componentDidMount() {\n    http.get(`/api/schedule/${store.record.id}/?id=${store.record.h_id}`)\n      .then(info => this.setState({info}))\n      .finally(() => this.setState({loading: false}))\n  }\n\n  render() {\n    const {run_time, success, failure, duration, outputs} = this.state.info;\n    const preStyle = {\n      marginTop: 5,\n      backgroundColor: '#eee',\n      borderRadius: 5,\n      padding: 10,\n      maxHeight: 215,\n    };\n    return (\n      <Modal\n        visible\n        width={800}\n        maskClosable={false}\n        title=\"任务执行详情\"\n        onCancel={() => store.infoVisible = false}\n        footer={null}>\n        <Spin spinning={this.state.loading}>\n          <StatisticsCard loading={this.state.loading}>\n            <StatisticsCard.Item title=\"执行成功\" value={<span style={{color: '#3f8600'}}>{success}</span>}/>\n            <StatisticsCard.Item title=\"执行失败\" value={<span style={{color: '#cf1322'}}>{failure}</span>}/>\n            <StatisticsCard.Item bordered={false} title=\"平均耗时(秒)\" value={<span style={{color: ''}}>{duration}</span>}/>\n          </StatisticsCard>\n          {outputs && (\n            <Tabs tabPosition=\"left\" defaultActiveKey=\"0\" style={{width: 700, height: 350, margin: 'auto'}}>\n              {outputs.map((item, index) => (\n                <Tabs.TabPane\n                  key={`${index}`}\n                  tab={item.code === 0 ? item.name : <span style={{color: 'red'}}>{item.name}</span>}>\n                  <div>执行时间： {run_time}（{moment(run_time).fromNow()}）</div>\n                  <div style={{marginTop: 5}}>运行耗时： {item.duration} s</div>\n                  <div style={{marginTop: 5}}>返回状态： {item.code}（非 0 则判定为失败）</div>\n                  <div style={{marginTop: 5}}>执行输出： <pre style={preStyle}>{item.output}</pre></div>\n                </Tabs.TabPane>\n              ))}\n            </Tabs>\n          )}\n        </Spin>\n      </Modal>\n    )\n  }\n}\n\nexport default ComForm\n"
  },
  {
    "path": "spug_web/src/pages/schedule/Record.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Table, Tag } from 'antd';\nimport { LinkButton } from 'components';\nimport { http } from 'libs';\nimport store from './store';\n\n@observer\nclass Record extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: true,\n      records: []\n    }\n  }\n\n  componentDidMount() {\n    http.get(`/api/schedule/${store.record.id}/`)\n      .then(res => this.setState({records: res}))\n      .finally(() => this.setState({loading: false}))\n  }\n\n  colors = ['orange', 'green', 'red'];\n\n  columns = [{\n    title: '执行时间',\n    dataIndex: 'run_time'\n  }, {\n    title: '执行状态',\n    render: info => <Tag color={this.colors[info['status']]}>{info['status_alias']}</Tag>\n  }, {\n    title: '操作',\n    render: info => <LinkButton onClick={() => store.showInfo(null, info.id)}>详情</LinkButton>\n  }];\n\n  render() {\n    return (\n      <Modal\n        visible\n        width={800}\n        maskClosable={false}\n        title={`任务执行记录 - ${store.record.name}`}\n        onCancel={() => store.recordVisible = false}\n        footer={null}>\n        <Table\n          rowKey=\"id\"\n          columns={this.columns}\n          dataSource={this.state.records}\n          pagination={{\n            showSizeChanger: true,\n            showLessItems: true,\n            hideOnSinglePage: true,\n            showTotal: total => `共 ${total} 条`,\n            pageSizeOptions: ['10', '20', '50', '100']\n          }}\n          loading={this.state.loading}/>\n      </Modal>\n    )\n  }\n}\n\nexport default Record"
  },
  {
    "path": "spug_web/src/pages/schedule/Step1.js",
    "content": "import React, {useState, useEffect} from 'react';\nimport {observer} from 'mobx-react';\nimport {Form, Input, Select, Modal, Button, Radio} from 'antd';\nimport {ExclamationCircleOutlined} from '@ant-design/icons';\nimport {LinkButton, ACEditor} from 'components';\nimport TemplateSelector from '../exec/task/TemplateSelector';\nimport {cleanCommand, http} from 'libs';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [showTmp, setShowTmp] = useState(false);\n  const [command, setCommand] = useState(store.record.command || '');\n  const [rstValue, setRstValue] = useState({});\n  const [contacts, setContacts] = useState([]);\n\n  useEffect(() => {\n    const {mode, value} = store.record.rst_notify\n    setRstValue({[mode]: value})\n    http.get('/api/alarm/contact/?only_push=1')\n      .then(res => setContacts(res))\n  }, []);\n\n  function handleAddZone() {\n    let type;\n    Modal.confirm({\n      icon: <ExclamationCircleOutlined/>,\n      title: '添加任务类型',\n      content: (\n        <Form layout=\"vertical\" style={{marginTop: 24}}>\n          <Form.Item required label=\"任务类型\">\n            <Input onChange={e => type = e.target.value}/>\n          </Form.Item>\n        </Form>\n      ),\n      onOk: () => {\n        if (type) {\n          store.types.push(type);\n          form.setFieldsValue({type})\n        }\n      },\n    })\n  }\n\n  function canNext() {\n    const formData = form.getFieldsValue()\n    return !(formData.type && formData.name && command)\n  }\n\n  function handleNext() {\n    const notifyMode = store.record.rst_notify.mode\n    store.record.rst_notify.value = rstValue[notifyMode]\n    Object.assign(store.record, form.getFieldsValue(), {command: cleanCommand(command)})\n    store.page += 1;\n  }\n\n  function handleSelect(tpl) {\n    const {interpreter, body} = tpl;\n    setCommand(body)\n    form.setFieldsValue({interpreter})\n  }\n\n  let modePlaceholder;\n  switch (store.record.rst_notify.mode) {\n    case '0':\n      modePlaceholder = '已关闭'\n      break\n    case '1':\n      modePlaceholder = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'\n      break\n    case '3':\n      modePlaceholder = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx'\n      break\n    case '4':\n      modePlaceholder = 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'\n      break\n    default:\n      modePlaceholder = '请输入'\n  }\n\n  const notifyMode = store.record.rst_notify.mode\n  return (\n    <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n      <Form.Item required label=\"任务类型\" style={{marginBottom: 0}}>\n        <Form.Item name=\"type\" style={{display: 'inline-block', width: '80%'}}>\n          <Select placeholder=\"请选择任务类型\">\n            {store.types.map(item => (\n              <Select.Option value={item} key={item}>{item}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n        <Form.Item style={{display: 'inline-block', width: '20%', textAlign: 'right'}}>\n          <Button type=\"link\" onClick={handleAddZone}>添加类型</Button>\n        </Form.Item>\n      </Form.Item>\n      <Form.Item required name=\"name\" label=\"任务名称\">\n        <Input placeholder=\"请输入任务名称\"/>\n      </Form.Item>\n      <Form.Item required label=\"任务内容\" extra={<LinkButton onClick={() => setShowTmp(true)}>从模板添加</LinkButton>}>\n        <Form.Item noStyle name=\"interpreter\">\n          <Radio.Group buttonStyle=\"solid\" style={{marginBottom: 12}}>\n            <Radio.Button value=\"sh\" style={{width: 80, textAlign: 'center'}}>Shell</Radio.Button>\n            <Radio.Button value=\"python\" style={{width: 80, textAlign: 'center'}}>Python</Radio.Button>\n          </Radio.Group>\n        </Form.Item>\n        <Form.Item noStyle shouldUpdate>\n          {({getFieldValue}) => (\n            <ACEditor mode={getFieldValue('interpreter')} value={command} width=\"100%\" height=\"150px\"\n                      onChange={setCommand}/>\n          )}\n        </Form.Item>\n      </Form.Item>\n      <Form.Item label=\"失败通知\" extra={(\n        <span>\n            任务执行失败告警通知，\n            <a target=\"_blank\" rel=\"noopener noreferrer\"\n               href=\"https://ops.spug.cc/docs/use-problem#use-dd\">钉钉收不到通知？</a>\n          </span>)}>\n        <Input.Group compact>\n          <Select style={{width: '25%'}} value={notifyMode}\n                  onChange={v => store.record.rst_notify.mode = v}>\n            <Select.Option value=\"0\">关闭</Select.Option>\n            <Select.Option value=\"1\">钉钉</Select.Option>\n            <Select.Option value=\"4\">飞书</Select.Option>\n            <Select.Option value=\"3\">企业微信</Select.Option>\n            <Select.Option value=\"2\">Webhook</Select.Option>\n            <Select.Option value=\"5\">推送助手</Select.Option>\n          </Select>\n          <Select hidden={notifyMode !== '5'} mode=\"multiple\" style={{width: '75%'}} value={rstValue[notifyMode]}\n                  onChange={v => setRstValue(Object.assign({}, rstValue, {[notifyMode]: v}))}\n                  placeholder=\"请选择推送对象\">\n            {contacts.map(item => (\n              <Select.Option value={item.id} key={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n          <Input\n            hidden={notifyMode === '5'}\n            style={{width: '75%'}}\n            value={rstValue[notifyMode]}\n            onChange={e => setRstValue(Object.assign({}, rstValue, {[notifyMode]: e.target.value}))}\n            disabled={notifyMode === '0'}\n            placeholder={modePlaceholder}/>\n        </Input.Group>\n      </Form.Item>\n      <Form.Item name=\"desc\" label=\"备注信息\">\n        <Input.TextArea placeholder=\"请输入模板备注信息\"/>\n      </Form.Item>\n      <Form.Item shouldUpdate wrapperCol={{span: 14, offset: 6}}>\n        {() => <Button disabled={canNext()} type=\"primary\" onClick={handleNext}>下一步</Button>}\n      </Form.Item>\n      {showTmp && <TemplateSelector onOk={handleSelect} onCancel={() => setShowTmp(false)}/>}\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/schedule/Step2.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';\nimport { Form, Select, Button } from 'antd';\nimport HostSelector from 'pages/host/Selector';\nimport store from './store';\nimport hostStore from 'pages/host/store';\nimport styles from './index.module.css';\n\nexport default observer(function () {\n  function handleChange(ids) {\n    if (store.targets.includes('local')) {\n      ids.unshift('local')\n    }\n    store.targets = ids\n  }\n\n  return (\n    <React.Fragment>\n      <Form labelCol={{span: 7}} wrapperCol={{span: 14}} style={{minHeight: 350}}>\n        <Form.Item required label=\"执行对象\">\n          {store.targets.map((id, index) => (\n            <React.Fragment key={index}>\n              <Select\n                value={id}\n                showSearch\n                placeholder=\"请选择\"\n                optionFilterProp=\"children\"\n                style={{width: '80%', marginRight: 10, marginBottom: 12}}\n                filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}\n                onChange={v => store.editTarget(index, v)}>\n                <Select.Option value=\"local\" disabled={store.targets.includes('local')}>本机</Select.Option>\n                {hostStore.rawRecords.map(item => (\n                  <Select.Option key={item.id} value={item.id} disabled={store.targets.includes(item.id)}>\n                    {`${item.name}(${item['hostname']}:${item['port']})`}\n                  </Select.Option>\n                ))}\n              </Select>\n              {store.targets.length > 1 && (\n                <MinusCircleOutlined className={styles.delIcon} onClick={() => store.delTarget(index)}/>\n              )}\n            </React.Fragment>\n          ))}\n        </Form.Item>\n        <Form.Item wrapperCol={{span: 14, offset: 6}}>\n          <HostSelector value={store.targets.filter(x => x !== 'local')} onChange={handleChange}>\n            <Button type=\"dashed\" style={{width: '80%'}}><PlusOutlined/>添加执行对象</Button>\n          </HostSelector>\n        </Form.Item>\n      </Form>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button disabled={store.targets.filter(x => x).length === 0} type=\"primary\"\n                onClick={() => store.page += 1}>下一步</Button>\n        <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n      </Form.Item>\n    </React.Fragment>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/schedule/Step3.js",
    "content": "import React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Form, Tabs, DatePicker, InputNumber, Input, Button, message } from 'antd';\nimport { LoadingOutlined } from '@ant-design/icons';\nimport { http } from 'libs';\nimport store from './store';\nimport moment from 'moment';\nimport lds from 'lodash';\n\nlet lastFetchId = 0;\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [trigger, setTrigger] = useState(store.record.trigger);\n  const [args, setArgs] = useState({[store.record.trigger]: store.record.trigger_args});\n  const [nextRunTime, setNextRunTime] = useState(null);\n\n  function handleSubmit() {\n    if (trigger === 'date' && args['date'] <= moment()) {\n      return message.error('任务执行时间不能早于当前时间')\n    }\n    setLoading(true)\n    const formData = lds.pick(store.record, ['id', 'name', 'type', 'interpreter', 'command', 'desc', 'rst_notify']);\n    formData['targets'] = store.targets.filter(x => x);\n    formData['trigger'] = trigger;\n    formData['trigger_args'] = _parse_args();\n    http.post('/api/schedule/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleArgs(key, val) {\n    setArgs(Object.assign({}, args, {[key]: val}))\n  }\n\n  function handleCronArgs(key, val) {\n    let tmp = args['cron'] || {};\n    tmp = Object.assign(tmp, {[key]: val});\n    setArgs(Object.assign({}, args, {cron: tmp}));\n    _fetchNextRunTime()\n  }\n\n  function _parse_args() {\n    switch (trigger) {\n      case 'date':\n        return moment(args['date']).format('YYYY-MM-DD HH:mm:ss');\n      case 'cron':\n        const {rule, start, stop} = args['cron'];\n        return JSON.stringify({\n          rule,\n          start: start ? moment(start).format('YYYY-MM-DD HH:mm:ss') : null,\n          stop: stop ? moment(stop).format('YYYY-MM-DD HH:mm:ss') : null\n        });\n      default:\n        return args[trigger];\n    }\n  }\n\n  function _fetchNextRunTime() {\n    if (trigger === 'cron') {\n      const rule = lds.get(args, 'cron.rule');\n      if (rule && rule.trim().split(/ +/).length === 5) {\n        setNextRunTime(<LoadingOutlined/>);\n        lastFetchId += 1;\n        const fetchId = lastFetchId;\n        const args = _parse_args();\n        http.post('/api/schedule/run_time/', JSON.parse(args))\n          .then(res => {\n            if (fetchId !== lastFetchId) return;\n            if (res.success) {\n              setNextRunTime(<span style={{fontSize: 12, color: '#52c41a'}}>{res.msg}</span>)\n            } else {\n              setNextRunTime(<span style={{fontSize: 12, color: '#ff4d4f'}}>{res.msg}</span>)\n            }\n          })\n      } else {\n        setNextRunTime(null)\n      }\n    }\n  }\n\n  return (\n    <Form layout=\"vertical\" wrapperCol={{span: 14, offset: 6}}>\n      <Form.Item>\n        <Tabs activeKey={trigger} onChange={setTrigger} tabPosition=\"left\" style={{minHeight: 200}}>\n          <Tabs.TabPane tab=\"普通间隔\" key=\"interval\">\n            <Form.Item required label=\"间隔时间(秒)\" extra=\"每隔指定n秒执行一次。\">\n              <InputNumber\n                style={{width: 200}}\n                placeholder=\"请输入\"\n                value={args['interval']}\n                onChange={v => handleArgs('interval', v)}/>\n            </Form.Item>\n          </Tabs.TabPane>\n          <Tabs.TabPane tab=\"一次性\" key=\"date\">\n            <Form.Item required label=\"执行时间\" extra=\"仅在指定时间运行一次。\">\n              <DatePicker\n                showTime\n                disabledDate={v => v && v.format('YYYY-MM-DD') < moment().format('YYYY-MM-DD')}\n                style={{width: 200}}\n                placeholder=\"请选择执行时间\"\n                onOk={() => false}\n                value={args['date'] ? moment(args['date']) : undefined}\n                onChange={v => handleArgs('date', v)}/>\n            </Form.Item>\n          </Tabs.TabPane>\n          <Tabs.TabPane tab=\"UNIX Cron\" key=\"cron\">\n            <Form.Item required label=\"执行规则\" extra=\"兼容Cron风格，可参考官方例子\">\n              <Input\n                suffix={nextRunTime || <span/>}\n                value={lds.get(args, 'cron.rule')}\n                placeholder=\"例如每天凌晨1点执行：0 1 * * *\"\n                onChange={e => handleCronArgs('rule', e.target.value)}/>\n            </Form.Item>\n            <Form.Item label=\"生效时间\" extra=\"定义的执行规则在到达该时间后生效\">\n              <DatePicker\n                showTime\n                style={{width: '100%'}}\n                placeholder=\"可选输入\"\n                value={lds.get(args, 'cron.start') ? moment(args['cron']['start']) : undefined}\n                onChange={v => handleCronArgs('start', v)}/>\n            </Form.Item>\n            <Form.Item label=\"结束时间\" extra=\"执行规则在到达该时间后不再执行\">\n              <DatePicker\n                showTime\n                style={{width: '100%'}}\n                placeholder=\"可选输入\"\n                value={lds.get(args, 'cron.stop') ? moment(args['cron']['stop']) : undefined}\n                onChange={v => handleCronArgs('stop', v)}/>\n            </Form.Item>\n          </Tabs.TabPane>\n        </Tabs>\n      </Form.Item>\n      <Form.Item wrapperCol={{span: 14, offset: 6}}>\n        <Button type=\"primary\" loading={loading} disabled={!args[trigger]} onClick={handleSubmit}>提交</Button>\n        <Button style={{marginLeft: 20}} onClick={() => store.page -= 1}>上一步</Button>\n      </Form.Item>\n    </Form>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/schedule/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { DownOutlined, PlusOutlined } from '@ant-design/icons';\nimport { Modal, Tag, Dropdown, Menu, Radio, message } from 'antd';\nimport { LinkButton, Action, TableCard, AuthButton } from 'components';\nimport { http } from 'libs';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  colors = ['orange', 'green', 'red'];\n\n  moreMenus = (info) => (\n    <Menu>\n      <Menu.Item>\n        <LinkButton auth=\"schedule.schedule.edit\" onClick={() => this.handleTest(info)}>执行测试</LinkButton>\n      </Menu.Item>\n      <Menu.Item>\n        <LinkButton\n          auth=\"schedule.schedule.edit\"\n          onClick={() => this.handleActive(info)}>\n          {info.is_active ? '禁用任务' : '激活任务'}</LinkButton>\n      </Menu.Item>\n      <Menu.Item>\n        <LinkButton onClick={() => store.showRecord(info)}>历史记录</LinkButton>\n      </Menu.Item>\n      <Menu.Divider/>\n      <Menu.Item>\n        <LinkButton danger auth=\"schedule.schedule.del\" onClick={() => this.handleDelete(info)}>删除</LinkButton>\n      </Menu.Item>\n    </Menu>\n  );\n\n  columns = [{\n    title: '任务名称',\n    dataIndex: 'name',\n  }, {\n    title: '任务类型',\n    dataIndex: 'type',\n  }, {\n    title: '最新状态',\n    render: info => {\n      if (info.is_active) {\n        if (info['latest_status_alias']) {\n          return <Tag color={this.colors[info['latest_status']]}>{info['latest_status_alias']}</Tag>\n        } else {\n          return <Tag color=\"blue\">待调度</Tag>\n        }\n      } else {\n        return <Tag>未激活</Tag>\n      }\n    },\n  }, {\n    title: '更新于',\n    dataIndex: 'latest_run_time_alias',\n    sorter: (a, b) => a.latest_run_time.localeCompare(b.latest_run_time)\n  }, {\n    title: '描述信息',\n    dataIndex: 'desc',\n    ellipsis: true\n  }, {\n    title: '操作',\n    width: 180,\n    render: info => (\n      <Action>\n        <Action.Button disabled={info['latest_run_time'] === '1970-01-01'}\n                       onClick={() => store.showInfo(info)}>详情</Action.Button>\n        <Action.Button auth=\"schedule.schedule.edit\" onClick={() => store.showForm(info)}>编辑</Action.Button>\n        <Dropdown overlay={() => this.moreMenus(info)} trigger={['click']}>\n          <LinkButton>\n            更多 <DownOutlined/>\n          </LinkButton>\n        </Dropdown>\n      </Action>\n    )\n  }];\n\n  handleActive = (text) => {\n    Modal.confirm({\n      title: '操作确认',\n      content: `确定要${text.is_active ? '禁用' : '激活'}任务【${text['name']}】?`,\n      onOk: () => {\n        return http.patch('/api/schedule/', {id: text.id, is_active: !text.is_active})\n          .then(() => {\n            message.success('操作成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/schedule/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  handleTest = (text) => {\n    Modal.confirm({\n      title: '操作确认',\n      content: '立即以串行模式执行该任务（不影响调度规则，且不会触发失败通知，测试执行会有120秒的超时，真实调度执行无此限制）？',\n      onOk: () => http.post(`/api/schedule/${text.id}/`, null, {timeout: 120000})\n        .then(res => store.showInfo(text, res))\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"si\"\n        rowKey=\"id\"\n        title=\"任务列表\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton\n            auth=\"schedule.schedule.add\"\n            type=\"primary\"\n            icon={<PlusOutlined/>}\n            onClick={() => store.showForm()}>新建</AuthButton>,\n          <Radio.Group value={store.f_active} onChange={e => store.f_active = e.target.value}>\n            <Radio.Button value=\"\">全部</Radio.Button>\n            <Radio.Button value=\"1\">已激活</Radio.Button>\n            <Radio.Button value=\"0\">未激活</Radio.Button>\n          </Radio.Group>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}\n        columns={this.columns}/>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/schedule/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input, Select } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport Info from './Info';\nimport Record from './Record';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"schedule.schedule.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>任务计划</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={6} title=\"状态\">\n          <Select allowClear value={store.f_status} onChange={v => store.f_status = v} placeholder=\"请选择\">\n            <Select.Option value={-1}>待调度</Select.Option>\n            <Select.Option value={0}>执行中</Select.Option>\n            <Select.Option value={1}>成功</Select.Option>\n            <Select.Option value={2}>失败</Select.Option>\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={6} title=\"类型\">\n          <Select allowClear value={store.f_type} onChange={v => store.f_type = v} placeholder=\"请选择\">\n            {store.types.map(item => (\n              <Select.Option value={item} key={item}>{item}</Select.Option>\n            ))}\n          </Select>\n        </SearchForm.Item>\n        <SearchForm.Item span={6} title=\"名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n      {store.infoVisible && <Info/>}\n      {store.recordVisible && <Record/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/schedule/index.module.css",
    "content": ".steps {\n    width: 520px;\n    margin: 0 auto 30px;\n}\n\n.delIcon {\n    font-size: 24px;\n    position: relative;\n    top: 4px;\n    color: #999999;\n}\n\n.delIcon:hover {\n    color: #f5222d;\n}"
  },
  {
    "path": "spug_web/src/pages/schedule/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\nimport moment from \"moment\";\n\nclass Store {\n  @observable records = [];\n  @observable types = [];\n  @observable record = {};\n  @observable page = 0;\n  @observable targets = [undefined];\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable infoVisible = false;\n  @observable recordVisible = false;\n\n  @observable f_status;\n  @observable f_active = '';\n  @observable f_name;\n  @observable f_type;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_active) records = records.filter(x => x.is_active === (this.f_active === '1'));\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    if (this.f_type) records = records.filter(x => x.type.toLowerCase().includes(this.f_type.toLowerCase()));\n    if (this.f_status !== undefined) {\n      if (this.f_status === -1) {\n        records = records.filter(x => x.is_active && !x.latest_status_alias);\n      } else {\n        records = records.filter(x => x.latest_status === this.f_status)\n      }\n    }\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/schedule/')\n      .then(res => {\n        res.tasks.map(item => {\n          const value = item['latest_run_time'];\n          item['latest_run_time_alias'] = value ? moment(value).fromNow() : null;\n          item['latest_run_time'] = value || '1970-01-01';\n          return null\n        });\n        this.records = res.tasks;\n        this.types = res.types\n      })\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info) => {\n    this.page = 0;\n    this.record = info || {interpreter: 'sh', rst_notify: {mode: '0'}, trigger: 'interval'};\n    this.formVisible = true\n  };\n\n  showInfo = (info, h_id = 'latest') => {\n    if (info) this.record = info;\n    this.record.h_id = h_id;\n    this.infoVisible = true\n  };\n\n  showRecord = (info) => {\n    this.recordVisible = true;\n    this.record = info\n  };\n\n  editTarget = (index, v) => {\n    this.targets[index] = v\n  };\n\n  delTarget = (index) => {\n    this.targets.splice(index, 1)\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/ssh/FileManager.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Breadcrumb, Table, Switch, Progress, Modal, Input, message } from 'antd';\nimport {\n  DeleteOutlined,\n  DownloadOutlined,\n  FileOutlined,\n  FolderOutlined,\n  HomeOutlined,\n  UploadOutlined,\n  EditOutlined\n} from '@ant-design/icons';\nimport { AuthButton, Action } from 'components';\nimport { http, uniqueId, X_TOKEN } from 'libs';\nimport lds from 'lodash';\nimport styles from './index.module.less'\nimport moment from 'moment';\n\n\nclass FileManager extends React.Component {\n  constructor(props) {\n    super(props);\n    this.input = null;\n    this.pwdHistoryCaches = new Map()\n    this.state = {\n      fetching: false,\n      showDot: false,\n      uploading: false,\n      inputPath: null,\n      uploadStatus: 'active',\n      pwd: [],\n      objects: [],\n      percent: 0\n    }\n  }\n\n  componentDidMount() {\n    this.fetchFiles()\n  }\n\n  componentDidUpdate(prevProps) {\n    if (this.props.id !== prevProps.id) {\n      let pwd = this.pwdHistoryCaches.get(this.props.id) || []\n      this.setState({objects: [], pwd})\n      this.fetchFiles(pwd)\n    }\n  }\n\n  columns = [{\n    title: '名称',\n    key: 'name',\n    render: info => info.kind === 'd' ? (\n      <div onClick={() => this.handleChdir(info.name, '1')} style={{cursor: 'pointer'}}>\n        <FolderOutlined style={{color: info.is_link ? '#008b8b' : '#2563fc'}}/>\n        <span style={{color: info.is_link ? '#008b8b' : '#2563fc', paddingLeft: 5}}>{info.name}</span>\n      </div>\n    ) : (\n      <React.Fragment>\n        <FileOutlined/>\n        <span style={{paddingLeft: 5}}>{info.name}</span>\n      </React.Fragment>\n    ),\n    ellipsis: true\n  }, {\n    title: '大小',\n    dataIndex: 'size',\n    align: 'right',\n    className: styles.fileSize,\n    width: 90\n  }, {\n    title: '修改时间',\n    dataIndex: 'date',\n    sorter: (a, b) => moment(a.date).unix() - moment(b.date).unix(),\n    width: 190\n  }, {\n    title: '属性',\n    dataIndex: 'code',\n    width: 110\n  }, {\n    title: '操作',\n    width: 100,\n    align: 'right',\n    key: 'action',\n    render: info => info.kind === '-' ? (\n      <Action>\n        <Action.Button className={styles.drawerBtn} icon={<DownloadOutlined/>}\n                       onClick={() => this.handleDownload(info.name)}/>\n        <Action.Button danger auth=\"host.console.del\" className={styles.drawerBtn} icon={<DeleteOutlined/>}\n                       onClick={() => this.handleDelete(info.name)}/>\n      </Action>\n    ) : null\n  }];\n\n  _kindSort = (item) => {\n    return item.kind === 'd'\n  };\n\n  fetchFiles = (pwd) => {\n    this.setState({ fetching: true });\n    pwd = pwd || this.state.pwd;\n    const path = '/' + pwd.join('/');\n    return http.get('/api/file/', {params: {id: this.props.id, path}})\n      .then(res => {\n        const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);\n        this.setState({objects, pwd})\n        this.pwdHistoryCaches.set(this.props.id, pwd)\n        this.state.inputPath !== null && this.setState({inputPath: path})\n      })\n      .finally(() => this.setState({fetching: false}))\n  };\n\n  handleChdir = (name, action) => {\n    let pwd = this.state.pwd.map(x => x);\n    if (action === '1') {\n      pwd.push(name)\n      this.setState({inputPath: null})\n    } else if (action === '2') {\n      const index = pwd.indexOf(name);\n      pwd = pwd.splice(0, index + 1)\n    } else {\n      pwd = []\n    }\n    this.fetchFiles(pwd)\n  };\n\n  handleInputEdit = () => {\n    let inputPath = '/' + this.state.pwd.join('/')\n    this.setState({inputPath})\n  }\n\n  handleInputEnter = () => {\n    if (this.state.inputPath) {\n      let pwdStr = this.state.inputPath.replace(/^\\/+/, '')\n      pwdStr = pwdStr.replace(/\\/+$/, '')\n      this.fetchFiles(pwdStr.split('/'))\n        .then(() => this.setState({inputPath: null}))\n    } else {\n      this.setState({inputPath: null})\n    }\n  }\n\n  handleUpload = () => {\n    this.input.click();\n    this.input.onchange = e => {\n      this.setState({uploading: true, uploadStatus: 'active', percent: 0});\n      const file = e.target['files'][0];\n      const formData = new FormData();\n      const token = uniqueId();\n      this._updatePercent(token);\n      formData.append('file', file);\n      formData.append('id', this.props.id);\n      formData.append('token', token);\n      formData.append('path', '/' + this.state.pwd.join('/'));\n      this.input.value = '';\n      http.post('/api/file/object/', formData, {timeout: 600000, onUploadProgress: this._updateLocal})\n        .then(() => {\n          this.setState({uploadStatus: 'success'});\n          this.fetchFiles()\n        }, () => this.setState({uploadStatus: 'exception'}))\n        .finally(() => setTimeout(() => this.setState({uploading: false}), 2000))\n    }\n  };\n\n  _updateLocal = (e) => {\n    const percent = e.loaded / e.total * 100 / 2\n    this.setState({percent: Number(percent.toFixed(1))})\n  }\n\n  _updatePercent = token => {\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${token}/?x-token=${X_TOKEN}`);\n    this.socket.onopen = () => this.socket.send('ok');\n    this.socket.onmessage = e => {\n      if (e.data === 'pong') {\n        this.socket.send('ping')\n      } else {\n        const percent = this.state.percent + Number(e.data) / 2;\n        if (percent > this.state.percent) this.setState({percent: Number(percent.toFixed(1))});\n        if (percent === 100) {\n          this.socket.close()\n        }\n      }\n    }\n  };\n\n  handleDownload = (name) => {\n    const file = `/${this.state.pwd.join('/')}/${name}`;\n    const link = document.createElement('a');\n    link.download = name;\n    link.href = `/api/file/object/?id=${this.props.id}&file=${file}&x-token=${X_TOKEN}`;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    message.warning('即将开始下载，请勿重复点击。')\n  };\n\n  handleDelete = (name) => {\n    const file = `/${this.state.pwd.join('/')}/${name}`;\n    Modal.confirm({\n      title: '删除文件确认',\n      content: `确认删除文件：${file} ?`,\n      onOk: () => {\n        return http.delete('/api/file/object/', {params: {id: this.props.id, file}})\n          .then(() => {\n            message.success('删除成功');\n            this.fetchFiles()\n          })\n      }\n    })\n  };\n\n  render() {\n    let objects = this.state.objects;\n    if (!this.state.showDot) {\n      objects = objects.filter(x => !x.name.startsWith('.'))\n    }\n    const scrollY = document.body.clientHeight - 168;\n    return (\n      <React.Fragment>\n        <input style={{display: 'none'}} type=\"file\" ref={ref => this.input = ref}/>\n        <div className={styles.drawerHeader}>\n          {this.state.inputPath !== null ? (\n            <Input size=\"small\" className={styles.input}\n                   suffix={<div style={{color: '#999', fontSize: 12}}>回车确认</div>}\n                   value={this.state.inputPath} onChange={e => this.setState({inputPath: e.target.value})}\n                   onBlur={this.handleInputEnter}\n                   onPressEnter={this.handleInputEnter}/>\n          ) : (\n            <Breadcrumb className={styles.bread}>\n              <Breadcrumb.Item href=\"#\" onClick={() => this.handleChdir('', '0')}>\n                <HomeOutlined style={{fontSize: 16}}/>\n              </Breadcrumb.Item>\n              {this.state.pwd.map(item => (\n                <Breadcrumb.Item key={item} href=\"#\" onClick={() => this.handleChdir(item, '2')}>\n                  <span>{item}</span>\n                </Breadcrumb.Item>\n              ))}\n              <Breadcrumb.Item onClick={this.handleInputEdit}>\n                <EditOutlined className={styles.edit}/>\n              </Breadcrumb.Item>\n            </Breadcrumb>\n          )}\n\n          <div className={styles.action}>\n            <span>显示隐藏文件：</span>\n            <Switch\n              checked={this.state.showDot}\n              checkedChildren=\"开启\"\n              unCheckedChildren=\"关闭\"\n              onChange={v => this.setState({showDot: v})}/>\n            {this.state.uploading ? (\n              <Progress className={styles.progress} strokeWidth={14} status={this.state.uploadStatus}\n                        percent={this.state.percent}/>\n            ) : (\n              <AuthButton\n                auth=\"host.console.upload\"\n                style={{marginLeft: 12}}\n                size=\"small\"\n                type=\"primary\"\n                icon={<UploadOutlined/>}\n                onClick={this.handleUpload}>上传文件</AuthButton>\n            )}\n          </div>\n        </div>\n        <Table\n          size=\"small\"\n          rowKey=\"name\"\n          loading={this.state.fetching}\n          pagination={false}\n          columns={this.columns}\n          scroll={{y: scrollY}}\n          style={{fontFamily: 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei'}}\n          dataSource={objects}/>\n      </React.Fragment>\n    )\n  }\n}\n\nexport default FileManager"
  },
  {
    "path": "spug_web/src/pages/ssh/Setting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { Drawer, Form, Button, Select, Space, message } from 'antd';\nimport themes from './themes';\nimport gStore from 'gStore';\nimport css from './setting.module.less'\n\nfunction Setting(props) {\n  const [theme, setTheme] = useState('dark')\n  const [styles, setStyles] = useState(themes['dark'])\n  const [fontSize, setFontSize] = useState(14)\n  const [fontFamily, setFontFamily] = useState('Courier')\n  const [loading, setLoading] = useState(false)\n\n  useEffect(() => {\n    const {theme, styles, fontSize, fontFamily} = gStore.terminal\n    setTheme(theme)\n    setStyles(styles)\n    setFontSize(fontSize)\n    setFontFamily(fontFamily)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gStore.terminal])\n\n  useEffect(() => {\n    setStyles(themes[theme])\n  }, [theme])\n\n  function handleSubmit() {\n    setLoading(true)\n    const data = {fontSize, fontFamily, theme}\n    gStore.updateUserSettings('terminal', JSON.stringify(data))\n      .then(() => {\n        message.success('已保存')\n        props.onClose()\n      })\n      .finally(() => setLoading(false))\n  }\n\n  return (<Drawer\n      title=\"终端设置\"\n      placement=\"right\"\n      width={300}\n      visible={props.visible}\n      onClose={props.onClose}>\n      <Form layout=\"vertical\">\n        <Form.Item label=\"字体大小\">\n          <Select value={fontSize} placeholder=\"请选择字体大小\" onChange={v => setFontSize(v)}>\n            <Select.Option value={12}>12</Select.Option>\n            <Select.Option value={14}>14</Select.Option>\n            <Select.Option value={16}>16</Select.Option>\n            <Select.Option value={18}>18</Select.Option>\n            <Select.Option value={20}>20</Select.Option>\n          </Select>\n        </Form.Item>\n        <Form.Item label=\"字体名称\">\n          <Select value={fontFamily} placeholder=\"请选择字体\" onChange={v => setFontFamily(v)}>\n            <Select.Option value=\"Courier\">Courier</Select.Option>\n            <Select.Option value=\"Consolas\">Consolas</Select.Option>\n            <Select.Option value=\"DejaVu Sans Mono\">DejaVu Sans Mono</Select.Option>\n            <Select.Option value=\"Droid Sans Mono\">Droid Sans Mono</Select.Option>\n            <Select.Option value=\"Monaco\">Monaco</Select.Option>\n            <Select.Option value=\"Menlo\">Menlo</Select.Option>\n            <Select.Option value=\"monospace\">monospace</Select.Option>\n            <Select.Option value=\"Source Code Pro\">Source Code Pro</Select.Option>\n          </Select>\n        </Form.Item>\n        <Form.Item label=\"主题配色\">\n          <Space wrap className={css.theme} size={12}>\n            {Object.entries(themes).map(([key, item]) => (\n              <pre key={key} style={{background: item.background, color: item.foreground}}\n                   onClick={() => setTheme(key)}>spug</pre>))}\n          </Space>\n        </Form.Item>\n        <Form.Item label=\"预览\">\n          <div className={css.preview}\n               style={{fontSize, fontFamily, background: styles.background, color: styles.foreground}}>\n            <div>Welcome to Spug !</div>\n            <div>* Website: https://spug.cc</div>\n            <div>[root@iZ8vb48roZ ~]# ls</div>\n            <div>\n              <span style={{color: styles.brightBlue}}>apps </span>\n              <span style={{color: styles.brightRed}}>bak.tar.gz </span>\n              <span style={{color: styles.brightGreen}}>manage.py </span>\n              <span>README.md</span>\n            </div>\n            <div>[root@iZ8vb48roZ ~]# pwd</div>\n            <div>/data/api</div>\n            <div>[root@iZ8vb48roZ ~]#</div>\n          </div>\n        </Form.Item>\n        <Button block type=\"primary\" className={css.btn} loading={loading} onClick={handleSubmit}>保存</Button>\n      </Form>\n    </Drawer>)\n}\n\nexport default Setting"
  },
  {
    "path": "spug_web/src/pages/ssh/Terminal.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState, useRef, useLayoutEffect } from 'react';\nimport { Terminal } from 'xterm';\nimport { FitAddon } from 'xterm-addon-fit';\nimport { X_TOKEN } from 'libs';\nimport 'xterm/css/xterm.css';\nimport styles from './index.module.less';\nimport gStore from 'gStore';\n\nfunction WebSSH(props) {\n  const container = useRef();\n  const [term] = useState(new Terminal());\n  const [fitPlugin] = useState(new FitAddon());\n\n  useEffect(() => {\n    term.loadAddon(fitPlugin);\n    term.setOption('fontSize', gStore.terminal.fontSize)\n    term.setOption('fontFamily', gStore.terminal.fontFamily)\n    term.setOption('theme', gStore.terminal.styles)\n    term.attachCustomKeyEventHandler((arg) => {\n      if (arg.code === 'PageUp' && arg.type === 'keydown') {\n        term.scrollPages(-1)\n        return false\n      } else if (arg.code === 'PageDown' && arg.type === 'keydown') {\n        term.scrollPages(1)\n        return false\n      }\n      return true\n    })\n    term.open(container.current);\n    term.write('WebSocket connecting ... ');\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/ssh/${props.id}/?x-token=${X_TOKEN}`);\n    socket.onmessage = e => term.write(e.data)\n    socket.onopen = () => {\n      term.write('ok')\n      term.focus();\n      fitTerminal();\n    };\n    socket.onclose = e => {\n      setTimeout(() => term.write('\\r\\n\\r\\n\\x1b[31mConnection is closed.\\x1b[0m\\r\\n'), 200)\n    };\n    term.onData(data => socket.send(JSON.stringify({data})));\n    term.onResize(({cols, rows}) => {\n      if (socket.readyState === 1) {\n        socket.send(JSON.stringify({resize: [cols, rows]}))\n      }\n    });\n    window.addEventListener('resize', fitTerminal)\n\n    return () => {\n      window.removeEventListener('resize', fitTerminal);\n      if (socket) socket.close()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    term.setOption('fontSize', gStore.terminal.fontSize)\n    term.setOption('fontFamily', gStore.terminal.fontFamily)\n    term.setOption('theme', gStore.terminal.styles)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gStore.terminal])\n\n  useEffect(() => {\n    if (props.vId === props.activeId) {\n      setTimeout(() => term.focus())\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [props.activeId])\n\n  useLayoutEffect(fitTerminal)\n\n  function fitTerminal() {\n    if (props.vId === props.activeId) {\n      const dims = fitPlugin.proposeDimensions();\n      if (!dims || !term || !dims.cols || !dims.rows) return;\n      if (term.rows !== dims.rows || term.cols !== dims.cols) {\n        term._core._renderService.clear();\n        term.resize(dims.cols, dims.rows);\n      }\n    }\n  }\n\n  return (\n    <div className={styles.terminal} ref={container}/>\n  )\n}\n\nexport default WebSSH"
  },
  {
    "path": "spug_web/src/pages/ssh/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useEffect, useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Tabs, Tree, Input, Spin, Dropdown, Menu, Button, Drawer } from 'antd';\nimport {\n  FolderOutlined,\n  FolderOpenOutlined,\n  CloudServerOutlined,\n  SearchOutlined,\n  SyncOutlined,\n  CopyOutlined,\n  ReloadOutlined,\n  VerticalAlignBottomOutlined,\n  VerticalAlignMiddleOutlined,\n  CloseOutlined,\n  LeftOutlined,\n  SkinFilled,\n} from '@ant-design/icons';\nimport { NotFound, AuthButton } from 'components';\nimport Terminal from './Terminal';\nimport FileManager from './FileManager';\nimport Setting from './Setting';\nimport { http, hasPermission, includes } from 'libs';\nimport gStore from 'gStore';\nimport styles from './index.module.less';\nimport LogoSpugText from 'layout/logo-spug-white.png';\nimport lds from 'lodash';\n\nlet posX = 0\n\nfunction WebSSH(props) {\n  const [visible, setVisible] = useState(false);\n  const [visible2, setVisible2] = useState(false);\n  const [fetching, setFetching] = useState(true);\n  const [rawTreeData, setRawTreeData] = useState([]);\n  const [rawHostList, setRawHostList] = useState([]);\n  const [treeData, setTreeData] = useState([]);\n  const [searchValue, setSearchValue] = useState();\n  const [hosts, setHosts] = useState([]);\n  const [activeId, setActiveId] = useState();\n  const [hostId, setHostId] = useState();\n  const [width, setWidth] = useState(280);\n  const [sshMode] = useState(hasPermission('host.console.view'))\n\n  useEffect(() => {\n    window.document.title = 'Spug web terminal'\n    window.addEventListener('beforeunload', leaveTips)\n    fetchNodes()\n    gStore.fetchUserSettings()\n    return () => window.removeEventListener('beforeunload', leaveTips)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    if (searchValue) {\n      const newTreeData = rawHostList.filter(x => includes([x.title, x.hostname], searchValue))\n      setTreeData(newTreeData)\n    } else {\n      setTreeData(rawTreeData)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [searchValue])\n\n  function leaveTips(e) {\n    e.returnValue = '确定要离开页面？'\n  }\n\n  function fetchNodes() {\n    setFetching(true)\n    http.get('/api/host/group/?with_hosts=1')\n      .then(res => {\n        const tmp = {}\n        setRawTreeData(res.treeData)\n        setTreeData(res.treeData)\n        const loop = (data) => {\n          for (let item of data) {\n            if (item.children) {\n              loop(item.children)\n            } else if (item.isLeaf) {\n              tmp[item.id] = item\n            }\n          }\n        }\n        loop(res.treeData)\n        setRawHostList(Object.values(tmp))\n        const query = new URLSearchParams(props.location.search);\n        const id = query.get('id');\n        if (id) {\n          const node = lds.find(Object.values(tmp), {id: Number(id)})\n          if (node) _openNode(node)\n        }\n      })\n      .finally(() => setFetching(false))\n  }\n\n  function _openNode(node, replace) {\n    const newNode = {...node}\n    newNode.vId = String(new Date().getTime())\n    if (replace) {\n      const index = lds.findIndex(hosts, {vId: node.vId})\n      if (index >= 0) hosts[index] = newNode\n    } else {\n      hosts.push(newNode);\n    }\n    setHosts(lds.cloneDeep(hosts))\n    setActiveId(newNode.vId)\n  }\n\n  function handleSelect(e) {\n    if (e.nativeEvent.detail > 1 && e.node.isLeaf) {\n      _openNode(e.node)\n    }\n  }\n\n  function handleRemove(key, target) {\n    const index = lds.findIndex(hosts, x => x.vId === key);\n    if (index === -1) return;\n    switch (target) {\n      case 'self':\n        hosts.splice(index, 1)\n        setHosts([...hosts])\n        if (hosts.length > index) {\n          setActiveId(hosts[index].vId)\n        } else if (hosts.length) {\n          setActiveId(hosts[index - 1].vId)\n        } else {\n          setActiveId(undefined)\n        }\n        break\n      case 'right':\n        hosts.splice(index + 1, hosts.length)\n        setHosts([...hosts])\n        setActiveId(key)\n        break\n      case 'other':\n        setHosts([hosts[index]])\n        setActiveId(key)\n        break\n      case 'all':\n        setHosts([])\n        setActiveId(undefined)\n        break\n      default:\n        break\n    }\n  }\n\n  function handleOpenFileManager() {\n    const index = lds.findIndex(hosts, x => x.vId === activeId);\n    if (index !== -1) {\n      setHostId(hosts[index].id)\n      setVisible(true)\n    }\n  }\n\n  function renderIcon(node) {\n    if (node.isLeaf) {\n      return <CloudServerOutlined/>\n    } else if (node.expanded) {\n      return <FolderOpenOutlined/>\n    } else {\n      return <FolderOutlined/>\n    }\n  }\n\n  function handleMouseMove(e) {\n    if (posX) {\n      setWidth(e.pageX);\n    }\n  }\n\n  function handeTabAction(action, host, e) {\n    if (e) e.stopPropagation()\n    switch (action) {\n      case 'copy':\n        return _openNode(host)\n      case 'reconnect':\n        return _openNode(host, true)\n      case 'rClose':\n        return handleRemove(host.vId, 'right')\n      case 'oClose':\n        return handleRemove(host.vId, 'other')\n      case 'aClose':\n        return handleRemove(host.vId, 'all')\n      default:\n        break\n    }\n  }\n\n  function TabRender(props) {\n    const host = props.host;\n    return (\n      <Dropdown trigger={['contextMenu']} overlay={(\n        <Menu onClick={({key, domEvent}) => handeTabAction(key, host, domEvent)}>\n          <Menu.Item key=\"copy\" icon={<CopyOutlined/>}>复制窗口</Menu.Item>\n          <Menu.Item key=\"reconnect\" icon={<ReloadOutlined/>}>重新连接</Menu.Item>\n          <Menu.Item key=\"rClose\"\n                     icon={<VerticalAlignBottomOutlined style={{transform: 'rotate(90deg)'}}/>}>关闭右侧</Menu.Item>\n          <Menu.Item key=\"oClose\"\n                     icon={<VerticalAlignMiddleOutlined style={{transform: 'rotate(90deg)'}}/>}>关闭其他</Menu.Item>\n          <Menu.Item key=\"aClose\" icon={<CloseOutlined/>}>关闭所有</Menu.Item>\n        </Menu>\n      )}>\n        <div className={styles.tabRender} onDoubleClick={() => handeTabAction('copy', host)}>{host.title}</div>\n      </Dropdown>\n    )\n  }\n\n  const spug_web_terminal =\n    '                                                 __       __                          _                __\\n' +\n    '   _____ ____   __  __ ____ _   _      __ ___   / /_     / /_ ___   _____ ____ ___   (_)____   ____ _ / /\\n' +\n    '  / ___// __ \\\\ / / / // __ `/  | | /| / // _ \\\\ / __ \\\\   / __// _ \\\\ / ___// __ `__ \\\\ / // __ \\\\ / __ `// / \\n' +\n    ' (__  )/ /_/ // /_/ // /_/ /   | |/ |/ //  __// /_/ /  / /_ /  __// /   / / / / / // // / / // /_/ // /  \\n' +\n    '/____// .___/ \\\\__,_/ \\\\__, /    |__/|__/ \\\\___//_.___/   \\\\__/ \\\\___//_/   /_/ /_/ /_//_//_/ /_/ \\\\__,_//_/   \\n' +\n    '     /_/            /____/                                                                               \\n'\n\n  return hasPermission('host.console.view|host.console.list') ? (\n    <div className={styles.container} onMouseUp={() => posX = 0} onMouseMove={handleMouseMove}>\n      <div className={styles.sider} style={{width}}>\n        <a className={styles.logo} href=\"/host\" target=\"_blank\">\n          <img src={LogoSpugText} alt=\"logo\"/>\n        </a>\n        <div className={styles.hosts}>\n          <Spin spinning={fetching}>\n            <Input allowClear className={styles.search} prefix={<SearchOutlined style={{color: '#999'}}/>}\n                   placeholder=\"输入主机名/IP检索\" onChange={e => setSearchValue(e.target.value)}/>\n            <Button icon={<SyncOutlined/>} type=\"link\" loading={fetching} onClick={fetchNodes}/>\n            {treeData.length > 0 ? (\n              <Tree.DirectoryTree\n                defaultExpandAll={treeData.length > 0 && treeData.length < 5}\n                expandAction=\"doubleClick\"\n                treeData={treeData}\n                icon={renderIcon}\n                onSelect={(k, e) => handleSelect(e)}/>\n            ) : null}\n          </Spin>\n        </div>\n        <div className={styles.split} onMouseDown={e => posX = e.pageX}/>\n      </div>\n      <div className={styles.content}>\n        <Tabs\n          hideAdd\n          activeKey={activeId}\n          type=\"editable-card\"\n          onTabClick={key => setActiveId(key)}\n          onEdit={(key, action) => action === 'remove' ? handleRemove(key, 'self') : null}\n          style={{background: '#fff', width: `calc(100vw - ${width}px)`}}\n          tabBarExtraContent={hosts.length === 0 ? (\n            <div className={styles.tips}>小提示：双击标签快速复制窗口，右击标签展开更多操作。</div>\n          ) : sshMode ? (\n            <React.Fragment>\n              <AuthButton\n                auth=\"host.console.list\"\n                type=\"link\"\n                disabled={!activeId}\n                onClick={handleOpenFileManager}\n                icon={<LeftOutlined/>}>文件管理器</AuthButton>\n              <SkinFilled className={styles.setting} onClick={() => setVisible2(true)}/>\n            </React.Fragment>\n          ) : null}>\n          {hosts.map(item => (\n            <Tabs.TabPane key={item.vId} tab={<TabRender host={item}/>}>\n              {sshMode ? (\n                <Terminal id={item.id} vId={item.vId} activeId={activeId}/>\n              ) : (\n                <div className={styles.fileManger}>\n                  <FileManager id={item.id}/>\n                </div>\n              )}\n            </Tabs.TabPane>\n          ))}\n        </Tabs>\n        {hosts.length === 0 && (\n          <pre className={sshMode ? styles.fig : styles.fig2}>{spug_web_terminal}</pre>\n        )}\n      </div>\n      <Drawer\n        title=\"文件管理器\"\n        placement=\"right\"\n        width={900}\n        className={styles.drawerContainer}\n        visible={visible}\n        onClose={() => setVisible(false)}>\n        <FileManager id={hostId}/>\n      </Drawer>\n      <Setting visible={visible2} onClose={() => setVisible2(false)}/>\n    </div>\n  ) : (\n    <div style={{height: '100vh'}}>\n      <NotFound/>\n    </div>\n  )\n}\n\nexport default observer(WebSSH)"
  },
  {
    "path": "spug_web/src/pages/ssh/index.module.less",
    "content": ".container {\n  display: flex;\n  min-height: 100vh;\n\n  .sider {\n    display: flex;\n    flex-direction: column;\n    width: 280px;\n    background-color: #fafafa;\n    position: relative;\n\n    .split {\n      position: absolute;\n      width: 8px;\n      height: 100vh;\n      right: -4px;\n      cursor: ew-resize;\n    }\n\n    .logo {\n      height: 42px;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      background-color: #2563fc;\n\n      img {\n        height: 28px;\n      }\n    }\n\n    .hosts {\n      box-shadow: 2px 2px 2px #e0e0e0;\n\n      :global(.ant-tree-node-content-wrapper) {\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n\n      :global(.ant-tree) {\n        background-color: #fafafa;\n        height: calc(100vh - 98px);\n        overflow: auto;\n      }\n\n      .search {\n        margin: 12px 10px;\n        width: calc(100% - 60px);\n      }\n    }\n  }\n\n  .content {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    background: #eeeeee;\n\n    .tips {\n      position: absolute;\n      top: 12px;\n      left: 12px;\n      font-size: 12px;\n      color: #666;\n    }\n\n    .fig {\n      flex: 1;\n      background-color: #2b2b2b;\n      color: #A9B7C6;\n      margin: 12px;\n      padding-top: 200px;\n      text-align: center;\n      border-radius: 6px;\n    }\n\n    .fig2 {\n      flex: 1;\n      background-color: #fff;\n      color: #2b2b2b;\n      margin: 12px;\n      padding-top: 200px;\n      text-align: center;\n      border-radius: 6px;\n    }\n\n    .tabRender {\n      user-select: none;\n      padding: 8px 8px 8px 16px;\n      margin: 0 -8px 0 -16px;\n      color: #2563fc;\n    }\n\n    .fileManger {\n      margin: 12px;\n      padding: 12px;\n      border-radius: 6px;\n      background: #fff;\n      height: calc(100vh - 66px);\n    }\n\n    .setting {\n      cursor: pointer;\n      padding-right: 6px;\n      margin-right: 6px;\n      color: #fa8c16;\n    }\n\n    :global(.ant-tabs-nav) {\n      height: 42px;\n      margin: 0;\n      padding-left: 12px;\n    }\n\n    :global(.ant-tabs-nav:before) {\n      border: none;\n    }\n\n    :global(.ant-tabs-tab) {\n      border: none;\n      background: #fff;\n    }\n\n    :global(.ant-tabs-tab-active) {\n      border-bottom: 2px solid #2563fc !important;\n      transition: unset;\n    }\n\n    :global(.ant-tabs-content) {\n      background: #eeeeee;\n    }\n  }\n}\n\n.terminal {\n  margin: 12px;\n\n  :global(.xterm) {\n    padding: 10px 0 6px 10px;\n    height: calc(100vh - 66px);\n  }\n\n  :global(.xterm-viewport) {\n    border-radius: 6px;\n  }\n}\n\n\n.fileSize {\n  padding-right: 24px !important;\n}\n\n.drawerContainer {\n  :global(.ant-drawer-body) {\n    padding: 10px 16px;\n  }\n}\n\n.drawerHeader {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 15px;\n  height: 24px;\n\n  .action {\n    display: flex;\n    justify-content: flex-end;\n    align-items: center;\n    height: 24px;\n  }\n\n  .bread:hover {\n    .edit {\n      display: inline-block;\n    }\n  }\n\n  .input {\n    width: 60%;\n    font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n  }\n\n  .edit {\n    display: none;\n    color: #2563fcbb;\n    margin-left: 24px;\n    cursor: pointer;\n  }\n\n  .progress {\n    width: 94px;\n    margin-left: 12px;\n  }\n\n  :global(.ant-breadcrumb) {\n    flex: 1;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    margin-right: 24px;\n  }\n\n  :global(.ant-breadcrumb-separator) {\n    margin: 0 4px;\n    color: rgba(0, 0, 0, 0.85);\n  }\n\n  :global(.ant-breadcrumb-link) {\n    color: rgba(0, 0, 0, 0.85);\n  }\n}\n\n.drawerBtn {\n  height: 22px;\n  width: 22px;\n}"
  },
  {
    "path": "spug_web/src/pages/ssh/setting.module.less",
    "content": ".theme {\n\n  pre {\n    padding: 2px 6px;\n    border-radius: 4px;\n    border: 1px solid #333333;\n    cursor: pointer;\n  }\n}\n\n.preview {\n  border: 1px solid rgba(0, 0, 0, 0.1);\n  padding: 4px;\n  border-radius: 4px;\n  font-family: Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei;\n}\n\n.btn {\n  margin-top: 24px;\n}"
  },
  {
    "path": "spug_web/src/pages/ssh/themes.js",
    "content": "export default {\n  gray: {\n    foreground: '#A9B7C6', background: '#2b2b2b', cursor: '#A9B7C6',\n\n    black: '#1b1b1b', brightBlack: '#626262',\n\n    red: '#bb5653', brightRed: '#bb5653',\n\n    green: '#909d62', brightGreen: '#909d62',\n\n    yellow: '#eac179', brightYellow: '#eac179',\n\n    blue: '#7da9c7', brightBlue: '#7da9c7',\n\n    magenta: '#b06597', brightMagenta: '#b06597',\n\n    cyan: '#8cdcd8', brightCyan: '#8cdcd8',\n\n    white: '#d8d8d8', brightWhite: '#f7f7f7'\n  }, dark: {\n    foreground: '#c7c7c7', background: '#000000', cursor: '#c7c7c7',\n\n    black: '#000000', brightBlack: '#676767',\n\n    red: '#c91b00', brightRed: '#ff6d67',\n\n    green: '#00c200', brightGreen: '#5ff967',\n\n    yellow: '#c7c400', brightYellow: '#fefb67',\n\n    blue: '#0225c7', brightBlue: '#6871ff',\n\n    magenta: '#c930c7', brightMagenta: '#ff76ff',\n\n    cyan: '#00c5c7', brightCyan: '#5ffdff',\n\n    white: '#c7c7c7', brightWhite: '#fffefe'\n  }, ubuntu: {\n    foreground: '#f1f1ef', background: '#3f0e2f', cursor: '#c7c7c7',\n\n    black: '#3c4345', brightBlack: '#676965',\n\n    red: '#d71e00', brightRed: '#f44135',\n\n    green: '#5da602', brightGreen: '#98e342',\n\n    yellow: '#cfad00', brightYellow: '#fcea60',\n\n    blue: '#417ab3', brightBlue: '#83afd8',\n\n    magenta: '#88658d', brightMagenta: '#bc93b6',\n\n    cyan: '#00a7aa', brightCyan: '#37e5e7',\n\n    white: '#dbded8', brightWhite: '#f1f1ef'\n  }, light: {\n    foreground: '#000000', background: '#fffefe', cursor: '#000000',\n\n    black: '#000000', brightBlack: '#676767',\n\n    red: '#c91b00', brightRed: '#ff6d67',\n\n    green: '#00c200', brightGreen: '#5ff967',\n\n    yellow: '#c7c400', brightYellow: '#fefb67',\n\n    blue: '#0225c7', brightBlue: '#6871ff',\n\n    magenta: '#c930c7', brightMagenta: '#ff76ff',\n\n    cyan: '#00c5c7', brightCyan: '#5ffdff',\n\n    white: '#c7c7c7', brightWhite: '#fffefe'\n  }, solarized_light: {\n    foreground: '#657b83', background: '#fdf6e3', cursor: '#657b83',\n\n    black: '#073642', brightBlack: '#002b36',\n\n    red: '#dc322f', brightRed: '#cb4b16',\n\n    green: '#859900', brightGreen: '#586e75',\n\n    yellow: '#b58900', brightYellow: '#657b83',\n\n    blue: '#268bd2', brightBlue: '#839496',\n\n    magenta: '#d33682', brightMagenta: '#6c71c4',\n\n    cyan: '#2aa198', brightCyan: '#93a1a1',\n\n    white: '#eee8d5', brightWhite: '#fdf6e3'\n  }, material: {\n    foreground: '#2e2d2c', background: '#eeeeee', cursor: '#2e2d2c',\n\n    black: '#2c2c2c', brightBlack: '#535353',\n\n    red: '#c52728', brightRed: '#ee524f',\n\n    green: '#558a2f', brightGreen: '#8bc24a',\n\n    yellow: '#f8a725', brightYellow: '#ffea3b',\n\n    blue: '#1564bf', brightBlue: '#64b4f5',\n\n    magenta: '#691e99', brightMagenta: '#b967c7',\n\n    cyan: '#00828e', brightCyan: '#26c5d9',\n\n    white: '#f2f1f1', brightWhite: '#e0dfdf'\n  },\n}"
  },
  {
    "path": "spug_web/src/pages/system/account/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, {useState, useEffect} from 'react';\nimport {Link} from 'react-router-dom';\nimport {observer} from 'mobx-react';\nimport {Modal, Form, Select, Input, message} from 'antd';\nimport {http, includes} from 'libs';\nimport store from './store';\nimport rStore from '../role/store';\n\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n  const [contacts, setContacts] = useState([])\n\n  useEffect(() => {\n    http.get('/api/alarm/contact/?only_push=1')\n      .then(res => setContacts(res))\n  }, []);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData.id = store.record.id;\n    http.post('/api/account/user/', formData)\n      .then(() => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      width={700}\n      maskClosable={false}\n      title={store.record.id ? '编辑账户' : '新建账户'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"username\" label=\"登录名\">\n          <Input placeholder=\"请输入登录名\"/>\n        </Form.Item>\n        <Form.Item required name=\"nickname\" label=\"姓名\">\n          <Input placeholder=\"请输入姓名\"/>\n        </Form.Item>\n        <Form.Item required hidden={store.record.id} name=\"password\" label=\"密码\"\n                   extra=\"至少8位包含数字、小写和大写字母。\">\n          <Input.Password placeholder=\"请输入密码\"/>\n        </Form.Item>\n        <Form.Item hidden={store.record.is_supper} label=\"角色\" style={{marginBottom: 0}}>\n          <Form.Item name=\"role_ids\" style={{display: 'inline-block', width: '80%'}}\n                     extra=\"权限最大化原则，组合多个角色权限。\">\n            <Select mode=\"multiple\" placeholder=\"请选择\">\n              {rStore.records.map(item => (\n                <Select.Option value={item.id} key={item.id}>{item.name}</Select.Option>\n              ))}\n            </Select>\n          </Form.Item>\n          <Form.Item style={{display: 'inline-block', width: '20%', textAlign: 'right'}}>\n            <Link to=\"/system/role\">新建角色</Link>\n          </Form.Item>\n        </Form.Item>\n        <Form.Item\n          name=\"wx_token\"\n          label=\"MFA标识\"\n          extra={(\n            <span>\n              如果启用了MFA（两步验证）则该项为必填。\n              <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://push.spug.cc/guide/spug\">如何获取MFA标识？</a>\n            </span>)}>\n          <Select showSearch allowClear filterOption={(i, o) => includes(o.children, i)}\n                  placeholder=\"请选择绑定推送标识\">\n            {contacts.map(item => (\n              <Select.Option value={item.id} key={item.id}>{item.name}</Select.Option>\n            ))}\n          </Select>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/account/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons';\nimport { Form, Radio, Modal, Button, Badge, message, Input } from 'antd';\nimport { TableCard, Action } from 'components';\nimport http from 'libs/http';\nimport store from './store';\nimport rStore from '../role/store';\n\n@observer\nclass ComTable extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      password: ''\n    }\n  }\n\n  componentDidMount() {\n    if (rStore.records.length === 0) {\n      rStore.fetchRecords()\n        .then(() => store.fetchRecords())\n    } else {\n      store.fetchRecords()\n    }\n  }\n\n  columns = [{\n    title: '登录名',\n    dataIndex: 'username',\n  }, {\n    title: '姓名',\n    dataIndex: 'nickname',\n  }, {\n    title: '角色',\n    dataIndex: 'role_ids',\n    render: v => v.map(x => rStore.idMap[x]?.name).join(',')\n  }, {\n    title: '状态',\n    render: text => text['is_active'] ? <Badge status=\"success\" text=\"正常\"/> : <Badge status=\"default\" text=\"禁用\"/>\n  }, {\n    title: '最近登录',\n    dataIndex: 'last_login'\n  }, {\n    title: '操作',\n    render: info => (\n      <Action>\n        <Action.Button onClick={() => this.handleActive(info)}>{info['is_active'] ? '禁用' : '启用'}</Action.Button>\n        <Action.Button onClick={() => store.showForm(info)}>编辑</Action.Button>\n        <Action.Button disabled={info['type'] === 'ldap'} onClick={() => this.handleReset(info)}>重置密码</Action.Button>\n        <Action.Button danger onClick={() => this.handleDelete(info)}>删除</Action.Button>\n      </Action>\n    )\n  }];\n\n  handleActive = (text) => {\n    Modal.confirm({\n      title: '操作确认',\n      content: `确定要${text['is_active'] ? '禁用' : '启用'}【${text['nickname']}】?`,\n      onOk: () => {\n        return http.patch(`/api/account/user/`, {id: text.id, is_active: !text['is_active']})\n          .then(() => {\n            message.success('操作成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  handleReset = (info) => {\n    Modal.confirm({\n      icon: <ExclamationCircleOutlined/>,\n      title: '重置登录密码',\n      content: <Form layout=\"vertical\" style={{marginTop: 24}}>\n        <Form.Item required label=\"重置后的新密码\" extra=\"至少8位包含数字、小写和大写字母。\">\n          <Input.Password onChange={val => this.setState({password: val.target.value})}/>\n        </Form.Item>\n      </Form>,\n      onOk: () => {\n        return http.patch('/api/account/user/', {id: info.id, password: this.state.password})\n          .then(() => message.success('重置成功', 0.5))\n      },\n    })\n  };\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除【${text['nickname']}】?`,\n      onOk: () => {\n        return http.delete('/api/account/user/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"sa\"\n        rowKey=\"id\"\n        title=\"账户列表\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <Button type=\"primary\" icon={<PlusOutlined/>} onClick={() => store.showForm()}>新建</Button>,\n          <Radio.Group value={store.f_status} onChange={e => store.f_status = e.target.value}>\n            <Radio.Button value=\"\">全部</Radio.Button>\n            <Radio.Button value=\"true\">正常</Radio.Button>\n            <Radio.Button value=\"false\">禁用</Radio.Button>\n          </Radio.Group>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}\n        columns={this.columns}/>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/system/account/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"system.account.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>系统管理</Breadcrumb.Item>\n        <Breadcrumb.Item>账户管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"账户名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/account/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable records = [];\n  @observable record = {};\n  @observable isFetching = true;\n  @observable formVisible = false;\n\n  @observable f_name;\n  @observable f_status = '';\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.username.toLowerCase().includes(this.f_name.toLowerCase()));\n    if (this.f_status) records = records.filter(x => String(x.is_active) === this.f_status);\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/account/user/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/system/login/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Radio, Tag } from 'antd';\nimport { TableCard } from 'components';\nimport store from './store';\n\n@observer\nclass ComTable extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      password: ''\n    }\n  }\n\n  componentDidMount() {\n    store.fetchRecords()\n  }\n\n  columns = [{\n    title: '时间',\n    width: 200,\n    dataIndex: 'created_at'\n  }, {\n    title: '账户名',\n    width: 120,\n    dataIndex: 'username',\n  }, {\n    title: '登录方式',\n    width: 100,\n    hide: true,\n    dataIndex: 'type',\n    render: text => text === 'ldap' ? 'LDAP' : '普通登录'\n  }, {\n    title: '状态',\n    width: 90,\n    render: text => text['is_success'] ? <Tag color=\"success\">成功</Tag> : <Tag color=\"error\">失败</Tag>\n  }, {\n    title: '登录IP',\n    width: 160,\n    dataIndex: 'ip',\n  }, {\n    title: 'User Agent',\n    ellipsis: true,\n    dataIndex: 'agent'\n  }, {\n    title: '提示信息',\n    ellipsis: true,\n    dataIndex: 'message'\n  }];\n\n  render() {\n    return (\n      <TableCard\n        tKey=\"sl\"\n        rowKey=\"id\"\n        title=\"登录记录\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <Radio.Group value={store.f_status} onChange={e => store.f_status = e.target.value}>\n            <Radio.Button value=\"\">全部</Radio.Button>\n            <Radio.Button value=\"true\">成功</Radio.Button>\n            <Radio.Button value=\"false\">失败</Radio.Button>\n          </Radio.Group>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}\n        columns={this.columns}/>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/system/login/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"system.account.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>系统管理</Breadcrumb.Item>\n        <Breadcrumb.Item>账户管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"账户名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n        <SearchForm.Item span={8} title=\"登录IP\">\n          <Input allowClear value={store.f_ip} onChange={e => store.f_ip = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n    </AuthDiv>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/login/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport { http, includes } from 'libs';\n\nclass Store {\n  @observable records = [];\n  @observable isFetching = false;\n\n  @observable f_ip;\n  @observable f_name;\n  @observable f_status = '';\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_ip) records = records.filter(x => includes(x.ip, this.f_ip));\n    if (this.f_name) records = records.filter(x => includes(x.username, this.f_name));\n    if (this.f_status) records = records.filter(x => String(x.is_success) === this.f_status);\n    return records\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    http.get('/api/account/login/history/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/system/role/DeployPerm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Transfer, message, Tabs, Alert } from 'antd';\nimport http from 'libs/http';\nimport envStore from 'pages/config/environment/store';\nimport appStore from 'pages/config/app/store';\nimport store from './store';\nimport lds from 'lodash';\n\n@observer\nclass DeployPerm extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n      envs: [],\n      apps: []\n    }\n  }\n\n  componentDidMount() {\n    if (envStore.records.length === 0) {\n      envStore.fetchRecords().then(\n        () => this._updateRecords(envStore.records, 'envs')\n      )\n    } else {\n      this._updateRecords(envStore.records, 'envs')\n    }\n    if (appStore.records.length === 0) {\n      appStore.fetchRecords().then(\n        () => this._updateRecords(appStore.records, 'apps')\n      )\n    } else {\n      this._updateRecords(appStore.records, 'apps')\n    }\n  }\n\n  _updateRecords = (records, key) => {\n    const data = records.map(x => {\n      return {...x, key: x.id, _key: x.key}\n    });\n    this.setState({[key]: data})\n  };\n\n  handleSubmit = () => {\n    const envs = lds.get(store.deployRel, 'envs', [])\n    const apps = lds.get(store.deployRel, 'apps', [])\n    if (!(envs.length === 0 && apps.length === 0)) {\n      if (envs.length === 0) return message.error('请至少设置一个环境权限')\n      if (apps.length === 0) return message.error('请至少设置一个应用权限')\n    }\n    this.setState({loading: true});\n    http.patch('/api/account/role/', {id: store.record.id, deploy_perms: {envs, apps}})\n      .then(res => {\n        message.success('操作成功');\n        store.deployPermVisible = false;\n        store.fetchRecords()\n      }, () => this.setState({loading: false}))\n  };\n\n  handleFilter = (inputValue, option) => {\n    const keywords = inputValue.toLowerCase();\n    return `${option.name} - ${option._key}`.toLowerCase().includes(keywords)\n  }\n\n  render() {\n    return (\n      <Modal\n        visible\n        width={800}\n        maskClosable={false}\n        title=\"发布权限设置\"\n        onCancel={() => store.deployPermVisible = false}\n        confirmLoading={this.state.loading}\n        onOk={this.handleSubmit}>\n        <Alert\n          closable\n          showIcon\n          type=\"info\"\n          style={{margin: '0 24px 24px 24px'}}\n          message=\"环境权限和应用权限都需要设置，应用的创建者将默认拥有该应用的发布权限。\"/>\n        <Tabs tabPosition=\"left\">\n          <Tabs.TabPane tab=\"环境权限\" key=\"env\">\n            <Form.Item label=\"设置可发布至哪个环境\">\n              <Transfer\n                showSearch\n                listStyle={{width: 280, minHeight: 300}}\n                titles={['所有环境', '已选环境']}\n                dataSource={this.state.envs}\n                targetKeys={store.deployRel.envs}\n                filterOption={this.handleFilter}\n                onChange={keys => store.deployRel.envs = keys}\n                render={item => `${item.name} - ${item._key}`}/>\n            </Form.Item>\n          </Tabs.TabPane>\n          <Tabs.TabPane tab=\"应用权限\" key=\"app\">\n            <Form.Item label=\"设置可发布的应用\">\n              <Transfer\n                showSearch\n                listStyle={{width: 280, minHeight: 300}}\n                titles={['所有应用', '已选应用']}\n                dataSource={this.state.apps}\n                targetKeys={store.deployRel.apps}\n                filterOption={this.handleFilter}\n                onChange={keys => store.deployRel.apps = keys}\n                render={item => `${item.name} - ${item._key}`}/>\n            </Form.Item>\n          </Tabs.TabPane>\n        </Tabs>\n      </Modal>\n    )\n  }\n}\n\nexport default DeployPerm\n"
  },
  {
    "path": "spug_web/src/pages/system/role/Form.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Input, message } from 'antd';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    formData['id'] = store.record.id;\n    http.post('/api/account/role/', formData)\n      .then(res => {\n        message.success('操作成功');\n        store.formVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  return (\n    <Modal\n      visible\n      maskClosable={false}\n      title={store.record.id ? '编辑角色' : '新建角色'}\n      onCancel={() => store.formVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form form={form} initialValues={store.record} labelCol={{span: 6}} wrapperCol={{span: 14}}>\n        <Form.Item required name=\"name\" label=\"角色名称\">\n          <Input placeholder=\"请输入角色名称\"/>\n        </Form.Item>\n        <Form.Item name=\"desc\" label=\"备注信息\">\n          <Input.TextArea placeholder=\"请输入角色备注信息\"/>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/role/HostPerm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Form, Button, message, TreeSelect } from 'antd';\nimport { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';\nimport hostStore from 'pages/host/store';\nimport http from 'libs/http';\nimport store from './store';\nimport styles from './index.module.css';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [groups, setGroups] = useState([...store.record.group_perms]);\n\n  useEffect(() => {\n    hostStore.initial()\n  }, [])\n\n  function handleSubmit() {\n    setLoading(true);\n    http.patch('/api/account/role/', {id: store.record.id, group_perms: groups})\n      .then(res => {\n        message.success('操作成功');\n        store.hostPermVisible = false;\n        store.fetchRecords()\n      }, () => setLoading(false))\n  }\n\n  function handleChange(index, value) {\n    const tmp = [...groups];\n    if (index !== undefined) {\n      if (value) {\n        tmp[index] = value;\n      } else {\n        tmp.splice(index, 1)\n      }\n    } else {\n      tmp.push(undefined)\n    }\n    setGroups(tmp)\n  }\n\n  return (\n    <Modal\n      visible\n      width={400}\n      maskClosable={false}\n      title=\"主机权限设置\"\n      onCancel={() => store.hostPermVisible = false}\n      confirmLoading={loading}\n      onOk={handleSubmit}>\n      <Form layout=\"vertical\">\n        <Form.Item label=\"授权访问主机组\" tooltip=\"主机权限将全局影响属于该角色的用户能够看到的主机。\">\n          {groups.map((id, index) => (\n            <div className={styles.groupItem} key={index}>\n              <TreeSelect\n                value={id}\n                allowClear\n                showSearch={false}\n                treeNodeLabelProp=\"name\"\n                treeData={hostStore.rawTreeData}\n                onChange={value => handleChange(index, value)}\n                placeholder=\"请选择分组\"/>\n              {groups.length > 1 && (\n                <MinusCircleOutlined className={styles.delIcon} onClick={() => handleChange(index)}/>\n              )}\n            </div>\n          ))}\n        </Form.Item>\n        <Form.Item>\n          <Button type=\"dashed\" style={{width: '100%'}} onClick={() => handleChange()}>\n            <PlusOutlined/>添加授权主机组\n          </Button>\n        </Form.Item>\n      </Form>\n    </Modal>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/role/PagePerm.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport {Modal, Checkbox, Row, Col, message, Alert} from 'antd';\nimport http from 'libs/http';\nimport store from './store';\nimport codes from './codes';\nimport styles from './index.module.css';\nimport lds from 'lodash';\n\n@observer\nclass PagePerm extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      loading: false,\n    }\n  }\n\n  handleSubmit = () => {\n    this.setState({loading: true});\n    http.patch('/api/account/role/', {id: store.record.id, page_perms: store.permissions})\n      .then(res => {\n        message.success('操作成功');\n        store.pagePermVisible = false;\n        store.fetchRecords()\n      }, () => this.setState({loading: false}))\n  };\n\n  handleAllCheck = (e, mod, page) => {\n    const checked = e.target.checked;\n    if (checked) {\n      const key = `${mod}.${page}`;\n      store.permissions[mod][page] = lds.clone(store.allPerms[key])\n    } else {\n      store.permissions[mod][page] = []\n    }\n  };\n\n  handlePermCheck = (mod, page, perm) => {\n    const perms = store.permissions[mod][page];\n    if (perms.includes(perm)) {\n      perms.splice(perms.indexOf(perm), 1)\n    } else {\n      perms.push(perm)\n    }\n  };\n\n  PermBox = observer(({mod, page, perm, children}) => (\n    <Checkbox\n      value={perm}\n      onChange={() => this.handlePermCheck(mod, page, perm)}\n      checked={store.permissions[mod][page].includes(perm)}>\n      {children}\n    </Checkbox>\n  ));\n\n  render() {\n    const PermBox = this.PermBox;\n    return (\n      <Modal\n        visible\n        width={1000}\n        maskClosable={false}\n        title=\"功能权限设置\"\n        className={styles.container}\n        onCancel={() => store.pagePermVisible = false}\n        confirmLoading={this.state.loading}\n        onOk={this.handleSubmit}>\n        <Alert\n          closable\n          showIcon\n          type=\"info\"\n          style={{marginBottom: 12}}\n          message=\"功能权限仅影响页面功能，管理应用的发布权限请在发布权限中设置。权限更改成功后会强制属于该角色的账户重新登录。\"/>\n        <table border=\"1\" bordercolor=\"#dfdfdf\" className={styles.table}>\n          <thead>\n          <tr>\n            <th>模块</th>\n            <th>页面</th>\n            <th>功能</th>\n          </tr>\n          </thead>\n          <tbody>\n          {codes.map(mod => (\n            mod.pages.map((page, index) => (\n              <tr key={page.key}>\n                {index === 0 && <td rowSpan={mod.pages.length}>{mod.label}</td>}\n                <td>\n                  <Checkbox onChange={e => this.handleAllCheck(e, mod.key, page.key)}>\n                    {page.label}\n                  </Checkbox>\n                </td>\n                <td>\n                  <Row>\n                    {page.perms.map(perm => (\n                      <Col key={perm.key} span={8}>\n                        <PermBox mod={mod.key} page={page.key} perm={perm.key}>{perm.label}</PermBox>\n                      </Col>\n                    ))}\n                  </Row>\n                </td>\n              </tr>\n            ))\n          ))}\n\n          </tbody>\n        </table>\n      </Modal>\n    )\n  }\n}\n\nexport default PagePerm\n"
  },
  {
    "path": "spug_web/src/pages/system/role/RoleUsers.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Badge, Table } from 'antd';\nimport uStore from '../account/store';\n\n\nexport default observer(function (props) {\n  const users = uStore.records.filter(x => x.role_ids.includes(props.id))\n  return (\n    <Table rowKey=\"id\" dataSource={users} pagination={false} scroll={{y: 500}}>\n      <Table.Column width={120} title=\"姓名\" dataIndex=\"nickname\"/>\n      <Table.Column width={90} title=\"状态\" dataIndex=\"is_active\"\n                    render={v => v ? <Badge status=\"success\" text=\"正常\"/> : <Badge status=\"default\" text=\"禁用\"/>}/>\n      <Table.Column width={180} title=\"最近登录\" dataIndex=\"last_login\"/>\n    </Table>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/role/Table.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Modal, Popover, Button, message } from 'antd';\nimport { PlusOutlined } from '@ant-design/icons';\nimport { TableCard, AuthButton, Action } from 'components';\nimport RoleUsers from './RoleUsers';\nimport http from 'libs/http';\nimport store from './store';\nimport uStore from '../account/store';\nimport styles from './index.module.css';\n\n@observer\nclass ComTable extends React.Component {\n  componentDidMount() {\n    store.fetchRecords()\n    if (uStore.records.length === 0) {\n      uStore.fetchRecords()\n    }\n  }\n\n  columns = [{\n    title: '角色名称',\n    dataIndex: 'name',\n  }, {\n    title: '关联账户',\n    render: info => info.used ? (\n      <Popover overlayClassName={styles.roleUser} content={<RoleUsers id={info.id}/>}>\n        <Button type=\"link\">{info.used}</Button>\n      </Popover>\n    ) : <Button type=\"link\" disabled>{info.used}</Button>\n  }, {\n    title: '描述信息',\n    dataIndex: 'desc',\n    ellipsis: true\n  }, {\n    title: '操作',\n    width: 400,\n    render: info => (\n      <Action>\n        <Action.Button onClick={() => store.showForm(info)}>编辑</Action.Button>\n        <Action.Button onClick={() => store.showPagePerm(info)}>功能权限</Action.Button>\n        <Action.Button onClick={() => store.showDeployPerm(info)}>发布权限</Action.Button>\n        <Action.Button onClick={() => store.showHostPerm(info)}>主机权限</Action.Button>\n        <Action.Button danger onClick={() => this.handleDelete(info)}>删除</Action.Button>\n      </Action>\n    )\n  }];\n\n  handleDelete = (text) => {\n    Modal.confirm({\n      title: '删除确认',\n      content: `确定要删除角色【${text['name']}】?`,\n      onOk: () => {\n        return http.delete('/api/account/role/', {params: {id: text.id}})\n          .then(() => {\n            message.success('删除成功');\n            store.fetchRecords()\n          })\n      }\n    })\n  };\n\n  render() {\n    return (\n      <TableCard\n        rowKey=\"id\"\n        title=\"角色列表\"\n        loading={store.isFetching}\n        dataSource={store.dataSource}\n        onReload={store.fetchRecords}\n        actions={[\n          <AuthButton type=\"primary\" icon={<PlusOutlined/>} onClick={() => store.showForm()}>新建</AuthButton>\n        ]}\n        pagination={{\n          showSizeChanger: true,\n          showLessItems: true,\n          showTotal: total => `共 ${total} 条`,\n          pageSizeOptions: ['10', '20', '50', '100']\n        }}\n        columns={this.columns}/>\n    )\n  }\n}\n\nexport default ComTable\n"
  },
  {
    "path": "spug_web/src/pages/system/role/codes.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nexport default [{\n  key: 'dashboard',\n  label: 'Dashboard',\n  pages: [{\n    key: 'dashboard',\n    label: 'Dashboard',\n    perms: [\n      {key: 'view', label: '查看Dashboard'}\n    ]\n  }]\n}, {\n  key: 'host',\n  label: '主机管理',\n  pages: [{\n    key: 'host',\n    label: '主机管理',\n    perms: [\n      {key: 'view', label: '查看主机'},\n      {key: 'add', label: '新建主机'},\n      {key: 'edit', label: '编辑主机'},\n      {key: 'del', label: '删除主机'},\n    ]\n  }, {\n    key: 'console',\n    label: 'Web终端',\n    perms: [\n      {key: 'view', label: 'Web终端'},\n      {key: 'list', label: '文件管理'},\n      {key: 'upload', label: '上传文件'},\n      {key: 'del', label: '删除文件'},\n    ]\n  }]\n}, {\n  key: 'exec',\n  label: '批量执行',\n  pages: [{\n    key: 'task',\n    label: '执行任务',\n    perms: [\n      {key: 'do', label: '执行任务'}\n    ]\n  }, {\n    key: 'template',\n    label: '模板管理',\n    perms: [\n      {key: 'view', label: '查看模板'},\n      {key: 'add', label: '新建模板'},\n      {key: 'edit', label: '编辑模板'},\n      {key: 'del', label: '删除模板'},\n    ]\n  }, {\n    key: 'transfer',\n    label: '文件分发',\n    perms: [\n      {key: 'do', label: '文件分发'}\n    ]\n  }]\n}, {\n  key: 'deploy',\n  label: '应用发布',\n  pages: [{\n    key: 'app',\n    label: '应用管理',\n    perms: [\n      {key: 'view', label: '查看应用'},\n      {key: 'add', label: '新建应用'},\n      {key: 'edit', label: '编辑应用'},\n      {key: 'del', label: '删除应用'},\n      {key: 'config', label: '查看配置'},\n    ]\n  }, {\n    key: 'repository',\n    label: '构建仓库',\n    perms: [\n      {key: 'view', label: '查看构建'},\n      {key: 'add', label: '新建版本'},\n      {key: 'build', label: '执行构建'},\n      {key: 'del', label: '删除版本'},\n    ]\n  },{\n    key: 'request',\n    label: '发布申请',\n    perms: [\n      {key: 'view', label: '查看申请'},\n      {key: 'add', label: '新建申请'},\n      {key: 'edit', label: '编辑申请'},\n      {key: 'del', label: '删除申请'},\n      {key: 'approve', label: '审核申请'},\n      {key: 'do', label: '执行发布'}\n    ]\n  }]\n}, {\n  key: 'schedule',\n  label: '任务计划',\n  pages: [{\n    key: 'schedule',\n    label: '任务计划',\n    perms: [\n      {key: 'view', label: '查看任务'},\n      {key: 'add', label: '新建任务'},\n      {key: 'edit', label: '编辑任务'},\n      {key: 'del', label: '删除任务'},\n    ]\n  }]\n}, {\n  key: 'config',\n  label: '配置中心',\n  pages: [{\n    key: 'env',\n    label: '环境管理',\n    perms: [\n      {key: 'view', label: '查看环境'},\n      // {key: 'add', label: '新建环境'},\n      {key: 'edit', label: '编辑环境'},\n      {key: 'del', label: '删除环境'}\n    ]\n  }, {\n    key: 'src',\n    label: '服务管理',\n    perms: [\n      {key: 'view', label: '查看服务'},\n      {key: 'add', label: '新建服务'},\n      {key: 'edit', label: '编辑服务'},\n      {key: 'del', label: '删除服务'},\n      {key: 'view_config', label: '查看配置'},\n      {key: 'edit_config', label: '修改配置'},\n    ]\n  }, {\n    key: 'app',\n    label: '应用管理',\n    perms: [\n      {key: 'view', label: '查看应用'},\n      // {key: 'add', label: '新建应用'},\n      {key: 'edit', label: '编辑应用'},\n      {key: 'del', label: '删除应用'},\n      {key: 'view_config', label: '查看配置'},\n      {key: 'edit_config', label: '修改配置'},\n    ]\n  }]\n}, {\n  key: 'monitor',\n  label: '监控中心',\n  pages: [{\n    key: 'monitor',\n    label: '监控中心',\n    perms: [\n      {key: 'view', label: '查看监控'},\n      {key: 'add', label: '新建监控'},\n      {key: 'edit', label: '编辑监控'},\n      {key: 'del', label: '删除监控'},\n    ]\n  }]\n}, {\n  key: 'alarm',\n  label: '报警中心',\n  pages: [{\n    key: 'alarm',\n    label: '报警记录',\n    perms: [\n      {key: 'view', label: '查看记录'}\n    ]\n  }, {\n    key: 'contact',\n    label: '报警联系人',\n    perms: [\n      {key: 'view', label: '查看联系人'},\n      {key: 'add', label: '新建联系人'},\n      {key: 'edit', label: '编辑联系人'},\n      {key: 'del', label: '删除联系人'},\n    ]\n  }, {\n    key: 'group',\n    label: '报警联系组',\n    perms: [\n      {key: 'view', label: '查看联系组'},\n      {key: 'add', label: '新建联系组'},\n      {key: 'edit', label: '编辑联系组'},\n      {key: 'del', label: '删除联系组'},\n    ]\n  }]\n}]\n"
  },
  {
    "path": "spug_web/src/pages/system/role/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Input } from 'antd';\nimport { SearchForm, AuthDiv, Breadcrumb } from 'components';\nimport ComTable from './Table';\nimport ComForm from './Form';\nimport PagePerm from './PagePerm';\nimport DeployPerm from './DeployPerm';\nimport HostPerm from './HostPerm';\nimport store from './store';\n\nexport default observer(function () {\n  return (\n    <AuthDiv auth=\"system.role.view\">\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>系统管理</Breadcrumb.Item>\n        <Breadcrumb.Item>角色管理</Breadcrumb.Item>\n      </Breadcrumb>\n      <SearchForm>\n        <SearchForm.Item span={8} title=\"角色名称\">\n          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder=\"请输入\"/>\n        </SearchForm.Item>\n      </SearchForm>\n      <ComTable/>\n      {store.formVisible && <ComForm/>}\n      {store.pagePermVisible && <PagePerm/>}\n      {store.deployPermVisible && <DeployPerm/>}\n      {store.hostPermVisible && <HostPerm/>}\n    </AuthDiv>\n  );\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/role/index.module.css",
    "content": ".container :global(.ant-modal-footer) {\n  border-top: 0\n}\n\n.table {\n  width: 100%;\n  border: 1px solid #dfdfdf;\n}\n\n.table :global(.ant-checkbox-group) {\n  width: 100%;\n}\n\n.table th {\n  background-color: #fafafa;\n  color: #404040;\n  font-size: 18px;\n  font-weight: 500;\n  padding: 5px 15px;\n}\n\n.table td {\n  padding: 5px 15px;\n}\n\n.groupItem {\n  margin-bottom: 12px;\n  display: flex;\n  align-items: center;\n}\n\n.delIcon {\n  font-size: 24px;\n  margin-left: 10px;\n}\n\n.delIcon:hover {\n  color: #f5222d;\n}\n\n.roleUser :global(.ant-popover-inner-content) {\n  padding: 0;\n  width: 400px;\n}"
  },
  {
    "path": "spug_web/src/pages/system/role/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable, computed } from 'mobx';\nimport http from 'libs/http';\nimport codes from './codes';\nimport lds from 'lodash';\n\nclass Store {\n  allPerms = {};\n  initPerms = {};\n  @observable records = [];\n  @observable record = {};\n  @observable permissions = lds.cloneDeep(codes);\n  @observable deployRel = {};\n  @observable isFetching = false;\n  @observable formVisible = false;\n  @observable pagePermVisible = false;\n  @observable deployPermVisible = false;\n  @observable hostPermVisible = false;\n\n  @observable f_name;\n\n  @computed get dataSource() {\n    let records = this.records;\n    if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));\n    return records\n  }\n\n  constructor() {\n    this.initPermissions()\n  }\n\n  @computed get idMap() {\n    const tmp = {}\n    for (let item of this.records) {\n      tmp[item.id] = item\n    }\n    return tmp\n  }\n\n  fetchRecords = () => {\n    this.isFetching = true;\n    return http.get('/api/account/role/')\n      .then(res => this.records = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  initPermissions = () => {\n    for (let mod of codes) {\n      this.initPerms[mod.key] = {};\n      for (let page of mod.pages) {\n        this.initPerms[mod.key][page.key] = [];\n        this.allPerms[`${mod.key}.${page.key}`] = page.perms.map(x => x.key)\n      }\n    }\n  };\n\n  showForm = (info = {}) => {\n    this.formVisible = true;\n    this.record = info\n  };\n\n  showPagePerm = (info) => {\n    this.record = info;\n    this.pagePermVisible = true;\n    this.permissions = lds.merge({}, this.initPerms, info.page_perms)\n  };\n\n  showDeployPerm = (info) => {\n    this.record = info;\n    this.deployPermVisible = true;\n    this.deployRel = info.deploy_perms || {}\n  };\n\n  showHostPerm = (info) => {\n    this.record = info;\n    this.hostPermVisible = true\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/About.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport styles from './index.module.css';\nimport { SmileTwoTone } from '@ant-design/icons';\nimport { Descriptions, Spin, Button, Alert, notification } from 'antd';\nimport { observer } from 'mobx-react'\nimport { http, VERSION } from 'libs';\n\n\n@observer\nclass About extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      fetching: true,\n      info: {}\n    }\n  }\n\n  componentDidMount() {\n    http.get('/api/setting/about/')\n      .then(res => this.setState({info: res}))\n      .finally(() => this.setState({fetching: false}))\n    http.get(`https://api.spug.cc/apis/release/latest/?version=${VERSION}`)\n      .then(res => {\n        if (res.has_new) {\n          notification.open({\n            key: 'new_version',\n            duration: 0,\n            top: 88,\n            message: `发现新版本 ${res.version}`,\n            icon: <SmileTwoTone/>,\n            btn: <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://ops.spug.cc/docs/update-version/\">如何升级？</a>,\n            description: <pre style={{lineHeight: '30px'}}>{res.content}<br/>{res.extra}</pre>\n          })\n        } else if (res.extra) {\n          notification.open({\n            key: 'new_version',\n            duration: 0,\n            top: 88,\n            message: `已是最新版本`,\n            icon: <SmileTwoTone/>,\n            btn: <Button type=\"link\" onClick={() => notification.close('new_version')}>知道了</Button>,\n            description: <pre style={{lineHeight: '30px'}}>{res.extra}</pre>\n          })\n        }\n      })\n  }\n\n\n  render() {\n    const {info, fetching} = this.state;\n    return (\n      <Spin spinning={fetching}>\n        <div className={styles.title}>关于</div>\n        <Descriptions column={1}>\n          <Descriptions.Item label=\"操作系统\">{info['system_version']}</Descriptions.Item>\n          <Descriptions.Item label=\"Python版本\">{info['python_version']}</Descriptions.Item>\n          <Descriptions.Item label=\"Django版本\">{info['django_version']}</Descriptions.Item>\n          <Descriptions.Item label=\"Spug API版本\">{info['spug_version']}</Descriptions.Item>\n          <Descriptions.Item label=\"Spug Web版本\">{VERSION}</Descriptions.Item>\n          <Descriptions.Item label=\"官网文档\">\n            <a href=\"https://spug.cc\" target=\"_blank\" rel=\"noopener noreferrer\">https://spug.cc</a>\n          </Descriptions.Item>\n          <Descriptions.Item label=\"更新日志\">\n            <a href=\"https://ops.spug.cc/docs/change-log/\" target=\"_blank\"\n               rel=\"noopener noreferrer\">https://ops.spug.cc/docs/change-log/</a>\n          </Descriptions.Item>\n        </Descriptions>\n        {info['spug_version'] !== VERSION && (\n          <Alert showIcon style={{width: 500}} type=\"warning\" message=\"Spug API版本与Web版本不匹配，请尝试刷新浏览器后再次查看。\"/>\n        )}\n      </Spin>\n    )\n  }\n}\n\nexport default About\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/AlarmSetting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { observer } from 'mobx-react';\nimport { Button, Form, Input, Space, message } from 'antd';\nimport styles from './index.module.css';\nimport { http } from 'libs';\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const setting = store.settings.mail_service || {};\n  const [loading, setLoading] = useState(false);\n\n  function handleEmailTest() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    http.post('/api/setting/email_test/', formData)\n      .then(() => {\n        message.success('邮件服务连接成功')\n      }).finally(() => setLoading(false))\n  }\n\n  function _doSubmit(formData) {\n    store.loading = true;\n    http.post('/api/setting/', {data: formData})\n      .then(() => {\n        message.success('保存成功');\n        store.fetchSettings()\n      })\n      .finally(() => store.loading = false)\n  }\n\n  function handleSubmit() {\n    let formData = form.getFieldsValue();\n    if (!formData.server || !formData.port || !formData.username || !formData.password) {\n      return message.error('请完成邮件服务配置');\n    }\n    _doSubmit([{key: 'mail_service', value: formData}])\n  }\n\n  return (\n    <React.Fragment>\n      <div className={styles.title}>报警服务设置</div>\n      <div style={{maxWidth: 340}}>\n        <Form.Item label=\"邮件服务\" labelCol={{span: 24}} style={{marginTop: 12}} extra=\"用于通过邮件方式发送报警信息\">\n          <div style={{marginTop: 12}}>\n            <Form form={form} initialValues={setting} labelCol={{span: 7}} wrapperCol={{span: 17}}>\n              <Form.Item required name=\"server\" label=\"邮件服务器\">\n                <Input placeholder=\"例如：smtp.exmail.qq.com\"/>\n              </Form.Item>\n              <Form.Item required name=\"port\" label=\"端口\">\n                <Input placeholder=\"例如：465\"/>\n              </Form.Item>\n              <Form.Item required name=\"username\" label=\"邮箱账号\">\n                <Input placeholder=\"例如：dev@exmail.com\"/>\n              </Form.Item>\n              <Form.Item required name=\"password\" label=\"密码/授权码\">\n                <Input.Password placeholder=\"请输入对应的密码或授权码\"/>\n              </Form.Item>\n              <Form.Item name=\"nickname\" label=\"发件人昵称\">\n                <Input placeholder=\"请输入发件人昵称\"/>\n              </Form.Item>\n            </Form>\n          </div>\n        </Form.Item>\n        <Space style={{marginTop: 24}}>\n          <Button type=\"danger\" loading={loading} onClick={handleEmailTest}>测试邮件服务</Button>\n          <Button type=\"primary\" loading={store.loading} onClick={handleSubmit}>保存设置</Button>\n        </Space>\n      </div>\n    </React.Fragment>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/setting/KeySetting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Form, Alert, Button, Input, Modal, message } from 'antd';\nimport styles from './index.module.css';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  function handleSubmit() {\n    Modal.confirm({\n      title: '密钥修改确认',\n      content: <span style={{color: '#f5222d'}}>请谨慎修改密钥对，修改密钥对可能会让现有的主机都无法进行验证，影响与主机相关的各项功能！</span>,\n      onOk: () => {\n        Modal.confirm({\n          title: '小提示',\n          content: <div>修改密钥对需要<span style={{color: '#f5222d'}}>重启服务后生效</span>，已添加的主机可能需要重新进行编辑验证后才可以正常连接。</div>,\n          onOk: doModify\n        })\n      }\n    })\n  }\n\n  function doModify() {\n    return http.post('/api/setting/', {\n      data: [\n        {key: 'public_key', value: store.settings.public_key},\n        {key: 'private_key', value: store.settings.private_key}\n      ]\n    })\n      .then(() => {\n        message.success('保存成功');\n        store.fetchSettings()\n      })\n      .finally(() => store.loading = false)\n  }\n\n  return (\n    <React.Fragment>\n      <div className={styles.title}>密钥设置</div>\n      <Alert\n        closable\n        showIcon\n        type=\"info\"\n        style={{width: 650}}\n        message=\"小提示\"\n        description=\"在这里你可以上传并使用已有的密钥对，没有上传密钥的情况下，Spug会在首次添加主机时自动生成密钥对。\"\n      />\n      <Form layout=\"vertical\" style={{maxWidth: 650, marginTop: 12}}>\n        <Form.Item label=\"公钥\" extra=\"一般位于 ~/.ssh/id_rsa.pub\">\n          <Input.TextArea\n            rows={7}\n            spellCheck={false}\n            className={styles.keyText}\n            value={store.settings.public_key}\n            onChange={e => store.settings.public_key = e.target.value}\n            placeholder=\"请输入公钥\"/>\n        </Form.Item>\n        <Form.Item label=\"私钥\" extra=\"一般位于 ~/.ssh/id_rsa\" style={{marginTop: 12}}>\n          <Input.TextArea\n            rows={14}\n            spellCheck={false}\n            className={styles.keyText}\n            value={store.settings.private_key}\n            onChange={e => store.settings.private_key = e.target.value}\n            placeholder=\"请输入私钥\"/>\n        </Form.Item>\n        <Form.Item style={{marginTop: 24}}>\n          <Button type=\"primary\" loading={store.loading} onClick={handleSubmit}>保存设置</Button>\n        </Form.Item>\n      </Form>\n    </React.Fragment>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/LDAPSetting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport styles from './index.module.css';\nimport { Form, Button, Input, Space, message } from 'antd';\nimport { http } from 'libs';\nimport { observer } from 'mobx-react'\nimport store from './store';\n\nexport default observer(function () {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  function handleSubmit() {\n    store.loading = true;\n    const formData = form.getFieldsValue();\n    http.post('/api/setting/', {data: [{key: 'ldap_service', value: formData}]})\n      .then(() => {\n        message.success('保存成功');\n        store.fetchSettings()\n      })\n      .finally(() => store.loading = false)\n  }\n\n  function ldapTest() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    http.post('/api/setting/ldap_test/', formData).then(() => {\n      message.success('LDAP服务连接成功')\n    }).finally(() => setLoading(false))\n  }\n\n  return (\n    <React.Fragment>\n      <div className={styles.title}>LDAP设置</div>\n      <Form form={form} initialValues={store.settings.ldap_service} style={{maxWidth: 400}} labelCol={{span: 8}}\n            wrapperCol={{span: 16}}>\n        <Form.Item required name=\"server\" label=\"LDAP服务地址\">\n          <Input placeholder=\"例如：ldap.spug.cc\"/>\n        </Form.Item>\n        <Form.Item required name=\"port\" label=\"LDAP服务端口\">\n          <Input placeholder=\"例如：389\"/>\n        </Form.Item>\n        <Form.Item required name=\"admin_dn\" label=\"管理员DN\">\n          <Input placeholder=\"例如：cn=admin,dc=spug,dc=dev\"/>\n        </Form.Item>\n        <Form.Item required name=\"password\" label=\"管理员密码\">\n          <Input.Password placeholder=\"请输入LDAP管理员密码\"/>\n        </Form.Item>\n        <Form.Item required name=\"rules\" label=\"LDAP搜索规则\">\n          <Input placeholder=\"例如：cn\"/>\n        </Form.Item>\n        <Form.Item required name=\"base_dn\" label=\"基本DN\">\n          <Input placeholder=\"例如：dc=spug,dc=dev\"/>\n        </Form.Item>\n        <Space>\n          <Button type=\"danger\" loading={loading} onClick={ldapTest}>测试LDAP</Button>\n          <Button type=\"primary\" loading={store.loading} onClick={handleSubmit}>保存设置</Button>\n        </Space>\n      </Form>\n    </React.Fragment>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/setting/OpenService.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { observer } from 'mobx-react';\nimport { Form, Button, Input, message } from 'antd';\nimport styles from './index.module.css';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  function handleSubmit() {\n    store.loading = true;\n    const value = store.settings.api_key;\n    http.post('/api/setting/', {data: [{key: 'api_key', value}]})\n      .then(() => {\n        message.success('保存成功');\n        store.fetchSettings()\n      })\n      .finally(() => store.loading = false)\n  }\n\n  return (\n    <React.Fragment>\n      <div className={styles.title}>开放服务设置</div>\n      <Form layout=\"vertical\" style={{maxWidth: 320}}>\n        <Form.Item colon={false} label=\"访问凭据\" extra=\"该自定义凭据用于访问平台的开放服务，例如：配置中心的配置获取API等，其他开放服务请查询官方文档。\">\n          <Input\n            value={store.settings.api_key}\n            onChange={e => store.settings.api_key = e.target.value}\n            placeholder=\"请输入自定义Token\"/>\n        </Form.Item>\n        <Form.Item style={{marginTop: 24}}>\n          <Button type=\"primary\" loading={store.loading} onClick={handleSubmit}>保存设置</Button>\n        </Form.Item>\n      </Form>\n    </React.Fragment>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/PushSetting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, {useEffect, useState} from 'react';\nimport {observer} from 'mobx-react';\nimport {Form, Input, Button, Spin, Popconfirm, message} from 'antd';\nimport {Link} from 'components';\nimport css from './index.module.css';\nimport {http, clsNames} from 'libs';\nimport store from './store';\n\nexport default observer(function () {\n  const [loading, setLoading] = useState(false);\n  const [fetching, setFetching] = useState(false);\n  const [balance, setBalance] = useState({});\n  const [pushKey, setPushKey] = useState(store.settings.spug_push_key);\n\n  useEffect(() => {\n    if (pushKey) {\n      fetchBalance()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  function fetchBalance() {\n    setFetching(true)\n    http.get('/api/setting/push/balance/')\n      .then(res => setBalance(res))\n      .finally(() => {\n        setLoading(false)\n        setFetching(false)\n      })\n  }\n\n  function handleBind() {\n    if (!pushKey) return message.error('请输入要绑定的推送助手用户ID')\n    setLoading(true);\n    http.post('/api/setting/push/bind/', {spug_push_key: pushKey})\n      .then(res => {\n        message.success('绑定成功');\n        store.fetchSettings();\n        setBalance(res)\n      })\n      .finally(() => setLoading(false))\n  }\n\n  function handleUnbind() {\n    if (store.settings.MFA?.enable) {\n      message.error('请先关闭登录MFA认证，否则将造成无法登录');\n      return\n    }\n    setLoading(true);\n    http.post('/api/setting/push/bind/', {spug_push_key: ''})\n      .then(() => {\n        message.success('解绑成功');\n        store.fetchSettings();\n        setBalance({})\n        setPushKey('')\n      })\n      .finally(() => setLoading(false))\n  }\n\n  const isVip = balance.is_vip\n  const spugPushKey = store.settings.spug_push_key\n  return (\n    <Spin spinning={fetching}>\n      <div className={css.title}>推送服务设置</div>\n      <div style={{maxWidth: 340}}>\n        <Form.Item label=\"推送助手账户绑定\" labelCol={{span: 24}} style={{marginTop: 12}}\n                   extra={<div>请登录推送助手，至个人中心 / 个人设置查看用户ID，注意保密该ID请勿泄漏给第三方。<Link\n                     href=\"https://push.spug.cc/guide/spug\" title=\"配置手册\"/></div>}>\n\n          {spugPushKey ? (\n            <Input.Group compact>\n              <div className={css.keyText}\n                   style={{width: 'calc(100% - 100px)', lineHeight: '32px', fontWeight: 'bold'}}>{spugPushKey}</div>\n              <Popconfirm title=\"确定要解除绑定？\" onConfirm={handleUnbind}>\n                <Button ghost type=\"danger\" style={{width: 80, marginLeft: 20}} loading={loading}>解绑</Button>\n              </Popconfirm>\n            </Input.Group>\n          ) : (\n            <Input.Group compact>\n              <Input\n                value={pushKey}\n                onChange={e => setPushKey(e.target.value)}\n                style={{width: 'calc(100% - 100px)'}}\n                placeholder=\"请输入要绑定的推送助手用户ID\"/>\n              <Button\n                type=\"primary\"\n                style={{width: 80, marginLeft: 20}}\n                onClick={handleBind}\n                loading={loading}>确定</Button>\n            </Input.Group>\n\n          )}\n        </Form.Item>\n      </div>\n\n      {balance.vip_desc ? (\n        <Form.Item style={{marginTop: 24}}\n                   extra={<div> 如需充值请至 <Link href=\"https://push.spug.cc/buy/sms\" title=\"推送助手\"/>，具体计费规则及说明请查看推送助手官网。\n                   </div>}>\n          <div className={css.statistic}>\n            <div className={css.body}>\n              <div className={css.item}>\n                <div className={css.title}>短信余额</div>\n                <div className={css.value}>{balance.sms_balance}</div>\n              </div>\n              <div className={css.item}>\n                <div className={css.title}>语音余额</div>\n                <div className={css.value}>{balance.voice_balance}</div>\n              </div>\n              <div className={css.item}>\n                <div className={css.title}>邮件余额</div>\n                <div className={css.value}>{balance.mail_balance}</div>\n                {isVip ? (\n                  <div className={clsNames(css.tips, css.active)}>+ 会员赠送{balance.mail_free}封 / 天</div>\n                ) : (\n                  <Link href=\"https://push.spug.cc/buy/vip\" title={`订阅会员每天赠送${balance.mail_free}封`}\n                        className={css.tips}/>\n                )}\n              </div>\n              <div className={css.item}>\n                <div className={css.title}>微信公众号余额</div>\n                <div className={css.value}>{balance.wx_mp_balance}</div>\n                {isVip ? (\n                  <div className={clsNames(css.tips, css.active)}>+ 会员赠送{balance.wx_mp_free}条 / 天</div>\n                ) : (\n                  <Link href=\"https://push.spug.cc/buy/vip\" title={`订阅会员每天赠送${balance.wx_mp_free}条`}\n                        className={css.tips}/>\n                )}\n              </div>\n              <Link href=\"https://push.spug.cc/buy/vip\" className={css.badge} title={balance.vip_desc}/>\n            </div>\n          </div>\n        </Form.Item>\n      ) : null}\n    </Spin>\n  )\n})"
  },
  {
    "path": "spug_web/src/pages/system/setting/SecuritySetting.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, {useState, useEffect} from 'react';\nimport {observer} from 'mobx-react';\nimport {Form, Switch, Input, Space, Spin, message, Button} from 'antd';\nimport styles from './index.module.css';\nimport http from 'libs/http';\nimport store from './store';\n\nexport default observer(function () {\n  const [code, setCode] = useState();\n  const [visible, setVisible] = useState(false);\n  const [counter, setCounter] = useState(0);\n  const [loading, setLoading] = useState(false);\n  const [loading2, setLoading2] = useState(false);\n\n  useEffect(() => {\n    setTimeout(() => {\n      if (counter > 0) {\n        setCounter(counter - 1)\n      }\n    }, 1000)\n  }, [counter])\n\n  function handleChangeVerifyIP(v) {\n    store.isFetching = true;\n    http.post('/api/setting/', {data: [{key: 'verify_ip', value: v}]})\n      .then(() => {\n        message.success('设置成功');\n        store.fetchSettings()\n      }, () => store.isFetching = false)\n  }\n\n  function handleChangeBindIP(v) {\n    store.isFetching = true;\n    http.post('/api/setting/', {data: [{key: 'bind_ip', value: v}]})\n      .then(() => {\n        message.success('设置成功');\n        store.fetchSettings()\n      }, () => store.isFetching = false)\n  }\n\n  function handleChangeMFA(v) {\n    if (v && !store.settings.spug_push_key) return message.error('开启MFA认证需要先在推送服务设置中绑定推送助手账户');\n    v ? setVisible(true) : handleMFAModify(false)\n  }\n\n  function handleCaptcha() {\n    setLoading(true)\n    http.get('/api/setting/mfa/')\n      .then(() => setCounter(60))\n      .finally(() => setLoading(false))\n  }\n\n  function handleMFAModify(v) {\n    setLoading2(true)\n    http.post('/api/setting/mfa/', {enable: v, code})\n      .then(() => {\n        setVisible(false);\n        message.success('设置成功');\n        store.fetchSettings()\n      })\n      .finally(() => setLoading2(false))\n  }\n\n  const {verify_ip, bind_ip, MFA} = store.settings;\n  return (\n    <Spin spinning={store.isFetching}>\n      <div className={styles.title}>安全设置</div>\n      <Form layout=\"vertical\" style={{maxWidth: 500}}>\n        <Form.Item\n          label=\"访问IP校验\"\n          extra={<span>建议开启，校验是否获取了真实的访问者IP，防止因为增加的反向代理层导致基于IP的安全策略失效，当校验失败时会在登录时弹窗提醒。如果你在内网部署且仅在内网使用可以关闭该特性。<a\n            href=\"https://ops.spug.cc/docs/practice\"\n            target=\"_blank\" rel=\"noopener noreferrer\">为什么没有获取到真实IP？</a></span>}>\n          <Switch\n            checkedChildren=\"开启\"\n            unCheckedChildren=\"关闭\"\n            onChange={handleChangeVerifyIP}\n            checked={verify_ip}/>\n        </Form.Item>\n        <Form.Item\n          label=\"登录IP绑定\"\n          extra=\"强烈建议开启，当开启后会把登录凭证与IP进行绑定，当该登录凭证通过其他IP访问时将自动失效。如非必要，切勿关闭该特性！\">\n          <Switch\n            checkedChildren=\"开启\"\n            unCheckedChildren=\"关闭\"\n            onChange={handleChangeBindIP}\n            checked={bind_ip}/>\n        </Form.Item>\n        <Form.Item\n          label=\"登录MFA（两步）认证\"\n          style={{marginTop: 24}}\n          extra={visible ? '输入验证码，通过验证后开启。' :\n            <span>建议开启，登录时额外使用验证码进行身份验证。开启前至少要确保管理员账户配置了MFA标识（账户管理/编辑），开启后未配置的账户将无法登录。<a\n              target=\"_blank\" rel=\"noopener noreferrer\"\n              href=\"https://push.spug.cc/guide/spug\">配置手册</a></span>}>\n          {visible ? (\n            <div style={{display: 'flex', width: 490}}>\n              <Form.Item noStyle extra=\"验证通过后开启MFA（两步验证）。\">\n                <Input placeholder=\"请输入验证码\" onChange={e => setCode(e.target.value)}/>\n              </Form.Item>\n              {counter > 0 ? (\n                <Button disabled style={{marginLeft: 8}}>{counter} 秒后重新获取</Button>\n              ) : (\n                <Button loading={loading} style={{marginLeft: 8}} onClick={handleCaptcha}>获取验证码</Button>\n              )}\n              <Space style={{marginLeft: 48}}>\n                <Button onClick={() => setVisible(false)}>取消</Button>\n                <Button type=\"primary\" loading={loading2} onClick={() => handleMFAModify(true)}>确认</Button>\n              </Space>\n            </div>\n          ) : (\n            <Switch\n              checkedChildren=\"开启\"\n              unCheckedChildren=\"关闭\"\n              onChange={handleChangeMFA}\n              checked={MFA?.enable}/>\n          )}\n        </Form.Item>\n      </Form>\n    </Spin>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport { Menu } from 'antd';\nimport { AuthDiv, Breadcrumb } from 'components';\nimport AlarmSetting from './AlarmSetting';\nimport LDAPSetting from './LDAPSetting';\nimport OpenService from './OpenService';\nimport KeySetting from './KeySetting';\nimport SecuritySetting from './SecuritySetting';\nimport PushSetting from './PushSetting';\nimport About from './About';\nimport styles from './index.module.css';\nimport store from './store';\n\n\nclass Index extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      selectedKeys: ['security']\n    }\n  }\n\n  componentDidMount() {\n    store.fetchSettings()\n  }\n\n  render() {\n    const {selectedKeys} = this.state;\n    return (\n      <AuthDiv auth=\"system.setting.view\">\n        <Breadcrumb>\n          <Breadcrumb.Item>首页</Breadcrumb.Item>\n          <Breadcrumb.Item>系统管理</Breadcrumb.Item>\n          <Breadcrumb.Item>系统设置</Breadcrumb.Item>\n        </Breadcrumb>\n        <div className={styles.container}>\n          <div className={styles.left}>\n            <Menu\n              mode=\"inline\"\n              selectedKeys={selectedKeys}\n              style={{border: 'none'}}\n              onSelect={({selectedKeys}) => this.setState({selectedKeys})}>\n              <Menu.Item key=\"security\">安全设置</Menu.Item>\n              <Menu.Item key=\"ldap\">LDAP设置</Menu.Item>\n              <Menu.Item key=\"key\">密钥设置</Menu.Item>\n              <Menu.Item key=\"push\">推送服务设置</Menu.Item>\n              <Menu.Item key=\"alarm\">报警服务设置</Menu.Item>\n              <Menu.Item key=\"service\">开放服务设置</Menu.Item>\n              <Menu.Item key=\"about\">关于</Menu.Item>\n            </Menu>\n          </div>\n          <div className={styles.right}>\n            {selectedKeys[0] === 'security' && <SecuritySetting/>}\n            {selectedKeys[0] === 'ldap' && <LDAPSetting/>}\n            {selectedKeys[0] === 'alarm' && <AlarmSetting/>}\n            {selectedKeys[0] === 'push' && <PushSetting/>}\n            {selectedKeys[0] === 'service' && <OpenService/>}\n            {selectedKeys[0] === 'key' && <KeySetting/>}\n            {selectedKeys[0] === 'about' && <About/>}\n          </div>\n        </div>\n      </AuthDiv>\n    )\n  }\n}\n\nexport default Index\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/index.module.css",
    "content": ".container {\n  display: flex;\n  background-color: #fff;\n  padding: 16px 0;\n}\n\n.left {\n  flex: 2;\n  border-right: 1px solid #e8e8e8;\n}\n\n.right {\n  flex: 7;\n  padding: 8px 40px;\n}\n\n.title {\n  margin-bottom: 24px;\n  color: rgba(0, 0, 0, .85);\n  font-weight: 500;\n  font-size: 20px;\n  line-height: 28px;\n}\n\n.form {\n  max-width: 320px;\n}\n\n.keyText {\n  font-family: \"Bitstream Vera Sans Mono\", Monaco, \"Courier New\", Courier, monospace;\n}\n\n.statistic {\n  background: #fafafa;\n  border-radius: 4px;\n\n  .body {\n    display: flex;\n    flex-direction: row;\n    position: relative;\n\n    .badge {\n      border-radius: 4px;\n      line-height: 20px;\n      height: 20px;\n      font-size: 12px;\n      padding: 0 8px;\n      background: #2563fc;\n      font-weight: bold;\n      color: #ffffff;\n      cursor: pointer;\n      position: absolute;\n      right: 0;\n    }\n\n    .item {\n      position: relative;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      width: 180px;\n      height: 150px;\n\n      &:nth-child(n+2) {\n        &:before {\n          content: ' ';\n          position: absolute;\n          top: 52px;\n          left: 0;\n          width: 1px;\n          height: 56px;\n          background: #CCCCCC;\n          opacity: 0.5;\n        }\n      }\n\n      .title {\n        font-size: 14px;\n        color: #666666;\n        margin-bottom: 6px;\n      }\n\n      .value {\n        font-size: 40px;\n        line-height: 46px;\n        color: #333333;\n        position: relative;\n      }\n\n      .tips {\n        position: absolute;\n        bottom: 16px;\n        font-size: 11px;\n        color: rgba(0, 0, 0, 0.35);\n        background: rgba(0, 0, 0, 0.04);\n        border-radius: 10px;\n        line-height: 20px;\n        text-align: center;\n        padding: 0 8px;\n      }\n\n      .active {\n        color: #2563fc;\n        background: #ffffff;\n      }\n    }\n\n    .buy {\n      position: absolute;\n      right: 51px;\n      top: 110px;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      height: 32px;\n      width: 96px;\n      padding: 0 16px 0 20px;\n      border-radius: 16px;\n      color: #2563fc;\n      font-size: 14px;\n      cursor: pointer;\n\n      :global(.iconfont) {\n        font-size: 14px;\n      }\n    }\n  }\n}\n\n"
  },
  {
    "path": "spug_web/src/pages/system/setting/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from \"mobx\";\nimport http from 'libs/http';\n\nclass Store {\n  @observable settings = {};\n  @observable isFetching = false;\n  @observable loading = false;\n\n  fetchSettings = () => {\n    this.isFetching = true;\n    http.get('/api/setting/')\n      .then(res => this.settings = res)\n      .finally(() => this.isFetching = false)\n  };\n\n  update = (key, value) => {\n    this.settings[key] = value\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/pages/welcome/index/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport {Card } from 'antd';\n\nexport default function (props) {\n  return (\n    <Card>\n      <div>{localStorage.getItem('nickname')}, 欢迎你</div>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/welcome/info/Basic.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState, useEffect } from 'react';\nimport { observer } from 'mobx-react';\nimport { Button, Form, Input, Spin, message } from 'antd';\nimport styles from './index.module.css';\nimport { http } from 'libs';\nimport store from './store';\n\n\nexport default observer(function Basic(props) {\n  const [form] = Form.useForm()\n  const [fetching, setFetching] = useState(false)\n  const [loading, setLoading] = useState(false)\n\n  useEffect(() => {\n    if (!store.user.nickname) {\n      setFetching(true)\n      store.fetchUser()\n        .then(() => form.setFieldsValue(store.user))\n        .finally(() => setFetching(false))\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  function handleSubmit() {\n    setLoading(true);\n    const formData = form.getFieldsValue();\n    http.patch('/api/account/self/', formData)\n      .then(() => {\n        message.success('保存成功，昵称将在重新登录或刷新页面后生效');\n        localStorage.setItem('nickname', formData.nickname);\n        store.fetchUser()\n      })\n      .finally(() => setLoading(false))\n  }\n\n  return (\n    <Spin spinning={fetching}>\n      <div className={styles.title}>基本设置</div>\n      <Form form={form} layout=\"vertical\" style={{maxWidth: 320}} initialValues={store.user}>\n        <Form.Item required name=\"nickname\" label=\"昵称\">\n          <Input placeholder=\"请输入\"/>\n        </Form.Item>\n        <Form.Item>\n          <Button type=\"primary\" loading={loading} onClick={handleSubmit}>保存设置</Button>\n        </Form.Item>\n      </Form>\n    </Spin>\n  )\n})\n"
  },
  {
    "path": "spug_web/src/pages/welcome/info/Reset.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { Button, Form, Input, message } from 'antd';\nimport styles from './index.module.css';\nimport { http } from 'libs';\nimport history from 'libs/history';\n\n\nexport default function Reset(props) {\n  const [loading, setLoading] = useState(false);\n  const [old_password, setOldPassword] = useState();\n  const [new_password, setNewPassword] = useState();\n  const [new2_password, setNew2Password] = useState();\n\n  function handleSubmit() {\n    if (!old_password) {\n      return message.error('请输入原密码')\n    } else if (!new_password) {\n      return message.error('请输入新密码')\n    } else if (new_password !== new2_password) {\n      return message.error('两次输入密码不一致')\n    }\n    setLoading(true);\n    http.patch('/api/account/self/', {old_password, new_password})\n      .then(() => {\n        message.success('密码修改成功');\n        history.push('/');\n        http.get('/api/account/logout/')\n      })\n      .finally(() => setLoading(false))\n  }\n\n  return (\n    <React.Fragment>\n      <div className={styles.title}>修改密码</div>\n      <Form style={{maxWidth: 320}} labelCol={{span: 6}} wrapperCol={{span: 18}}>\n        <Form.Item required label=\"原密码\">\n          <Input.Password value={old_password} placeholder=\"请输入\" onChange={e => setOldPassword(e.target.value)}/>\n        </Form.Item>\n        <Form.Item required label=\"新密码\" extra=\"至少8位包含数字、小写和大写字母。\">\n          <Input.Password value={new_password} placeholder=\"请输入新密码\" onChange={e => setNewPassword(e.target.value)}/>\n        </Form.Item>\n        <Form.Item required label=\"再次确认\">\n          <Input.Password value={new2_password} placeholder=\"请再次输入新密码\" onChange={e => setNew2Password(e.target.value)}/>\n        </Form.Item>\n        <Form.Item>\n          <Button type=\"primary\" loading={loading} onClick={handleSubmit}>保存设置</Button>\n        </Form.Item>\n      </Form>\n    </React.Fragment>\n  )\n}\n"
  },
  {
    "path": "spug_web/src/pages/welcome/info/index.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React, { useState } from 'react';\nimport { Menu } from 'antd';\nimport { Breadcrumb } from 'components';\nimport Basic from './Basic';\nimport Reset from './Reset';\nimport styles from './index.module.css';\n\nfunction Index() {\n  const [selectedKeys, setSelectedKeys] = useState(['basic'])\n\n  return (\n    <div>\n      <Breadcrumb>\n        <Breadcrumb.Item>首页</Breadcrumb.Item>\n        <Breadcrumb.Item>个人中心</Breadcrumb.Item>\n      </Breadcrumb>\n      <div className={styles.container}>\n        <div className={styles.left}>\n          <Menu\n            mode=\"inline\"\n            selectedKeys={selectedKeys}\n            style={{border: 'none'}}\n            onSelect={({selectedKeys}) => setSelectedKeys(selectedKeys)}>\n            <Menu.Item key=\"basic\">基本设置</Menu.Item>\n            <Menu.Item key=\"reset\">修改密码</Menu.Item>\n          </Menu>\n        </div>\n        <div className={styles.right}>\n          {selectedKeys[0] === 'basic' && <Basic/>}\n          {selectedKeys[0] === 'reset' && <Reset/>}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default Index\n"
  },
  {
    "path": "spug_web/src/pages/welcome/info/index.module.css",
    "content": ".container {\n    display: flex;\n    background-color: #fff;\n    padding: 16px 0;\n}\n.left {\n    flex: 2;\n    border-right: 1px solid #e8e8e8;\n}\n.right {\n    flex: 7;\n    padding: 8px 40px;\n}\n\n.title {\n    margin-bottom: 24px;\n    color: rgba(0, 0, 0, .85);\n    font-weight: 500;\n    font-size: 20px;\n    line-height: 28px;\n}\n\n.form {\n    max-width: 320px;\n}"
  },
  {
    "path": "spug_web/src/pages/welcome/info/store.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport { observable } from 'mobx';\nimport http from 'libs/http';\n\nclass Store {\n  @observable user = {};\n\n  fetchUser = () => {\n    return http.get('/api/account/self/')\n      .then(res => this.user = res)\n  }\n}\n\nexport default new Store()\n"
  },
  {
    "path": "spug_web/src/routes.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nimport React from 'react';\nimport {\n  DashboardOutlined,\n  DesktopOutlined,\n  CloudServerOutlined,\n  CodeOutlined,\n  FlagOutlined,\n  ScheduleOutlined,\n  DeploymentUnitOutlined,\n  MonitorOutlined,\n  AlertOutlined,\n  SettingOutlined\n} from '@ant-design/icons';\n\nimport HomeIndex from './pages/home';\nimport DashboardIndex from './pages/dashboard';\nimport HostIndex from './pages/host';\nimport ExecTask from './pages/exec/task';\nimport ExecTemplate from './pages/exec/template';\nimport ExecTransfer from './pages/exec/transfer';\nimport DeployApp from './pages/deploy/app';\nimport DeployRepository from './pages/deploy/repository';\nimport DeployRequest from './pages/deploy/request';\nimport ScheduleIndex from './pages/schedule';\nimport ConfigEnvironment from './pages/config/environment';\nimport ConfigService from './pages/config/service';\nimport ConfigApp from './pages/config/app';\nimport ConfigSetting from './pages/config/setting';\nimport MonitorIndex from './pages/monitor';\nimport AlarmIndex from './pages/alarm/alarm';\nimport AlarmGroup from './pages/alarm/group';\nimport AlarmContact from './pages/alarm/contact';\nimport SystemAccount from './pages/system/account';\nimport SystemRole from './pages/system/role';\nimport SystemSetting from './pages/system/setting';\nimport SystemLogin from './pages/system/login';\nimport WelcomeIndex from './pages/welcome/index';\nimport WelcomeInfo from './pages/welcome/info';\n\nexport default [\n  {icon: <DesktopOutlined/>, title: '工作台', path: '/home', component: HomeIndex},\n  {\n    icon: <DashboardOutlined/>,\n    title: 'Dashboard',\n    auth: 'dashboard.dashboard.view',\n    path: '/dashboard',\n    component: DashboardIndex\n  },\n  {icon: <CloudServerOutlined/>, title: '主机管理', auth: 'host.host.view', path: '/host', component: HostIndex},\n  {\n    icon: <CodeOutlined/>, title: '批量执行', auth: 'exec.task.do|exec.template.view', child: [\n      {title: '执行任务', auth: 'exec.task.do', path: '/exec/task', component: ExecTask},\n      {title: '模板管理', auth: 'exec.template.view', path: '/exec/template', component: ExecTemplate},\n      {title: '文件分发', auth: 'exec.transfer.do', path: '/exec/transfer', component: ExecTransfer},\n    ]\n  },\n  {\n    icon: <FlagOutlined/>, title: '应用发布', auth: 'deploy.app.view|deploy.repository.view|deploy.request.view', child: [\n      {title: '发布配置', auth: 'deploy.app.view', path: '/deploy/app', component: DeployApp},\n      {title: '构建仓库', auth: 'deploy.repository.view', path: '/deploy/repository', component: DeployRepository},\n      {title: '发布申请', auth: 'deploy.request.view', path: '/deploy/request', component: DeployRequest},\n    ]\n  },\n  {\n    icon: <ScheduleOutlined/>,\n    title: '任务计划',\n    auth: 'schedule.schedule.view',\n    path: '/schedule',\n    component: ScheduleIndex\n  },\n  {\n    icon: <DeploymentUnitOutlined/>, title: '配置中心', auth: 'config.env.view|config.src.view|config.app.view', child: [\n      {title: '环境管理', auth: 'config.env.view', path: '/config/environment', component: ConfigEnvironment},\n      {title: '服务配置', auth: 'config.src.view', path: '/config/service', component: ConfigService},\n      {title: '应用配置', auth: 'config.app.view', path: '/config/app', component: ConfigApp},\n      {path: '/config/setting/:type/:id', component: ConfigSetting},\n    ]\n  },\n  {icon: <MonitorOutlined/>, title: '监控中心', auth: 'monitor.monitor.view', path: '/monitor', component: MonitorIndex},\n  {\n    icon: <AlertOutlined/>, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [\n      {title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmIndex},\n      {title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmContact},\n      {title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmGroup},\n    ]\n  },\n  {\n    icon: <SettingOutlined/>, title: '系统管理', auth: \"system.account.view|system.role.view|system.setting.view\", child: [\n      {title: '登录日志', auth: 'system.login.view', path: '/system/login', component: SystemLogin},\n      {title: '账户管理', auth: 'system.account.view', path: '/system/account', component: SystemAccount},\n      {title: '角色管理', auth: 'system.role.view', path: '/system/role', component: SystemRole},\n      {title: '系统设置', auth: 'system.setting.view', path: '/system/setting', component: SystemSetting},\n    ]\n  },\n  {path: '/welcome/index', component: WelcomeIndex},\n  {path: '/welcome/info', component: WelcomeInfo},\n]\n"
  },
  {
    "path": "spug_web/src/serviceWorker.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\n// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read https://bit.ly/CRA-PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === 'localhost' ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === '[::1]' ||\n    // 127.0.0.1/8 is considered localhost for IPv4.\n    window.location.hostname.match(\n      /^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/\n    )\n);\n\nexport function register(config) {\n  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener('load', () => {\n      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log(\n            'This web app is being served cache-first by a service ' +\n              'worker. To learn more, visit https://bit.ly/CRA-PWA'\n          );\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nfunction registerValidSW(swUrl, config) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then(registration => {\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === 'installed') {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log(\n                'New content is available and will be used when all ' +\n                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'\n              );\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log('Content is cached for offline use.');\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n    })\n    .catch(error => {\n      console.error('Error during service worker registration:', error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl, config) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl)\n    .then(response => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get('content-type');\n      if (\n        response.status === 404 ||\n        (contentType != null && contentType.indexOf('javascript') === -1)\n      ) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then(registration => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log(\n        'No internet connection found. App is running in offline mode.'\n      );\n    });\n}\n\nexport function unregister() {\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.ready.then(registration => {\n      registration.unregister();\n    });\n  }\n}\n"
  },
  {
    "path": "spug_web/src/setupProxy.js",
    "content": "/**\n * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug\n * Copyright (c) <spug.dev@gmail.com>\n * Released under the AGPL-3.0 License.\n */\nconst proxy = require('http-proxy-middleware');\n\nmodule.exports = function (app) {\n  app.use(proxy('/api/', {\n    target: 'http://127.0.0.1:8000',\n    changeOrigin: true,\n    ws: true,\n    headers: {'X-Real-IP': '1.1.1.1'},\n    pathRewrite: {\n      '^/api': ''\n    }\n  }))\n};\n"
  }
]