commit 25dd8505a098338b6faf51523e0c179faa330cb4 Author: Andy Dixon Date: Wed Oct 7 13:08:54 2026 +0100 dockertop 1.0.0 htop-style view of Docker containers and the processes inside them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dae33b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: ci +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l .)" || { gofmt -d .; exit 1; } + - run: go vet ./... + - run: go test ./... + - run: make build + - run: man --warnings -l docs/dockertop.1 >/dev/null diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f6f378 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dockertop +bin/ +*.test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec668c0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 1.0.0 — 2026-10-07 + +First release. + +- Container list: CPU, memory and share of limit, network and block I/O + rates, pids, status and image, with totals against the host in the header. +- Tree view grouping containers by compose project, with project totals. +- Process view per container, laid out like htop: host PID, user from the + container's passwd, PRI, NI, VIRT, RES, state, CPU%, MEM%, TIME+, + command; flat or as a tree. +- Details (inspect summary, environment hidden by default) and followed logs. +- Actions: stop/start, restart, pause/unpause, signal to the container or to + one process. +- Search, sort, settings persisted in `~/.config/dockertop/config`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..53d1f3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e275bf6 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +BIN := bin +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION:v%=%) +PREFIX ?= /usr/local + +.PHONY: build install man test vet fmt brew-formula repo clean + +build: + go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/dockertop . + +install: build + install -Dm755 $(BIN)/dockertop $(DESTDIR)$(PREFIX)/bin/dockertop + install -Dm644 docs/dockertop.1 $(DESTDIR)$(PREFIX)/share/man/man1/dockertop.1 + +man: + man -l docs/dockertop.1 + +test: + go test ./... + +vet: + go vet ./... + +fmt: + gofmt -w . + +# Homebrew formula for the tagged release. The tag must already be pushed: +# the checksum is of GitHub's source tarball for that tag. +BREW_REPO := https://github.com/andydixon/dockertop +brew-formula: + @set -e; v=$(VERSION); v=$${v#v}; \ + case $$v in *-*) echo "brew-formula: HEAD is not exactly a release tag ($$v)"; exit 1;; esac; \ + url=$(BREW_REPO)/archive/refs/tags/v$$v.tar.gz; \ + sum=$$(curl -fsSL "$$url" | sha256sum | cut -d' ' -f1) || { echo "cannot fetch $$url (tag pushed?)"; exit 1; }; \ + mkdir -p $(BIN)/homebrew; \ + sed -e '/^#/d' -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/dockertop.rb.in > $(BIN)/homebrew/dockertop.rb; \ + echo "wrote $(BIN)/homebrew/dockertop.rb (v$$v, sha256 $$sum)" + +# Build .deb/.rpm/Arch packages for the newest tag and publish to repo.dixon.cx. +repo: + packaging/repo.sh + +clean: + rm -rf $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..186901b --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# dockertop + +htop-style view of Docker containers, and of the processes inside each one. +Linux only. + +![dockertop](https://img.shields.io/badge/platform-linux-blue) + +The main screen lists every container with CPU (100% = one core), memory and +its share of the container's limit, network and block I/O per second, process +count, status and image. The header totals CPU and memory across all +containers against the host. `t` groups containers under their compose +project with the project's totals. + +`Enter` on a container opens an htop for it: every process in its cgroup with +host PID, user, priority, nice, VIRT, RES, state, CPU%, MEM% (of the +container's limit), TIME+ and command, flat or as a tree. The header shows the +container's CPU against its limit, memory against its limit, tasks, threads, +pids limit, I/O rates and uptime. + +`d` shows details (image, command, state, health check, limits, networks, +ports, mounts, labels; environment hidden until you press `e`) and `l` follows +the logs. Actions, each confirmed first: stop/start (`x`), restart (`r`), +pause/unpause (`p`) and send a signal (`k`), to the container or, in the +process view, to the selected process. Search (`/`), sort (`s`), show stopped +(`a`), command line (`c`), help (`h`). Settings persist in +`~/.config/dockertop/config`. See `man dockertop`. + +It talks to the Engine API over the Docker socket directly, so you need to be +in the `docker` group or root. `dockertop NAME` opens straight into a +container's processes. + +```sh +dockertop +dockertop -t -r # grouped by compose project, running only +dockertop my-container # straight into its process view +``` + +## Install + +```sh +# Homebrew (Linux) +brew install andydixon/tap/dockertop + +# Debian, Ubuntu +sudo install -d -m 0755 /etc/apt/keyrings +curl -fsSL https://repo.dixon.cx/dixon.gpg | sudo tee /etc/apt/keyrings/dixon.gpg >/dev/null +echo "deb [signed-by=/etc/apt/keyrings/dixon.gpg] https://repo.dixon.cx/apt stable main" | + sudo tee /etc/apt/sources.list.d/dixon.list +sudo apt update && sudo apt install dockertop + +# Fedora, RHEL, AlmaLinux, Rocky +sudo curl -fsSL -o /etc/yum.repos.d/dixon.repo https://repo.dixon.cx/rpm/dixon.repo +sudo dnf install dockertop + +# openSUSE +sudo zypper addrepo https://repo.dixon.cx/rpm/dixon.repo +sudo zypper install dockertop + +# Arch Linux +curl -fsSL https://repo.dixon.cx/dixon.asc | sudo pacman-key --add - +sudo pacman-key --lsign-key C22FF7330668417C62C7A304CBC7951D7F2234D1 +printf '\n[dixon]\nServer = https://repo.dixon.cx/arch/$arch\n' | sudo tee -a /etc/pacman.conf +sudo pacman -Sy dockertop + +# From source +go install github.com/andydixon/dockertop@latest +# or +git clone https://github.com/andydixon/dockertop && cd dockertop && make && sudo make install +``` + +More at [repo.dixon.cx](https://repo.dixon.cx). + +## Development + +```sh +make # build bin/dockertop +make test vet # tests and go vet (CI runs these plus gofmt on every push) +make man # preview the manpage +make install # binary and manpage under /usr/local (PREFIX, DESTDIR) +``` + +Releases: tag `vX.Y.Z` and push it, then `make brew-formula` writes the +Homebrew formula for the tap and `make repo` builds and publishes the .deb, +.rpm and Arch packages to repo.dixon.cx. + +## Licence + +GPL-3.0-or-later. Andy Dixon, [www.dixon.cx](https://www.dixon.cx). diff --git a/config.go b/config.go new file mode 100644 index 0000000..c165678 --- /dev/null +++ b/config.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// config is ~/.config/dockertop/config: "key = value" lines, rewritten on exit. +type config struct { + Sort string // container list + ProcSort string // process view + All bool // show stopped containers + Tree bool // group containers by compose project + ProcTree bool + FullCmd bool + Refresh float64 // seconds +} + +var defaults = config{Sort: "CPU", ProcSort: "CPU", All: true, Refresh: 2} + +func configPath() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "dockertop", "config") +} + +func loadConfig() config { + c := defaults + b, err := os.ReadFile(configPath()) + if err != nil { + return c + } + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch k { + case "sort": + c.Sort = strings.ToUpper(v) + case "proc_sort": + c.ProcSort = strings.ToUpper(v) + case "all": + c.All = v == "true" + case "tree": + c.Tree = v == "true" + case "proc_tree": + c.ProcTree = v == "true" + case "full_cmd": + c.FullCmd = v == "true" + case "refresh": + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + c.Refresh = f + } + } + } + return c +} + +func (c config) save() error { + p := configPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte(fmt.Sprintf( + "# dockertop configuration; rewritten when dockertop exits. See dockertop(1).\nsort = %s\nproc_sort = %s\nall = %t\ntree = %t\nproc_tree = %t\nfull_cmd = %t\nrefresh = %g\n", + c.Sort, c.ProcSort, c.All, c.Tree, c.ProcTree, c.FullCmd, c.Refresh)), 0o644) +} diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..6cfb371 --- /dev/null +++ b/docker.go @@ -0,0 +1,499 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// Docker is a minimal Engine API client: plain HTTP over the daemon socket. +type Docker struct { + c *http.Client + base string + Host string +} + +func newDocker() (*Docker, error) { + host := os.Getenv("DOCKER_HOST") + if host == "" { + host = "unix:///var/run/docker.sock" + } + scheme, addr, _ := strings.Cut(host, "://") + tr := &http.Transport{MaxIdleConnsPerHost: 64} + d := &Docker{c: &http.Client{Transport: tr, Timeout: 60 * time.Second}, Host: host} + switch scheme { + case "unix": + d.base = "http://docker" + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var dl net.Dialer + return dl.DialContext(ctx, "unix", addr) + } + case "tcp": + // ponytail: no TLS; use a unix socket or an ssh tunnel if the daemon needs it. + if os.Getenv("DOCKER_TLS_VERIFY") != "" { + return nil, errors.New("DOCKER_TLS_VERIFY is set but TLS is not supported; tunnel the socket instead") + } + d.base = "http://" + addr + default: + return nil, fmt.Errorf("unsupported DOCKER_HOST %q (unix:// or tcp:// only)", host) + } + return d, nil +} + +// do sends a request and decodes a JSON reply into out (*[]byte takes the raw body). +func (d *Docker) do(method, path string, out any) error { + req, err := http.NewRequest(method, d.base+path, nil) + if err != nil { + return err + } + resp, err := d.c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { // 304 (already started/stopped) is fine + var e struct{ Message string } + json.NewDecoder(resp.Body).Decode(&e) + if e.Message == "" { + e.Message = resp.Status + } + return errors.New(e.Message) + } + switch o := out.(type) { + case nil: + return nil + case *[]byte: + *o, err = io.ReadAll(resp.Body) + return err + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// ---- containers and their stats ---- + +type Container struct { + ID, Name, Image, Command string + State, Status string // "running", "Up 3 hours (healthy)" + Project string // compose project, "" if none + Created int64 + Tree string // tree-view prefix, display only + Group bool // pseudo row: a compose project with its containers summed + + HasStats bool // running or paused, stats read + HasRate bool // a previous sample exists, so CPU and rates are valid + CPU float64 // percent of one core + Mem, MemLimit uint64 // bytes + Pids, PidsLimit uint64 + NetRx, NetTx float64 // bytes/s + BlkR, BlkW float64 // bytes/s + NetRxTot, NetTxTot uint64 + BlkRTot, BlkWTot uint64 + cpuNs uint64 + at time.Time + Online int +} + +func (c Container) MemPct() float64 { + if c.MemLimit == 0 { + return 0 + } + return float64(c.Mem) * 100 / float64(c.MemLimit) +} + +type Info struct { + Name, ServerVersion, OperatingSystem, CgroupDriver, CgroupVersion string + NCPU int + MemTotal uint64 + Images int +} + +type stats struct { + Read time.Time + CPU struct { + Usage struct { + Total uint64 `json:"total_usage"` + } `json:"cpu_usage"` + Online int `json:"online_cpus"` + } `json:"cpu_stats"` + Mem struct { + Usage, Limit uint64 + Stats map[string]uint64 + } `json:"memory_stats"` + Networks map[string]struct { + Rx uint64 `json:"rx_bytes"` + Tx uint64 `json:"tx_bytes"` + } + Pids struct{ Current, Limit uint64 } `json:"pids_stats"` + Blkio struct { + IO []struct { + Op string + Value uint64 + } `json:"io_service_bytes_recursive"` + } `json:"blkio_stats"` +} + +// collect lists every container and reads stats for the running ones in +// parallel. prev (the last result, by ID) is only read, to compute rates. +func (d *Docker) collect(prev map[string]Container) ([]Container, error) { + var list []struct { + ID string `json:"Id"` + Names []string + Image string + Command string + Created int64 + Labels map[string]string + State string + Status string + } + if err := d.do("GET", "/containers/json?all=1", &list); err != nil { + return nil, err + } + cs := make([]Container, len(list)) + var wg sync.WaitGroup + for i, l := range list { + name := l.ID[:12] + if len(l.Names) > 0 { + name = strings.TrimPrefix(l.Names[0], "/") + } + cs[i] = Container{ID: l.ID, Name: name, Image: l.Image, Command: l.Command, State: l.State, Status: l.Status, + Project: l.Labels["com.docker.compose.project"], Created: l.Created} + if l.State != "running" && l.State != "paused" { + continue + } + wg.Add(1) + go func(c *Container) { + defer wg.Done() + var s stats + if d.do("GET", "/containers/"+c.ID+"/stats?stream=false&one-shot=true", &s) == nil { + applyStats(c, s, prev[c.ID]) + } + }(&cs[i]) + } + wg.Wait() + return cs, nil +} + +func applyStats(c *Container, s stats, p Container) { + c.HasStats = true + c.at = s.Read + if c.at.IsZero() { + c.at = time.Now() + } + c.cpuNs, c.Online = s.CPU.Usage.Total, s.CPU.Online + // Same as `docker stats`: page cache that can be dropped doesn't count. + inactive := s.Mem.Stats["inactive_file"] // cgroup v2 + if v, ok := s.Mem.Stats["total_inactive_file"]; ok { + inactive = v // cgroup v1 + } + c.Mem, c.MemLimit = s.Mem.Usage, s.Mem.Limit + if inactive < c.Mem { + c.Mem -= inactive + } + c.Pids, c.PidsLimit = s.Pids.Current, s.Pids.Limit + for _, n := range s.Networks { + c.NetRxTot += n.Rx + c.NetTxTot += n.Tx + } + for _, b := range s.Blkio.IO { + switch strings.ToLower(b.Op) { + case "read": + c.BlkRTot += b.Value + case "write": + c.BlkWTot += b.Value + } + } + if !p.HasStats || p.at.IsZero() { + return + } + dt := c.at.Sub(p.at).Seconds() + if dt <= 0 { + return + } + c.HasRate = true + c.CPU = rate(c.cpuNs, p.cpuNs, dt) / 1e7 // ns/s -> percent of a core + c.NetRx, c.NetTx = rate(c.NetRxTot, p.NetRxTot, dt), rate(c.NetTxTot, p.NetTxTot, dt) + c.BlkR, c.BlkW = rate(c.BlkRTot, p.BlkRTot, dt), rate(c.BlkWTot, p.BlkWTot, dt) +} + +// rate is (cur-old)/dt, or 0 when the counter went backwards (container restarted). +func rate(cur, old uint64, dt float64) float64 { + if cur < old { + return 0 + } + return float64(cur-old) / dt +} + +func (d *Docker) info() (Info, error) { + var i Info + err := d.do("GET", "/info", &i) + return i, err +} + +// ---- inspect ---- + +type Inspect struct { + ID string `json:"Id"` + Name string + Created time.Time + Path string + Args []string + Image string + State struct { + Status string + Pid int + ExitCode int + OOMKilled bool + StartedAt, FinishedAt time.Time + Error string + Health *struct { + Status string + FailingStreak int + Log []struct { + ExitCode int + Output string + } + } + } + RestartCount int + Config struct { + Image, User, WorkingDir, Hostname string + Tty bool + Env, Entrypoint, Cmd []string + Labels map[string]string + } + HostConfig struct { + NanoCpus, CpuQuota, CpuPeriod, Memory, MemorySwap int64 + PidsLimit *int64 + CpusetCpus, NetworkMode string + Privileged, ReadonlyRootfs bool + RestartPolicy struct{ Name string } + } + Mounts []struct { + Type, Source, Destination, Mode string + RW bool + } + NetworkSettings struct { + Ports map[string][]struct{ HostIp, HostPort string } + Networks map[string]struct{ IPAddress, Gateway, MacAddress string } + } +} + +func (d *Docker) inspect(id string) (*Inspect, error) { + var i Inspect + if err := d.do("GET", "/containers/"+id+"/json", &i); err != nil { + return nil, err + } + return &i, nil +} + +// CPULimit is the container's CPU allowance in percent of one core, 0 if unlimited. +func (i *Inspect) CPULimit() float64 { + h := i.HostConfig + switch { + case h.NanoCpus > 0: + return float64(h.NanoCpus) / 1e7 + case h.CpuQuota > 0 && h.CpuPeriod > 0: + return float64(h.CpuQuota) * 100 / float64(h.CpuPeriod) + } + return 0 +} + +// details renders inspect output as the lines of the details view. +func (i *Inspect) details(c Container, showEnv bool) []string { + l := []string{} + add := func(f string, a ...any) { l = append(l, fmt.Sprintf(f, a...)) } + when := func(t time.Time) string { + if t.IsZero() || t.Year() < 2000 { + return "-" + } + return t.Local().Format("2006-01-02 15:04:05") + " (" + ago(time.Since(t)) + " ago)" + } + add("Name %s", strings.TrimPrefix(i.Name, "/")) + add("ID %s", i.ID) + add("Image %s (%s)", i.Config.Image, shortID(i.Image)) + add("Command %s", strings.TrimSpace(i.Path+" "+strings.Join(i.Args, " "))) + if i.Config.User != "" || i.Config.WorkingDir != "" { + add("User %s workdir %s", or(i.Config.User, "root"), or(i.Config.WorkingDir, "/")) + } + add("Created %s", when(i.Created)) + st := i.State + add("State %s pid %d restarts %d policy %s", st.Status, st.Pid, i.RestartCount, or(i.HostConfig.RestartPolicy.Name, "no")) + add("Started %s", when(st.StartedAt)) + if st.Status != "running" { + add("Finished %s exit code %d OOM killed %t", when(st.FinishedAt), st.ExitCode, st.OOMKilled) + } + if st.Error != "" { + add("Error %s", st.Error) + } + if h := st.Health; h != nil { + add("Health %s failing streak %d", h.Status, h.FailingStreak) + if n := len(h.Log); n > 0 { + add(" last check: exit %d %s", h.Log[n-1].ExitCode, oneLine(h.Log[n-1].Output)) + } + } + add("") + add("Limits") + hc := i.HostConfig + cpu := "unlimited" + if lim := i.CPULimit(); lim > 0 { + cpu = fmt.Sprintf("%.2f CPUs", lim/100) + } + if hc.CpusetCpus != "" { + cpu += " on cpus " + hc.CpusetCpus + } + mem := "unlimited" + if hc.Memory > 0 { + mem = human(uint64(hc.Memory)) + } + pids := "unlimited" + if hc.PidsLimit != nil && *hc.PidsLimit > 0 { + pids = fmt.Sprint(*hc.PidsLimit) + } + add(" CPU %s memory %s pids %s privileged %t read-only root %t", cpu, mem, pids, hc.Privileged, hc.ReadonlyRootfs) + if c.HasStats { + add("Usage") + add(" CPU %.1f%% memory %s / %s pids %d", c.CPU, human(c.Mem), human(c.MemLimit), c.Pids) + add(" net rx %s tx %s block read %s write %s (since start)", human(c.NetRxTot), human(c.NetTxTot), human(c.BlkRTot), human(c.BlkWTot)) + } + add("") + add("Network mode %s hostname %s", hc.NetworkMode, i.Config.Hostname) + for _, n := range sortedKeys(i.NetworkSettings.Networks) { + v := i.NetworkSettings.Networks[n] + add(" %-24s ip %-16s gw %-16s mac %s", n, or(v.IPAddress, "-"), or(v.Gateway, "-"), or(v.MacAddress, "-")) + } + for _, p := range sortedKeys(i.NetworkSettings.Ports) { + b := i.NetworkSettings.Ports[p] + if len(b) == 0 { + add(" port %-12s (not published)", p) + } + for _, h := range b { + add(" port %-12s -> %s:%s", p, or(h.HostIp, "*"), h.HostPort) + } + } + if len(i.Mounts) > 0 { + add("") + add("Mounts") + for _, m := range i.Mounts { + rw := "ro" + if m.RW { + rw = "rw" + } + add(" %-6s %s %s -> %s", m.Type, rw, m.Source, m.Destination) + } + } + if len(i.Config.Labels) > 0 { + add("") + add("Labels") + for _, k := range sortedKeys(i.Config.Labels) { + add(" %s = %s", k, i.Config.Labels[k]) + } + } + add("") + if showEnv { + add("Environment (e to hide)") + for _, e := range i.Config.Env { + add(" %s", e) + } + } else { + add("Environment %d variables hidden, they may hold secrets (e to show)", len(i.Config.Env)) + } + return l +} + +// ---- processes, logs, actions ---- + +type Top struct { + Titles []string + Processes [][]string +} + +func (d *Docker) top(id string) (*Top, error) { + var t Top + err := d.do("GET", "/containers/"+id+"/top", &t) + return &t, err +} + +var ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07`) + +func (d *Docker) logs(id string, tty bool, tail int) ([]string, error) { + var b []byte + if err := d.do("GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1&tail=%d", id, tail), &b); err != nil { + return nil, err + } + if !tty { + b = demux(b) + } + s := strings.ReplaceAll(ansi.ReplaceAllString(string(b), ""), "\r", "") + s = strings.ReplaceAll(s, "\t", " ") + return strings.Split(strings.TrimRight(s, "\n"), "\n"), nil +} + +// demux strips the 8-byte stream headers docker puts on non-TTY log output. +func demux(b []byte) []byte { + var out bytes.Buffer + for len(b) >= 8 { + n := int(binary.BigEndian.Uint32(b[4:8])) + b = b[8:] + n = min(n, len(b)) + out.Write(b[:n]) + b = b[n:] + } + return out.Bytes() +} + +// action POSTs a lifecycle call: start, stop, restart, pause, unpause, kill?signal=X. +func (d *Docker) action(id, what string) error { + return d.do("POST", "/containers/"+id+"/"+what, nil) +} + +// ---- small helpers ---- + +func sortedKeys[V any](m map[string]V) []string { + k := make([]string, 0, len(m)) + for s := range m { + k = append(k, s) + } + sort.Strings(k) + return k +} + +func or(s, def string) string { + if s == "" { + return def + } + return s +} + +func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } + +func shortID(s string) string { + s = strings.TrimPrefix(s, "sha256:") + if len(s) > 12 { + s = s[:12] + } + return s +} + +func ago(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + } + return fmt.Sprintf("%dd%02dh", int(d.Hours())/24, int(d.Hours())%24) +} diff --git a/dockertop_test.go b/dockertop_test.go new file mode 100644 index 0000000..30b6015 --- /dev/null +++ b/dockertop_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestParseStat(t *testing.T) { + // comm with spaces and a ')' must not shift the fields + in := "42 (my (odd) proc) S 1 42 42 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 3 0 1000 123456789 1500 18446744073709551615" + p, ok := parseStat([]byte(in)) + if !ok || p.Comm != "my (odd) proc" || p.State != 'S' || p.PPID != 1 || p.Ticks != 300 || + p.Pri != 20 || p.Nice != 0 || p.Threads != 3 || p.Virt != 123456789 { + t.Fatalf("bad parse: %+v", p) + } + if cpuTime(300) != "0:03.00" || cpuTime(100*3725) != "1h02:05" { + t.Fatalf("cpuTime: %s %s", cpuTime(300), cpuTime(100*3725)) + } +} + +func TestDemux(t *testing.T) { + frame := func(stream byte, s string) string { + n := len(s) + return string([]byte{stream, 0, 0, 0, byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}) + s + } + in := frame(1, "out line\n") + frame(2, "err line\n") + if got := string(demux([]byte(in))); got != "out line\nerr line\n" { + t.Fatalf("demux: %q", got) + } + if got := ansi.ReplaceAllString("\x1b[31mred\x1b[0m", ""); got != "red" { + t.Fatalf("ansi: %q", got) + } +} + +func TestStatsRates(t *testing.T) { + t0 := time.Unix(1000, 0) + var s stats + s.Read = t0 + s.CPU.Usage.Total = 1e9 + s.Mem.Usage, s.Mem.Limit = 300, 1000 + s.Mem.Stats = map[string]uint64{"inactive_file": 100} + var prev Container + applyStats(&prev, s, Container{}) + if prev.HasRate || prev.Mem != 200 { + t.Fatalf("first sample: %+v", prev) + } + s.Read = t0.Add(2 * time.Second) + s.CPU.Usage.Total = 2e9 // 1 CPU-second over 2s = 50% + var c Container + applyStats(&c, s, prev) + if !c.HasRate || c.CPU != 50 || c.MemPct() != 20 { + t.Fatalf("rates: %+v", c) + } + if rate(5, 10, 1) != 0 { + t.Fatal("counter reset must give 0") + } +} + +func TestGroupRows(t *testing.T) { + cs := []Container{ + {ID: "a", Name: "web-1", Project: "web", CPU: 30, State: "running"}, + {ID: "b", Name: "solo", CPU: 20, State: "running"}, + {ID: "c", Name: "web-db", Project: "web", CPU: 5, State: "exited"}, + } + byCPU := func(x, y Container) bool { return x.CPU > y.CPU } + r := groupRows(cs, byCPU) + var got []string + for _, c := range r { + got = append(got, c.Tree+c.Name) + } + if strings.Join(got, ",") != "web,├─ web-1,└─ web-db,solo" || r[0].CPU != 35 || r[0].Status != "1/2 running" { + t.Fatalf("bad grouping: %q %+v", got, r[0]) + } +} + +func TestConfig(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + c := defaults + c.Sort, c.ProcSort, c.Tree, c.All, c.Refresh = "MEM", "TIME", true, false, 0.5 + if err := c.save(); err != nil { + t.Fatal(err) + } + if got := loadConfig(); got != c { + t.Fatalf("round trip: %+v != %+v", got, c) + } +} diff --git a/docs/dockertop.1 b/docs/dockertop.1 new file mode 100644 index 0000000..ed5de57 --- /dev/null +++ b/docs/dockertop.1 @@ -0,0 +1,331 @@ +.\" Manual page for dockertop. View with: man -l docs/dockertop.1 +.TH DOCKERTOP 1 "October 2026" "dockertop 1.0.0" "User Commands" +.SH NAME +dockertop \- htop\-style view of Docker containers and the processes inside them +.SH SYNOPSIS +.B dockertop +[\fB\-d\fR \fISECONDS\fR] [\fB\-s\fR \fICOLUMN\fR] [\fB\-t\fR] [\fB\-r\fR] [\fICONTAINER\fR] +.br +.B dockertop +\fB\-\-version\fR +.SH DESCRIPTION +.B dockertop +is a full\-screen terminal monitor, laid out like +.BR htop (1), +for Docker. The main screen lists every container with its CPU, memory, +network and block I/O usage, process count and status. Selecting a container +and pressing \fBEnter\fR opens an htop\-like view of the processes running +inside it. +.PP +It talks to the Docker Engine API directly over the daemon socket, so the +.BR docker (1) +command line is not needed. Container figures come from the same statistics +as +.BR "docker stats" . +Process figures are read from +.IR /proc +on the host, using the host PIDs that the daemon reports. +.PP +Lifecycle actions (stop, start, restart, pause, signal) are bound to single +letter keys and always ask for confirmation first. +.SH OPTIONS +Every option overrides the value loaded from the configuration file. +.TP +.BI \-d " SECONDS" +Refresh interval. Fractions are allowed. Default 2. +.TP +.BI \-s " COLUMN" +Sort column of the container list, one of +.BR CPU , +.BR MEM , +.BR NET , +.BR IO , +.BR PIDS , +.BR NAME , +.BR IMAGE , +.BR STATE . +Default \fBCPU\fR. +.TP +.B \-t +Start in tree view, containers grouped by compose project. +.TP +.B \-r +Show running containers only. By default stopped ones are listed too. +.TP +.I CONTAINER +A container name or ID prefix. dockertop opens straight into that +container's process view. +.TP +.B \-\-version +Print the version and exit. +.TP +.B \-h +Print a short usage message and exit. +.SH DISPLAY +.SS Header +.TP +.B CPU +CPU used by all containers together, out of every core on the host. +.TP +.B Mem +Memory used by all containers together, out of the host's RAM. +.TP +Containers +How many containers are running, paused, stopped or restarting, then the +Docker version, host name, operating system and cgroup version. +.TP +Net, Block +Network traffic and block I/O per second, summed over every container, and +the current sort order. +.SS Columns +.TP +.B NAME +Container name. In tree view, containers are indented under a summary row +for their compose project. +.TP +.B CPU% +CPU time per second of wall time, as a percentage of one core: 100 is one +full core, as in +.B htop +and +.BR "docker stats" . +Shown as \fB\-\fR until two samples have been taken. +.TP +.B MEM +Memory charged to the container's cgroup, minus inactive page cache, the +same figure +.B docker stats +shows. +.TP +.B MEM% +\fBMEM\fR as a share of the container's memory limit, or of host RAM when +it has none. Yellow from 50%, red from 90%. +.TP +.BR "NET RX" ", " "NET TX" +Bytes received and sent per second, over all of the container's networks. +.TP +.BR READ ", " WRITE +Block device bytes read and written per second. +.TP +.B PIDS +Number of tasks (processes and threads) in the container. +.TP +.B STATUS +Docker's status text. Red for unhealthy, dead or a non\-zero exit, +yellow for paused or a health check still starting, grey for stopped. +.TP +.B IMAGE +Image name, or the command after pressing \fBc\fR. +.SS Process view +.B Enter +on a container shows the processes in it, like +.BR htop . +The header has the container's CPU against its limit (or all cores), memory +against its limit, task, thread and running counts, the pids limit, network +and block I/O rates and uptime. The columns are: +.TP +.B PID +Process ID on the host (not inside the container's PID namespace). +.TP +.B USER +User name from the container's own +.IR /etc/passwd , +read through +.IR /proc/PID/root . +That needs root; otherwise UID 0 shows as \fBroot\fR and other users by +number, because host user names would be wrong for container UIDs. +.TP +.BR PRI ", " NI +Kernel priority and nice value. +.TP +.BR VIRT ", " RES +Virtual size and resident memory. +.TP +.B S +State: R running, S sleeping, D uninterruptible (red), Z zombie, T stopped. +.TP +.B CPU% +Share of one core over the last refresh. +.TP +.B MEM% +Resident memory as a share of the container's memory limit. +.TP +.B TIME+ +User plus system CPU time used. +.TP +.B Command +Process name, or the full command line after pressing \fBc\fR. Indented +under its parent in tree view. +.SS Details and logs +.B d +shows the container's configuration: image, command, user, state, health +and the last health check, limits, current usage and totals since start, +networks and published ports, mounts and labels. Environment variables are +hidden, since they often hold secrets; \fBe\fR shows them. +.PP +.B l +shows the last 2000 lines of the container's log, stdout and stderr, +refreshed every interval and following the end. Scrolling up stops +following; \fBEnd\fR resumes. Colour escapes are stripped. +.SH KEYS +.TP +.B Up, Down, PgUp, PgDn, Home, End +Move the selection, or scroll details and logs. +.TP +.B Left, Right +Pan long lines in details and logs. +.TP +.B Enter +Process view of the selected container. +.TP +.B Esc, q +Back to the previous screen. \fBq\fR on the container list quits. +.TP +.B h, ? +Help. +.TP +.B / +Incremental search: over name, ID, image, compose project and status in +the list; over command line, name, user and PID in the process view. +\fBEnter\fR keeps the filter, \fBEsc\fR clears it. +.TP +.B s +Cycle the sort column. The process view sorts by +.BR CPU , +.BR MEM , +.BR TIME , +.BR PID , +.B USER +or +.BR COMMAND . +.TP +.B t +Toggle tree view: compose projects in the list, the process tree in the +process view. +.TP +.B a +Toggle showing stopped containers. +.TP +.B c +Toggle image or command in the list, process name or full command line in +the process view. +.TP +.B d +Details of the selected container. +.TP +.B l +Logs of the selected container. +.TP +.B e +Show or hide environment variables in the details view. +.TP +.B Ctrl\-C +Quit at once. +.SH ACTIONS +Each action asks for confirmation before doing anything. In the process +view, container actions apply to the container being viewed. Summary rows +of compose projects take no actions. +.TP +.B x \(em stop or start +Stops a running or paused container (SIGTERM, then SIGKILL once the +container's stop timeout, 10 seconds by default, has passed), or starts a +stopped one. +.TP +.B r \(em restart +Stops and starts the container. +.TP +.B p \(em pause +Freezes every process in the container with the cgroup freezer, or thaws a +paused one. +.TP +.B k \(em signal +Opens a picker of common signals (TERM, KILL, INT, HUP, QUIT, USR1, USR2, +STOP, CONT). In the list the signal goes to the container's main process +through the daemon. In the process view it goes to the selected process +directly, with +.BR kill (2), +which needs permission over that process: usually root. +.SH PERMISSIONS +dockertop needs the Docker socket, so its user must be root or in the +\fBdocker\fR group. Membership of that group is equivalent to root on the +host. +.PP +Everything else works without root, except the container's own user names +and signalling processes in the process view. +.SH ENVIRONMENT +.TP +.B DOCKER_HOST +Daemon address, \fBunix://\fR\fIPATH\fR (default +\fIunix:///var/run/docker.sock\fR) or \fBtcp://\fR\fIHOST\fR:\fIPORT\fR. +TLS is not supported: when \fBDOCKER_TLS_VERIFY\fR is set dockertop refuses +to start. Forward the socket over +.BR ssh (1) +instead. Docker contexts are not read; set \fBDOCKER_HOST\fR. +.PP +With a remote daemon the container list works as usual, but the host PIDs +are not in the local +.IR /proc . +The process view then shows the daemon's own +.B ps +output, in which CPU% is a lifetime average, and processes cannot be +signalled. +.TP +.B XDG_CONFIG_HOME +Where the configuration lives, see +.BR FILES . +.SH CONFIGURATION +Settings are read from the file below at start\-up and written back when +.B dockertop +exits, so the sort orders, tree views, stopped\-container filter, +command\-line display and refresh interval persist between runs. The file +holds one +.I key = value +per line and may be edited by hand: +.PP +.nf + sort = CPU # container list, see \-s + proc_sort = CPU # process view + all = true # false lists running containers only + tree = false # group by compose project + proc_tree = false # process tree + full_cmd = false + refresh = 2 # seconds +.fi +.SH FILES +.TP +.I $XDG_CONFIG_HOME/dockertop/config +Configuration, normally +.IR ~/.config/dockertop/config . +When run under +.BR sudo (8) +this is root's home unless \fBXDG_CONFIG_HOME\fR is passed through. +.TP +.I /var/run/docker.sock +The Docker daemon socket. +.TP +.IR /proc/PID/stat ", " /proc/PID/status ", " /proc/PID/cmdline ", " /proc/PID/cgroup +Process figures for the process view. See +.BR proc (5). +.SH EXIT STATUS +0 on a normal quit, 1 if the daemon cannot be reached or the terminal cannot +be initialised, 2 on a bad option. +.SH NOTES +Statistics for every running container are requested in parallel on each +refresh. With hundreds of containers a longer interval (\fB\-d 5\fR) eases +the load on the daemon. +.PP +Block I/O counts every device the kernel charges, so I/O through RAID or +device\-mapper may be counted on each layer, as in +.BR "docker stats" . +.SH SEE ALSO +.BR htop (1), +.BR docker (1), +.BR docker\-stats (1), +.BR docker\-top (1), +.BR swaptop (1), +.BR proc (5), +.BR cgroups (7) +.SH AUTHOR +Andy Dixon , https://www.dixon.cx +.SH BUGS +Report at https://github.com/andydixon/dockertop/issues diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95c7485 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/andydixon/dockertop + +go 1.24.0 + +require github.com/gdamore/tcell/v2 v2.13.10 + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..992dc6d --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ef9ae1c --- /dev/null +++ b/main.go @@ -0,0 +1,1498 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/gdamore/tcell/v2" +) + +var version = "dev" + +type view int + +const ( + viewList view = iota + viewProcs + viewText + viewHelp +) + +var sortNames = []string{"CPU", "MEM", "NET", "IO", "PIDS", "NAME", "IMAGE", "STATE"} +var psortNames = []string{"CPU", "MEM", "TIME", "PID", "USER", "COMMAND"} + +var signals = []struct { + name string + sig syscall.Signal +}{ + {"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}, {"SIGINT", syscall.SIGINT}, {"SIGHUP", syscall.SIGHUP}, + {"SIGQUIT", syscall.SIGQUIT}, {"SIGUSR1", syscall.SIGUSR1}, {"SIGUSR2", syscall.SIGUSR2}, + {"SIGSTOP", syscall.SIGSTOP}, {"SIGCONT", syscall.SIGCONT}, +} + +// modal is a yes/no question (onYes), an info box (neither) or a picker (items/onPick). +type modal struct { + title string + lines []string + onYes func() + items []string + pick int + onPick func(int) +} + +// snapMsg carries a finished background collection to the UI goroutine. +type snapMsg struct { + cs []Container + err error +} + +type app struct { + scr tcell.Screen + cfg config + dk *Docker + info Info + + // container list + snap []Container + byID map[string]Container + err error + loading bool + rows []Container + sel, top int + sortBy int + filter string + all bool + tree bool + fullCmd bool + openName string // container to open once the first snapshot arrives (command-line argument) + + // process view of a.cur + cur Container + insp *Inspect + procs []Proc + prows []Proc + remote *Top // daemon is not on this host: docker top's own table + procErr error + pprev map[int]uint64 + pat time.Time + users map[int]string + psel, ptop int + psortBy int + pfilter string + ptree bool + + // text view: details or logs of a.cur + text []string + textErr error + textTitle string + textLoad func() ([]string, error) + textFrom view + ttop int + tleft int + follow bool + showEnv bool + + view view + helpFrom view + searching bool + modal *modal + busy bool + + mu sync.Mutex + status string +} + +var ( + stDefault = tcell.StyleDefault + stHdr = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorGreen) + stSel = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) + stKey = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) + stFn = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) + stLabel = tcell.StyleDefault.Foreground(tcell.ColorTeal).Bold(true) + stDim = tcell.StyleDefault.Foreground(tcell.ColorGray) + stWarn = tcell.StyleDefault.Foreground(tcell.ColorYellow).Bold(true) + stErr = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) + stGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) + stYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) + stMagenta = tcell.StyleDefault.Foreground(tcell.ColorFuchsia) + stRed = tcell.StyleDefault.Foreground(tcell.ColorRed) +) + +func indexOf(names []string, name string) int { + for i, n := range names { + if n == strings.ToUpper(name) { + return i + } + } + return 0 +} + +func main() { + cfg := loadConfig() + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: dockertop [-d SECONDS] [-s COLUMN] [-t] [-r] [--version] [CONTAINER]\n\nhtop-style view of Docker containers and the processes inside them.\nCONTAINER (name or ID prefix) opens straight into its process view. Press h inside for keys.\n\n") + flag.PrintDefaults() + } + delay := flag.Float64("d", cfg.Refresh, "refresh interval in seconds") + sortFlag := flag.String("s", cfg.Sort, "sort column: "+strings.Join(sortNames, ", ")) + tree := flag.Bool("t", cfg.Tree, "group containers by compose project") + running := flag.Bool("r", !cfg.All, "show running containers only") + ver := flag.Bool("version", false, "print the version and exit") + flag.Parse() + if *ver { + fmt.Println("dockertop", version) + return + } + if *delay <= 0 || flag.NArg() > 1 { + flag.Usage() + os.Exit(2) + } + cfg.Refresh = *delay + + dk, err := newDocker() + if err != nil { + fmt.Fprintln(os.Stderr, "dockertop:", err) + os.Exit(1) + } + info, err := dk.info() + if err != nil { + fmt.Fprintf(os.Stderr, "dockertop: cannot reach the Docker daemon at %s: %v%s\n", dk.Host, err, permHint(err)) + os.Exit(1) + } + + scr, err := tcell.NewScreen() + if err == nil { + err = scr.Init() + } + if err != nil { + fmt.Fprintln(os.Stderr, "dockertop:", err) + os.Exit(1) + } + a := &app{scr: scr, cfg: cfg, dk: dk, info: info, sortBy: indexOf(sortNames, *sortFlag), psortBy: indexOf(psortNames, cfg.ProcSort), + tree: *tree, all: !*running, ptree: cfg.ProcTree, fullCmd: cfg.FullCmd, openName: flag.Arg(0)} + a.refresh() + events := make(chan tcell.Event, 64) + go func() { + for { + ev := scr.PollEvent() + if ev == nil { + return + } + events <- ev + } + }() + tick := time.NewTicker(time.Duration(cfg.Refresh * float64(time.Second))) +loop: + for { + a.draw() + select { + case ev := <-events: + if a.handle(ev) { + break loop + } + case <-tick.C: + a.tick() + } + } + scr.Fini() + cfg.Sort, cfg.ProcSort, cfg.All, cfg.Tree, cfg.ProcTree, cfg.FullCmd = sortNames[a.sortBy], psortNames[a.psortBy], a.all, a.tree, a.ptree, a.fullCmd + if err := cfg.save(); err != nil { + fmt.Fprintln(os.Stderr, "dockertop: saving config:", err) + } +} + +func (a *app) tick() { + a.refresh() + switch a.view { + case viewProcs: + a.sampleProcs() + case viewText: + a.loadText() + } +} + +// refresh starts a background collection unless one is in flight; the +// result comes back as a snapMsg event. +func (a *app) refresh() { + if a.loading { + return + } + a.loading = true + prev := a.byID + go func() { + cs, err := a.dk.collect(prev) + a.scr.PostEvent(tcell.NewEventInterrupt(snapMsg{cs, err})) + }() +} + +func (a *app) apply(m snapMsg) { + a.loading = false + a.err = m.err + if m.err != nil { + return + } + first := a.snap == nil + a.snap = m.cs + a.byID = make(map[string]Container, len(m.cs)) + for _, c := range m.cs { + a.byID[c.ID] = c + } + if c, ok := a.byID[a.cur.ID]; ok { + a.cur = c + } + a.rebuild() + if first { + a.refresh() // CPU and rates need a second sample; don't make the user wait a whole interval + if a.openName != "" { + a.openByName() + } + } +} + +func (a *app) openByName() { + n := a.openName + a.openName = "" + for _, c := range a.snap { + if c.Name == n || strings.HasPrefix(c.ID, n) { + a.openProcs(c) + return + } + } + a.notice("No such container", fmt.Sprintf("No container is named %q or has an ID starting with it.", n)) +} + +// ---- container list ---- + +func stateRank(s string) int { + return strings.Index("running restarting paused created exited dead", s) +} + +func (a *app) less(x, y Container) bool { + var d float64 + switch sortNames[a.sortBy] { + case "CPU": + d = x.CPU - y.CPU + case "MEM": + d = float64(x.Mem) - float64(y.Mem) + case "NET": + d = x.NetRx + x.NetTx - y.NetRx - y.NetTx + case "IO": + d = x.BlkR + x.BlkW - y.BlkR - y.BlkW + case "PIDS": + d = float64(x.Pids) - float64(y.Pids) + case "IMAGE": + if x.Image != y.Image { + return x.Image < y.Image + } + case "STATE": + if x.State != y.State { + return stateRank(x.State) < stateRank(y.State) + } + } + if d != 0 { + return d > 0 + } + return x.Name < y.Name +} + +func (a *app) visible(c Container) bool { + if !a.all && c.State != "running" && c.State != "paused" && c.State != "restarting" { + return false + } + f := strings.ToLower(a.filter) + return f == "" || strings.Contains(strings.ToLower(c.Name+" "+c.ID[:12]+" "+c.Image+" "+c.Project+" "+c.Status), f) +} + +func (a *app) rebuild() { + selID := "" + if a.sel < len(a.rows) { + selID = a.rows[a.sel].ID + } + var vis []Container + for _, c := range a.snap { + if a.visible(c) { + vis = append(vis, c) + } + } + sort.SliceStable(vis, func(i, j int) bool { return a.less(vis[i], vis[j]) }) + a.rows = vis + if a.tree { + a.rows = groupRows(vis, a.less) + } + a.sel = 0 + for i, c := range a.rows { + if c.ID == selID { + a.sel = i + } + } +} + +// groupRows puts containers under one summary row per compose project. +// cs is already sorted; containers outside any project stay top level. +func groupRows(cs []Container, less func(x, y Container) bool) []Container { + kids := map[string][]Container{} + var tops []Container + for _, c := range cs { + if c.Project == "" { + tops = append(tops, c) + continue + } + if kids[c.Project] == nil { + tops = append(tops, Container{ID: "project:" + c.Project, Name: c.Project, Project: c.Project, Group: true, State: "exited"}) + } + kids[c.Project] = append(kids[c.Project], c) + } + for i := range tops { + g := &tops[i] + if !g.Group { + continue + } + up := 0 + for _, c := range kids[g.Project] { + g.CPU += c.CPU + g.Mem += c.Mem + g.Pids += c.Pids + g.NetRx, g.NetTx, g.BlkR, g.BlkW = g.NetRx+c.NetRx, g.NetTx+c.NetTx, g.BlkR+c.BlkR, g.BlkW+c.BlkW + g.HasStats, g.HasRate = g.HasStats || c.HasStats, g.HasRate || c.HasRate + if c.State == "running" { + up++ + g.State = "running" + } + } + g.Status = fmt.Sprintf("%d/%d running", up, len(kids[g.Project])) + } + sort.SliceStable(tops, func(i, j int) bool { return less(tops[i], tops[j]) }) + var out []Container + for _, t := range tops { + out = append(out, t) + k := kids[t.Project] + if !t.Group { + continue + } + for i, c := range k { + c.Tree = "├─ " + if i == len(k)-1 { + c.Tree = "└─ " + } + out = append(out, c) + } + } + return out +} + +// ---- process view ---- + +func (a *app) openProcs(c Container) { + if c.Group { + return + } + a.cur, a.view = c, viewProcs + a.psel, a.ptop, a.pfilter, a.pprev, a.procs, a.remote = 0, 0, "", nil, nil, nil + a.insp, _ = a.dk.inspect(c.ID) + a.users = nil + if a.insp != nil { + a.users = containerUsers(a.insp.State.Pid) + } + a.sampleProcs() + // CPU% needs a second reading: take one shortly instead of a whole interval later. + time.AfterFunc(700*time.Millisecond, func() { a.scr.PostEvent(tcell.NewEventInterrupt("procs")) }) +} + +func (a *app) sampleProcs() { + if a.cur.State != "running" && a.cur.State != "paused" { + a.procs, a.remote, a.procErr = nil, nil, fmt.Errorf("container is %s", a.cur.State) + a.rebuildProcs() + return + } + t, err := a.dk.top(a.cur.ID) + a.procErr = err + if err != nil { + a.procs, a.remote = nil, nil + a.rebuildProcs() + return + } + col := -1 + for i, h := range t.Titles { + if h == "PID" { + col = i + } + } + var pids []int + for _, row := range t.Processes { + if col >= 0 && col < len(row) { + if pid, err := strconv.Atoi(row[col]); err == nil { + pids = append(pids, pid) + } + } + } + if len(pids) > 0 && !isLocal(pids[0], a.cur.ID) { + a.procs, a.remote = nil, t + return + } + a.remote = nil + now := time.Now() + a.procs = readProcs(pids, a.pprev, now.Sub(a.pat).Seconds()) + a.pprev, a.pat = make(map[int]uint64, len(a.procs)), now + for i := range a.procs { + p := &a.procs[i] + a.pprev[p.PID] = p.Ticks + p.User = a.users[p.UID] + switch { + case p.User != "": + case p.UID == 0: + p.User = "root" + default: + p.User = strconv.Itoa(p.UID) + } + } + a.rebuildProcs() +} + +func (a *app) memPct(p Proc) float64 { + if a.cur.MemLimit == 0 { + return 0 + } + return float64(p.RSS) * 100 / float64(a.cur.MemLimit) +} + +func (a *app) pless(x, y Proc) bool { + switch psortNames[a.psortBy] { + case "MEM": + return x.RSS > y.RSS + case "TIME": + return x.Ticks > y.Ticks + case "PID": + return x.PID < y.PID + case "USER": + return x.User < y.User + case "COMMAND": + return x.Comm < y.Comm + } + if x.CPU != y.CPU { + return x.CPU > y.CPU + } + return x.PID < y.PID +} + +func (a *app) pvisible(p Proc) bool { + f := strings.ToLower(a.pfilter) + return f == "" || strings.Contains(strings.ToLower(p.Cmd+" "+p.Comm+" "+p.User+" "+strconv.Itoa(p.PID)), f) +} + +func (a *app) rebuildProcs() { + selPID := -1 + if a.psel < len(a.prows) { + selPID = a.prows[a.psel].PID + } + a.prows = a.prows[:0] + if a.ptree { + a.buildTree() + } else { + for _, p := range a.procs { + if a.pvisible(p) { + a.prows = append(a.prows, p) + } + } + sort.SliceStable(a.prows, func(i, j int) bool { return a.pless(a.prows[i], a.prows[j]) }) + } + a.psel = 0 + for i, p := range a.prows { + if p.PID == selPID { + a.psel = i + } + } +} + +// buildTree lays processes out by parent, htop style. A process is shown if it +// or any descendant passes the filter, so ancestors stay for context. +func (a *app) buildTree() { + kids := map[int][]Proc{} + present := map[int]bool{} + for _, p := range a.procs { + present[p.PID] = true + kids[p.PPID] = append(kids[p.PPID], p) + } + for _, k := range kids { + sort.SliceStable(k, func(i, j int) bool { return a.pless(k[i], k[j]) }) + } + keep := map[int]bool{} + var mark func(p Proc) bool + mark = func(p Proc) bool { + ok := a.pvisible(p) + for _, c := range kids[p.PID] { + if mark(c) { + ok = true + } + } + keep[p.PID] = ok + return ok + } + var walk func(p Proc, prefix, branch string) + walk = func(p Proc, prefix, branch string) { + p.Tree = prefix + branch + a.prows = append(a.prows, p) + var shown []Proc + for _, c := range kids[p.PID] { + if keep[c.PID] { + shown = append(shown, c) + } + } + childPrefix := prefix + if branch == "├─ " { + childPrefix += "│ " + } else if branch == "└─ " { + childPrefix += " " + } + for i, c := range shown { + b := "├─ " + if i == len(shown)-1 { + b = "└─ " + } + walk(c, childPrefix, b) + } + } + var roots []Proc + for _, p := range a.procs { + if !present[p.PPID] || p.PPID == p.PID { + roots = append(roots, p) + } + } + sort.SliceStable(roots, func(i, j int) bool { return a.pless(roots[i], roots[j]) }) + for _, r := range roots { + if mark(r) { + walk(r, "", "") + } + } +} + +// ---- text views: details and logs ---- + +func (a *app) openText(c Container, title string, logs bool) { + if c.Group { + return + } + a.cur, a.textTitle, a.textFrom, a.view = c, title, a.view, viewText + a.ttop, a.tleft, a.follow, a.showEnv = 0, 0, logs, false + insp, err := a.dk.inspect(c.ID) + if err != nil { + a.text, a.textErr, a.textLoad = nil, err, nil + return + } + if logs { + a.textLoad = func() ([]string, error) { return a.dk.logs(c.ID, insp.Config.Tty, 2000) } + } else { + a.textLoad = func() ([]string, error) { + i, err := a.dk.inspect(c.ID) + if err != nil { + return nil, err + } + return i.details(a.cur, a.showEnv), nil + } + } + a.loadText() +} + +func (a *app) loadText() { + if a.textLoad == nil { + return + } + t, err := a.textLoad() + a.textErr = err + if err == nil { + a.text = t + } +} + +func (a *app) textAvail() int { + _, h := a.scr.Size() + return max(h-3, 1) +} + +// ---- input ---- + +func (a *app) handle(ev tcell.Event) (quit bool) { + switch ev := ev.(type) { + case *tcell.EventResize: + a.scr.Sync() + case *tcell.EventInterrupt: + switch d := ev.Data().(type) { + case snapMsg: + a.apply(d) + case string: + if d == "done" { + a.busy = false + a.refresh() + if a.view == viewProcs { + a.sampleProcs() + } + } else if d == "procs" && a.view == viewProcs { + a.sampleProcs() + } + } + case *tcell.EventKey: + return a.key(ev) + } + return false +} + +func (a *app) modalKey(ev *tcell.EventKey) { + m := a.modal + switch { + case m.items != nil: + switch ev.Key() { + case tcell.KeyUp: + m.pick = max(m.pick-1, 0) + case tcell.KeyDown: + m.pick = min(m.pick+1, len(m.items)-1) + case tcell.KeyEnter: + a.modal = nil + m.onPick(m.pick) + case tcell.KeyEscape: + a.modal = nil + case tcell.KeyRune: + if ev.Rune() == 'q' { + a.modal = nil + } + } + case m.onYes != nil && (ev.Rune() == 'y' || ev.Rune() == 'Y'): + a.modal = nil + m.onYes() + case m.onYes == nil || ev.Key() == tcell.KeyEscape || ev.Rune() == 'n' || ev.Rune() == 'N' || ev.Rune() == 'q': + a.modal = nil + } +} + +func (a *app) search(ev *tcell.EventKey) { + f, rebuild := &a.filter, a.rebuild + if a.view == viewProcs { + f, rebuild = &a.pfilter, a.rebuildProcs + } + switch ev.Key() { + case tcell.KeyEscape: + *f, a.searching = "", false + case tcell.KeyEnter: + a.searching = false + case tcell.KeyBackspace, tcell.KeyBackspace2: + if r := []rune(*f); len(r) > 0 { + *f = string(r[:len(r)-1]) + } + case tcell.KeyRune: + *f += string(ev.Rune()) + } + rebuild() +} + +func (a *app) back() { + switch a.view { + case viewText: + a.view = a.textFrom + case viewHelp: + a.view = a.helpFrom + default: + a.view = viewList + } +} + +func (a *app) key(ev *tcell.EventKey) bool { + if ev.Key() == tcell.KeyCtrlC { + return true + } + if a.modal != nil { + a.modalKey(ev) + return false + } + if a.searching { + a.search(ev) + return false + } + if a.view == viewHelp { + a.back() + return false + } + v, n, pos := a.view, len(a.rows), &a.sel + switch v { + case viewProcs: + n, pos = len(a.prows), &a.psel + if a.remote != nil { + n = len(a.remote.Processes) + } + case viewText: + n, pos = len(a.text)-a.textAvail()+1, &a.ttop + } + _, h := a.scr.Size() + pg := max(h-8, 1) + switch ev.Key() { + case tcell.KeyUp: + *pos-- + case tcell.KeyDown: + *pos++ + case tcell.KeyPgUp: + *pos -= pg + case tcell.KeyPgDn: + *pos += pg + case tcell.KeyHome: + *pos = 0 + case tcell.KeyEnd: + *pos = n - 1 + case tcell.KeyLeft: + a.tleft = max(a.tleft-8, 0) + case tcell.KeyRight: + a.tleft += 8 + case tcell.KeyEnter: + if a.view == viewList && a.sel < len(a.rows) { + a.openProcs(a.rows[a.sel]) + return false + } + case tcell.KeyEscape: + a.back() + return false + case tcell.KeyRune: + if a.rune(ev.Rune()) { + return true + } + } + if v == viewText && a.view == viewText { + n = len(a.text) - a.textAvail() + 1 + a.follow = *pos >= n-1 + } + *pos = max(0, min(*pos, n-1)) + return false +} + +func (a *app) rune(r rune) (quit bool) { + c, ok := a.current() + switch r { + case 'q': + if a.view == viewList { + return true + } + a.back() + case '?', 'h': + a.helpFrom, a.view = a.view, viewHelp + case 'c': + a.fullCmd = !a.fullCmd + case 'e': + if a.view == viewText && a.textTitle == "Details" { + a.showEnv = !a.showEnv + a.loadText() + } + } + if a.view == viewText { + return false + } + switch r { + case '/': + a.searching = true + case 's': + if a.view == viewProcs { + a.psortBy = (a.psortBy + 1) % len(psortNames) + a.rebuildProcs() + } else { + a.sortBy = (a.sortBy + 1) % len(sortNames) + a.rebuild() + } + case 't': + if a.view == viewProcs { + a.ptree = !a.ptree + a.rebuildProcs() + } else { + a.tree = !a.tree + a.rebuild() + } + case 'a': + if a.view == viewList { + a.all = !a.all + a.rebuild() + } + } + if !ok { + return false + } + switch r { + case 'd': + a.openText(c, "Details", false) + case 'l': + a.openText(c, "Logs", true) + case 'x': + a.askStartStop(c) + case 'r': + a.askAction(c, "Restart", "restart", "Stop and start "+c.Name+" again?") + case 'p': + if c.State == "paused" { + a.askAction(c, "Unpause", "unpause", "Resume every process in "+c.Name+"?") + } else { + a.askAction(c, "Pause", "pause", "Freeze every process in "+c.Name+" (cgroup freezer)?") + } + case 'k': + if a.view == viewProcs { + a.askSignalProc() + } else { + a.askKill(c) + } + } + return false +} + +// current is the container actions apply to: the selected row, or the one being viewed. +func (a *app) current() (Container, bool) { + if a.view != viewList { + return a.cur, true + } + if a.sel < len(a.rows) && !a.rows[a.sel].Group { + return a.rows[a.sel], true + } + return Container{}, false +} + +func (a *app) notice(title string, lines ...string) { a.modal = &modal{title: title, lines: lines} } + +func (a *app) setStatus(s string) { + a.mu.Lock() + a.status = s + a.mu.Unlock() + a.scr.PostEvent(tcell.NewEventInterrupt(nil)) +} + +func (a *app) runJob(fn func() error) { + if a.busy { + a.notice("Busy", "Another action is still running.") + return + } + a.busy = true + go func() { + if err := fn(); err != nil { + a.setStatus("Error: " + err.Error()) + } + a.scr.PostEvent(tcell.NewEventInterrupt("done")) + }() +} + +func (a *app) askAction(c Container, title, call, question string) { + a.modal = &modal{title: title, lines: []string{question}, onYes: func() { + a.runJob(func() error { + a.setStatus(title + " " + c.Name + "...") + start := time.Now() + if err := a.dk.action(c.ID, call); err != nil { + return err + } + a.setStatus(fmt.Sprintf("%s %s: done in %s.", title, c.Name, time.Since(start).Round(100*time.Millisecond))) + return nil + }) + }} +} + +func (a *app) askStartStop(c Container) { + switch c.State { + case "running", "paused", "restarting": + a.askAction(c, "Stop", "stop", "Stop "+c.Name+"? (SIGTERM, then SIGKILL after the stop timeout, 10s by default)") + default: + a.askAction(c, "Start", "start", "Start "+c.Name+"?") + } +} + +func signalItems() []string { + items := make([]string, len(signals)) + for i, s := range signals { + items[i] = fmt.Sprintf("%2d %s", int(s.sig), s.name) + } + return items +} + +func (a *app) askKill(c Container) { + a.modal = &modal{title: "Send signal to container " + c.Name + " (its main process)", items: signalItems(), onPick: func(i int) { + a.runJob(func() error { + if err := a.dk.action(c.ID, "kill?signal="+signals[i].name); err != nil { + return err + } + a.setStatus(fmt.Sprintf("Sent %s to %s.", signals[i].name, c.Name)) + return nil + }) + }} +} + +func (a *app) askSignalProc() { + if a.remote != nil || a.psel >= len(a.prows) { + a.notice("Not available", "Processes can only be signalled when the Docker daemon runs on this machine.") + return + } + p := a.prows[a.psel] + a.modal = &modal{title: fmt.Sprintf("Send signal to %s (host PID %d)", p.Comm, p.PID), items: signalItems(), onPick: func(i int) { + if err := syscall.Kill(p.PID, signals[i].sig); err != nil { + a.notice("Signal failed", err.Error()+permHint(err)) + return + } + a.setStatus(fmt.Sprintf("Sent %s to %s (%d).", signals[i].name, p.Comm, p.PID)) + }} +} + +func permHint(err error) string { + if errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) { + return " (try: sudo dockertop, or join the docker group)" + } + return "" +} + +// ---- drawing ---- + +func (a *app) put(x, y int, s string, st tcell.Style) int { + w, _ := a.scr.Size() + for _, r := range s { + if x >= w { + break + } + a.scr.SetContent(x, y, r, nil, st) + x++ + } + return x +} + +func (a *app) fill(y int, st tcell.Style) { + w, _ := a.scr.Size() + for x := 0; x < w; x++ { + a.scr.SetContent(x, y, ' ', nil, st) + } +} + +type seg struct { + frac float64 + st tcell.Style +} + +// bar draws an htop style Label[||||| text] of the given total width. +func (a *app) bar(x, y, width int, label string, segs []seg, text string) { + x = a.put(x, y, label, stLabel) + x = a.put(x, y, "[", stDefault) + inner := width - len(label) - 2 + if inner < 4 { + return + } + pos := 0 + for _, s := range segs { + n := min(int(s.frac*float64(inner)+0.5), inner-pos) + for i := 0; i < n; i++ { + a.scr.SetContent(x+pos, y, '|', nil, s.st) + pos++ + } + } + if len(text) < inner { + a.put(x+inner-len(text), y, text, stDim) + } + a.put(x+inner, y, "]", stDefault) +} + +// bars draws two bars side by side, or stacked on a narrow terminal. Returns the next line. +func (a *app) bars(y, w int, l1 string, s1 []seg, t1, l2 string, s2 []seg, t2 string) int { + if w >= 100 { + bw := (w - 2) / 2 + a.bar(1, y, bw, l1, s1, t1) + a.bar(1+bw+1, y, bw, l2, s2, t2) + return y + 1 + } + a.bar(1, y, w-2, l1, s1, t1) + a.bar(1, y+1, w-2, l2, s2, t2) + return y + 2 +} + +func cpuStyle(p float64) tcell.Style { + switch { + case p >= 90: + return stRed + case p >= 50: + return stYellow + } + return stGreen +} + +func (a *app) drawStatus(y int) { + a.mu.Lock() + status := a.status + a.mu.Unlock() + st := stWarn + if a.err != nil { + status, st = "Error: "+a.err.Error(), stErr + } else if strings.HasPrefix(status, "Error") { + st = stErr + } + a.put(1, y, status, st) +} + +func (a *app) drawHeader(w int) int { + var cpu float64 + var mem uint64 + var rx, tx, br, bw float64 + count := map[string]int{} + for _, c := range a.snap { + cpu += c.CPU + mem += c.Mem + rx, tx, br, bw = rx+c.NetRx, tx+c.NetTx, br+c.BlkR, bw+c.BlkW + count[c.State]++ + } + ncpu := float64(max(a.info.NCPU, 1)) + y := a.bars(0, w, + "CPU", []seg{{cpu / ncpu / 100, cpuStyle(cpu / ncpu)}}, fmt.Sprintf("%.1f%% of %d cores", cpu, a.info.NCPU), + "Mem", []seg{{float64(mem) / float64(max(a.info.MemTotal, 1)), stGreen}}, human(mem)+"/"+human(a.info.MemTotal)) + x := a.put(1, y, fmt.Sprintf("Containers %d: ", len(a.snap)), stDefault) + x = a.put(x, y, fmt.Sprintf("%d running ", count["running"]), stGreen) + x = a.put(x, y, fmt.Sprintf("%d paused ", count["paused"]), stYellow) + x = a.put(x, y, fmt.Sprintf("%d stopped ", count["exited"]+count["created"]+count["dead"]), stDim) + if n := count["restarting"]; n > 0 { + x = a.put(x, y, fmt.Sprintf("%d restarting ", n), stMagenta) + } + a.put(x+1, y, fmt.Sprintf("Docker %s on %s (%s, cgroup v%s)", a.info.ServerVersion, a.info.Name, a.info.OperatingSystem, a.info.CgroupVersion), stDim) + y++ + line := fmt.Sprintf("Net rx %s/s tx %s/s Block read %s/s write %s/s sort: %s", human(uint64(rx)), human(uint64(tx)), human(uint64(br)), human(uint64(bw)), sortNames[a.sortBy]) + if a.tree { + line += " tree" + } + if !a.all { + line += " running only (a: show all)" + } + if a.filter != "" { + line += " filter: " + a.filter + } + a.put(1, y, line, stDefault) + a.drawStatus(y + 1) + return y + 3 +} + +func (a *app) draw() { + a.scr.Clear() + w, h := a.scr.Size() + switch a.view { + case viewHelp: + a.drawHelp() + case viewProcs: + a.drawProcs(w, h) + case viewText: + a.drawText(w, h) + default: + a.drawList(w, h) + } + a.drawBottom(w, h) + if a.modal != nil { + a.drawModal(w, h) + } + a.scr.Show() +} + +func statusStyle(c Container) tcell.Style { + switch { + case c.Group && c.State == "running": + return stLabel + case c.Group: + return stDim + case strings.Contains(c.Status, "unhealthy"), c.State == "dead", + c.State == "exited" && !strings.HasPrefix(c.Status, "Exited (0)"): + return stRed + case strings.Contains(c.Status, "starting"), c.State == "paused": + return stYellow + case c.State == "restarting": + return stMagenta + case c.State != "running": + return stDim + } + return stDefault +} + +// cell formats a stats value: blank without stats, "-" before the first rate. +func cell(c Container, rate bool, s string) string { + switch { + case !c.HasStats: + return "" + case rate && !c.HasRate: + return "-" + } + return s +} + +func (a *app) drawList(w, h int) { + if a.snap == nil { + a.put(1, 0, "Reading containers from "+a.dk.Host+"...", stDim) + a.drawStatus(1) + return + } + y := a.drawHeader(w) + last := "IMAGE" + if a.fullCmd { + last = "COMMAND" + } + a.fill(y, stHdr) + a.put(0, y, fmt.Sprintf(" %-30s %6s %7s %5s %7s %7s %7s %7s %5s %-28s %s", "NAME", "CPU%", "MEM", "MEM%", "NET RX", "NET TX", "READ", "WRITE", "PIDS", "STATUS", last), stHdr) + y++ + avail := h - 1 - y + if avail < 1 { + return + } + if a.sel < a.top { + a.top = a.sel + } + if a.sel >= a.top+avail { + a.top = a.sel - avail + 1 + } + a.top = max(0, min(a.top, len(a.rows)-avail)) + if len(a.rows) == 0 { + a.put(1, y, "No containers match.", stDim) + } + for i := a.top; i < len(a.rows) && i < a.top+avail; i++ { + c := a.rows[i] + sel := i == a.sel + st := stDefault + if c.Group { + st = stLabel + } + pick := func(s tcell.Style) tcell.Style { + if sel { + return stSel + } + return s + } + if sel { + a.fill(y, stSel) + } + x := a.put(0, y, " ", pick(st)) + x = a.put(x, y, c.Tree, pick(stDim)) + x = a.put(x, y, fmt.Sprintf("%-*.*s ", 30-len([]rune(c.Tree)), 30-len([]rune(c.Tree)), c.Name), pick(st)) + x = a.put(x, y, fmt.Sprintf("%6s ", cell(c, true, fmt.Sprintf("%.1f", c.CPU))), pick(cpuStyle(c.CPU))) + x = a.put(x, y, fmt.Sprintf("%7s ", cell(c, false, human(c.Mem))), pick(st)) + mp := "" + if c.MemLimit > 0 { + mp = fmt.Sprintf("%.1f", c.MemPct()) + } + x = a.put(x, y, fmt.Sprintf("%5s ", cell(c, false, mp)), pick(cpuStyle(c.MemPct()))) + x = a.put(x, y, fmt.Sprintf("%7s %7s %7s %7s %5s ", + cell(c, true, human(uint64(c.NetRx))), cell(c, true, human(uint64(c.NetTx))), + cell(c, true, human(uint64(c.BlkR))), cell(c, true, human(uint64(c.BlkW))), + cell(c, false, strconv.FormatUint(c.Pids, 10))), pick(st)) + x = a.put(x, y, fmt.Sprintf("%-28.28s ", c.Status), pick(statusStyle(c))) + lastv := c.Image + if a.fullCmd { + lastv = c.Command + } + a.put(x, y, lastv, pick(stDim)) + y++ + } +} + +func (a *app) drawProcs(w, h int) { + c := a.cur + x := a.put(1, 0, c.Name, stLabel) + x = a.put(x+2, 0, c.Image, stDim) + a.put(x+2, 0, c.Status, statusStyle(c)) + online := c.Online + if online == 0 { + online = a.info.NCPU + } + limit, limTxt := float64(max(online, 1))*100, fmt.Sprintf("%d cores", online) + if a.insp != nil && a.insp.CPULimit() > 0 { + limit, limTxt = a.insp.CPULimit(), fmt.Sprintf("limit %.2f CPUs", a.insp.CPULimit()/100) + } + memFrac := 0.0 + if c.MemLimit > 0 { + memFrac = float64(c.Mem) / float64(c.MemLimit) + } + y := a.bars(1, w, + "CPU", []seg{{c.CPU / limit, cpuStyle(c.CPU * 100 / limit)}}, fmt.Sprintf("%.1f%% of %s", c.CPU, limTxt), + "Mem", []seg{{memFrac, cpuStyle(memFrac * 100)}}, human(c.Mem)+"/"+human(c.MemLimit)) + thr, run := 0, 0 + for _, p := range a.procs { + thr += p.Threads + if p.State == 'R' { + run++ + } + } + pl := "unlimited" + if c.PidsLimit > 0 && c.PidsLimit < 1<<62 { + pl = strconv.FormatUint(c.PidsLimit, 10) + } + line := fmt.Sprintf("Tasks %d, %d thr; %d running pids %d/%s Net rx %s/s tx %s/s Block read %s/s write %s/s", + len(a.procs), thr, run, c.Pids, pl, human(uint64(c.NetRx)), human(uint64(c.NetTx)), human(uint64(c.BlkR)), human(uint64(c.BlkW))) + if a.insp != nil && !a.insp.State.StartedAt.IsZero() && c.State == "running" { + line += " Uptime " + ago(time.Since(a.insp.State.StartedAt)) + } + a.put(1, y, line, stDefault) + y++ + line = "sort: " + psortNames[a.psortBy] + if a.ptree { + line += " tree" + } + if a.pfilter != "" { + line += " filter: " + a.pfilter + } + x = a.put(1, y, line, stDim) + if a.procErr != nil { + a.put(x+3, y, "cannot list processes: "+a.procErr.Error(), stErr) + } else if a.remote != nil { + a.put(x+3, y, "Docker runs on another host: showing its ps output (CPU% is a lifetime average)", stWarn) + } + y++ + a.drawStatus(y) + y += 2 + if a.remote != nil { + a.drawRemote(y, h) + return + } + a.fill(y, stHdr) + a.put(0, y, fmt.Sprintf("%8s %-9s %3s %3s %6s %6s S %5s %5s %9s %s", "PID", "USER", "PRI", "NI", "VIRT", "RES", "CPU%", "MEM%", "TIME+", "Command"), stHdr) + y++ + avail := h - 1 - y + if avail < 1 { + return + } + if a.psel < a.ptop { + a.ptop = a.psel + } + if a.psel >= a.ptop+avail { + a.ptop = a.psel - avail + 1 + } + a.ptop = max(0, min(a.ptop, len(a.prows)-avail)) + for i := a.ptop; i < len(a.prows) && i < a.ptop+avail; i++ { + p := a.prows[i] + sel := i == a.psel + pick := func(s tcell.Style) tcell.Style { + if sel { + return stSel + } + return s + } + if sel { + a.fill(y, stSel) + } + cpu := "-" + if p.HasCPU { + cpu = fmt.Sprintf("%.1f", p.CPU) + } + sst := stDefault + switch p.State { + case 'R': + sst = stGreen + case 'D': + sst = stRed + case 'Z', 'T', 't': + sst = stYellow + } + x := a.put(0, y, fmt.Sprintf("%8d %-9.9s %3d %3d %6s %6s ", p.PID, p.User, p.Pri, p.Nice, human(p.Virt), human(p.RSS)), pick(stDefault)) + x = a.put(x, y, string(p.State)+" ", pick(sst)) + x = a.put(x, y, fmt.Sprintf("%5s ", cpu), pick(cpuStyle(p.CPU))) + x = a.put(x, y, fmt.Sprintf("%5.1f %9s ", a.memPct(p), cpuTime(p.Ticks)), pick(stDefault)) + x = a.put(x, y, p.Tree, pick(stDim)) + cmd := p.Comm + if a.fullCmd { + cmd = p.Cmd + } + a.put(x, y, cmd, pick(stDefault)) + y++ + } +} + +// drawRemote shows docker top's table as-is, columns padded to fit. +func (a *app) drawRemote(y, h int) { + t := a.remote + wid := make([]int, len(t.Titles)) + for i, s := range t.Titles { + wid[i] = len(s) + } + for _, r := range t.Processes { + for i := range min(len(r), len(wid)) { + wid[i] = max(wid[i], len(r[i])) + } + } + row := func(cols []string) string { + var b strings.Builder + for i, s := range cols { + if i < len(wid)-1 { + fmt.Fprintf(&b, "%-*s ", wid[i], s) + } else { + b.WriteString(s) + } + } + return b.String() + } + a.fill(y, stHdr) + a.put(0, y, row(t.Titles), stHdr) + y++ + avail := h - 1 - y + a.ptop = max(0, min(a.ptop, a.psel, len(t.Processes)-avail)) + if a.psel >= a.ptop+avail { + a.ptop = a.psel - avail + 1 + } + for i := a.ptop; i < len(t.Processes) && i < a.ptop+avail; i++ { + st := stDefault + if i == a.psel { + a.fill(y, stSel) + st = stSel + } + a.put(0, y, row(t.Processes[i]), st) + y++ + } +} + +func (a *app) drawText(w, h int) { + x := a.put(1, 0, a.textTitle, stLabel) + x = a.put(x+2, 0, a.cur.Name, stDefault) + if a.textTitle == "Logs" { + hint := "last 2000 lines, refreshed every tick" + if a.follow { + hint += ", following" + } + a.put(x+2, 0, hint, stDim) + } + if a.textErr != nil { + a.put(1, 1, "Error: "+a.textErr.Error(), stErr) + } else { + a.drawStatus(1) + } + avail := a.textAvail() + if a.follow { + a.ttop = len(a.text) + } + a.ttop = max(0, min(a.ttop, len(a.text)-avail)) + for i := 0; i < avail && a.ttop+i < len(a.text); i++ { + l := []rune(a.text[a.ttop+i]) + st := stDefault + if len(l) > 0 && l[0] != ' ' && a.textTitle == "Details" { + st = stLabel + } + if a.tleft < len(l) { + a.put(0, 2+i, string(l[a.tleft:]), st) + } + } +} + +func (a *app) drawHelp() { + lines := []string{ + "dockertop " + version + " - containers and the processes inside them", + "", + "Container list", + " CPU% share of one core (100 = one full core), like docker stats and htop", + " MEM/MEM% memory in use, page cache that can be dropped excluded; percent of the container's limit (or host RAM)", + " NET RX/TX received and sent per second, all networks; READ/WRITE block I/O per second", + " t tree: group containers under their compose project, with the project's totals", + "", + "Process view (Enter)", + " htop for one container: every process in its cgroup, with host PIDs.", + " CPU% is per core; MEM% is resident memory against the container's memory limit.", + " USER comes from the container's own /etc/passwd when readable (root), else the numeric UID.", + "", + "Actions (each asks for confirmation)", + " x stop a running container, start a stopped one", + " r restart p pause / unpause", + " k send a signal: to the container's main process in the list, to the selected process in the process view", + "", + "Keys", + " Up/Down PgUp/PgDn Home/End move Enter processes d details (e: environment) l logs (Left/Right scroll)", + " / search s sort t tree a show stopped containers c command line h help q quit / back", + "", + "Settings (sorts, trees, a, c, refresh) are saved to " + configPath() + " on exit.", + "Needs access to the Docker socket (docker group or root). Signalling processes and container user names need root.", + "", + "Press any key to return.", + } + for i, l := range lines { + st := stDefault + if i == 0 || (l != "" && l[0] != ' ') { + st = stLabel + } + a.put(1, i, l, st) + } +} + +func (a *app) drawBottom(w, h int) { + y := h - 1 + a.fill(y, stDefault) + if a.searching { + f := a.filter + if a.view == viewProcs { + f = a.pfilter + } + a.put(0, y, "Search: "+f+"_", stWarn) + return + } + var keys [][2]string + switch a.view { + case viewList: + keys = [][2]string{{"h", "Help"}, {"/", "Search"}, {"s", "Sort"}, {"t", "Tree"}, {"a", "All"}, {"c", "Cmd"}, {"⏎", "Procs"}, {"d", "Details"}, {"l", "Logs"}, {"x", "Stop"}, {"r", "Restart"}, {"p", "Pause"}, {"k", "Kill"}, {"q", "Quit"}} + case viewProcs: + keys = [][2]string{{"q", "Back"}, {"/", "Search"}, {"s", "Sort"}, {"t", "Tree"}, {"c", "Cmd"}, {"k", "Signal"}, {"d", "Details"}, {"l", "Logs"}, {"x", "Stop"}, {"r", "Restart"}, {"p", "Pause"}} + case viewText: + keys = [][2]string{{"q", "Back"}, {"↑↓", "Scroll"}, {"←→", "Pan"}, {"End", "Follow"}} + if a.textTitle == "Details" { + keys = append(keys, [2]string{"e", "Env"}) + } + } + if c, ok := a.current(); ok && a.view != viewText { + for i := range keys { + switch { + case keys[i][0] == "x" && c.State != "running" && c.State != "paused" && c.State != "restarting": + keys[i][1] = "Start" + case keys[i][0] == "p" && c.State == "paused": + keys[i][1] = "Unpause" + } + } + } + x := 0 + for _, k := range keys { + x = a.put(x, y, " "+k[0], stKey) + x = a.put(x, y, fmt.Sprintf("%-7s", k[1]), stFn) + } +} + +func (a *app) drawModal(w, h int) { + m := a.modal + lines := append([]string{}, m.lines...) + switch { + case m.items != nil: + lines = append(lines, m.items...) + lines = append(lines, "", "[Enter] send [Esc] cancel") + case m.onYes != nil: + lines = append(lines, "", "[y] yes [n] no") + default: + lines = append(lines, "", "[Enter] ok") + } + bw := len(m.title) + for _, l := range lines { + bw = max(bw, len(l)) + } + bw = min(bw+4, w) + bh := len(lines) + 2 + x0, y0 := (w-bw)/2, (h-bh)/2 + st := tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver) + for y := y0; y < y0+bh; y++ { + for x := x0; x < x0+bw; x++ { + a.scr.SetContent(x, y, ' ', nil, st) + } + } + a.put(x0+2, y0, m.title, st.Bold(true)) + for i, l := range lines { + ls := st + if m.items != nil && i >= len(m.lines) && i-len(m.lines) == m.pick { + ls = stSel + for x := x0 + 1; x < x0+bw-1; x++ { + a.scr.SetContent(x, y0+1+i, ' ', nil, ls) + } + } + a.put(x0+2, y0+1+i, l, ls) + } +} + +// human formats a byte count: 512B, 1.5K, 3.2M, 1.25G. +func human(b uint64) string { + switch { + case b < 1024: + return fmt.Sprintf("%dB", b) + case b < 1<<20: + return fmt.Sprintf("%.1fK", float64(b)/(1<<10)) + case b < 1<<30: + return fmt.Sprintf("%.1fM", float64(b)/(1<<20)) + case b < 1<<40: + return fmt.Sprintf("%.2fG", float64(b)/(1<<30)) + } + return fmt.Sprintf("%.2fT", float64(b)/(1<<40)) +} diff --git a/packaging/homebrew/dockertop.rb.in b/packaging/homebrew/dockertop.rb.in new file mode 100644 index 0000000..6cb9707 --- /dev/null +++ b/packaging/homebrew/dockertop.rb.in @@ -0,0 +1,24 @@ +# Homebrew formula template for dockertop. `make brew-formula` fills in the +# version and the checksum of the GitHub source tarball and writes the +# result to bin/homebrew/dockertop.rb; copy that into your tap +# (github.com/andydixon/homebrew-tap, Formula/dockertop.rb). +class Dockertop < Formula + desc "Htop-style view of Docker containers and the processes inside them" + homepage "https://github.com/andydixon/dockertop" + url "https://github.com/andydixon/dockertop/archive/refs/tags/v@VERSION@.tar.gz" + sha256 "@SHA256@" + license "GPL-3.0-or-later" + head "https://github.com/andydixon/dockertop.git", branch: "master" + + depends_on "go" => :build + depends_on :linux + + def install + system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}") + man1.install "docs/dockertop.1" + end + + test do + assert_match version.to_s, shell_output("#{bin}/dockertop --version") + end +end diff --git a/packaging/repo.sh b/packaging/repo.sh new file mode 100755 index 0000000..158932c --- /dev/null +++ b/packaging/repo.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Build Linux packages (.deb, .rpm, Arch .pkg.tar.zst) for a release tag and +# publish them to the signed apt, dnf and pacman repositories served at +# https://repo.dixon.cx. +# +# packaging/repo.sh # the newest tag +# packaging/repo.sh v1.0.0 # a given tag +# +# Builds from a clean export of the tag, so the working tree doesn't matter. +# Every index is rebuilt over all the packages in the repository, so other +# projects publishing there with their copy of this script are kept. +# +# Needs: go, docker, gpg, apt-ftparchive (apt-utils), flock. +# REPO_DIR where the repository lives (default below) +# REPO_GNUPGHOME GnuPG home holding the signing key (default below) +# ARCHS Go architectures to package (default below) +# +# The same script, with its own project block, is in mapsize, vault and swaptop. +set -euo pipefail + +# ---- project --------------------------------------------------------------- +NAME=dockertop # the command +PACKAGE=dockertop +CONFLICTS= +SUMMARY="htop-style view of Docker containers and the processes inside them" +HOMEPAGE=https://github.com/andydixon/dockertop +LICENSE=GPL-3.0-or-later +MANPAGES=docs/*.1 +DOCS="README.md CHANGELOG.md LICENSE" +# build OUT: build the binary to OUT, in the source tree, with GOOS, GOARCH +# and GOARM set and VERSION the release. +build() { + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=$VERSION" \ + -o "$1" . +} +# ---------------------------------------------------------------------------- + +REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html} +REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg} +ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"} +NFPM="go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1" +SUITE=stable +COMPONENT=main +ARCH_REPO=dixon # the pacman repository name: [dixon] + +say() { echo "repo: $*" >&2; } +die() { say "$*"; exit 1; } + +for tool in go docker gpg apt-ftparchive flock; do + command -v $tool >/dev/null || die "needs $tool" +done +[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist" +export GNUPGHOME=$REPO_GNUPGHOME +KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/{print $10; exit}') +[[ -n $KEY ]] || die "no signing key in $GNUPGHOME" + +cd "$(git rev-parse --show-toplevel)" +TAG=${1:-$(git describe --tags --abbrev=0)} +git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || die "no tag $TAG" +VERSION=${TAG#v} +export VERSION + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +git archive "$TAG" | tar -x -C "$WORK" --one-top-level=src +gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc" + +# ---- build ----------------------------------------------------------------- +say "building $PACKAGE $VERSION for $ARCHS" +mkdir -p "$WORK/out" +for arch in $ARCHS; do + goarm= nfarch=$arch + [[ $arch == arm ]] && goarm=7 nfarch=arm7 + (cd "$WORK/src" && GOOS=linux GOARCH=$arch GOARM=$goarm build "$WORK/bin-$arch/$NAME") + { + cat <<-EOF + name: $PACKAGE + arch: $nfarch + platform: linux + version: $VERSION + release: 1 + maintainer: Andy Dixon + description: $SUMMARY + homepage: $HOMEPAGE + license: $LICENSE + conflicts: [${CONFLICTS:-}] + contents: + - src: $WORK/bin-$arch/$NAME + dst: /usr/bin/$NAME + file_info: {mode: 0755} + EOF + for m in $(cd "$WORK/src" && ls $MANPAGES); do + printf ' - src: %s\n dst: /usr/share/man/man1/%s\n file_info: {mode: 0644}\n' \ + "$WORK/src/$m" "$(basename "$m")" + done + for d in $DOCS; do + printf ' - src: %s\n dst: /usr/share/doc/%s/%s\n file_info: {mode: 0644}\n' \ + "$WORK/src/$d" "$PACKAGE" "$d" + done + printf 'rpm:\n signature:\n key_file: %s\n' "$WORK/signing.asc" + } >"$WORK/nfpm-$arch.yaml" + for fmt in deb rpm archlinux; do + $NFPM pkg -f "$WORK/nfpm-$arch.yaml" -p $fmt -t "$WORK/out/" >/dev/null + done +done + +# ---- publish --------------------------------------------------------------- +# One publisher at a time: the indexes cover every project's packages. +exec 9>"$REPO_DIR/../.publish.lock" +flock 9 +say "publishing to $REPO_DIR" +gpg --batch --armor --export "$KEY" >"$REPO_DIR/dixon.asc" +gpg --batch --export "$KEY" >"$REPO_DIR/dixon.gpg" +sign() { gpg --batch --yes --local-user "$KEY" "$@"; } + +# apt: pool/main///, indexed by apt-ftparchive. +pool=$REPO_DIR/apt/pool/$COMPONENT/${PACKAGE:0:1}/$PACKAGE +mkdir -p "$pool" +cp "$WORK"/out/*.deb "$pool/" +( + cd "$REPO_DIR/apt" + # Built beside the live suite and swapped in, so clients never see half. + dists=dists/.$SUITE.new + rm -rf "$dists" + debarchs=$(find pool -name '*.deb' | sed -E 's/.*_([^_]+)\.deb$/\1/' | sort -u | tr '\n' ' ') + for a in $debarchs; do + d=$dists/$COMPONENT/binary-$a + mkdir -p "$d" + apt-ftparchive --arch "$a" packages pool >"$d/Packages" + gzip -9kn "$d/Packages" + done + apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=repo.dixon.cx \ + -o APT::FTPArchive::Release::Label=repo.dixon.cx \ + -o APT::FTPArchive::Release::Suite=$SUITE \ + -o APT::FTPArchive::Release::Codename=$SUITE \ + -o APT::FTPArchive::Release::Components=$COMPONENT \ + -o "APT::FTPArchive::Release::Architectures=${debarchs% }" \ + release "$dists" >"$WORK/Release" + mv "$WORK/Release" "$dists/Release" + sign --clearsign -o "$dists/InRelease" "$dists/Release" + sign --armor --detach-sign -o "$dists/Release.gpg" "$dists/Release" + rm -rf "dists/.$SUITE.old" + [[ -d dists/$SUITE ]] && mv "dists/$SUITE" "dists/.$SUITE.old" + mv "$dists" "dists/$SUITE" + rm -rf "dists/.$SUITE.old" +) + +# dnf: every architecture in one directory; dnf picks its own. +mkdir -p "$REPO_DIR/rpm" +cp "$WORK"/out/*.rpm "$REPO_DIR/rpm/" +docker image inspect repo-tools:fedora >/dev/null 2>&1 || + docker build -q -t repo-tools:fedora - >/dev/null <<-'EOF' + FROM fedora:42 + RUN dnf -y install createrepo_c && dnf clean all + EOF +docker run --rm --user "$(id -u):$(id -g)" -v "$REPO_DIR/rpm:/repo" repo-tools:fedora \ + createrepo_c --quiet --update --general-compress-type=gz /repo +sign --armor --detach-sign -o "$REPO_DIR/rpm/repodata/repomd.xml.asc" "$REPO_DIR/rpm/repodata/repomd.xml" +cat >"$REPO_DIR/rpm/dixon.repo" <&1 | + { grep -v "newer version\|^$" >&2 || true; } + for db in $ARCH_REPO.db $ARCH_REPO.files; do + sign --detach-sign -o "$dir/$db.tar.gz.sig" "$dir/$db.tar.gz" + ln -sfn "$db.tar.gz.sig" "$dir/$db.sig" + done + rm -f "$dir"/*.old "$dir"/*.old.sig +done + +say "published $PACKAGE $VERSION: $(cd "$WORK/out" && ls | wc -l) packages" diff --git a/proc.go b/proc.go new file mode 100644 index 0000000..974e9e0 --- /dev/null +++ b/proc.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "os" + "strconv" + "strings" + "time" +) + +// userHZ is the kernel's clock tick for /proc/PID/stat times. +// ponytail: fixed at 100 on every mainstream Linux arch; read sysconf if a port needs otherwise. +const userHZ = 100 + +var pageSize = uint64(os.Getpagesize()) + +type Proc struct { + PID, PPID, UID int + Tree string // tree-view prefix, display only + User, Comm, Cmd string + State byte + Pri, Nice int + Threads int + Virt, RSS uint64 // bytes + Ticks uint64 // utime+stime + CPU float64 + HasCPU bool +} + +// readProcs reads /proc for the given host PIDs. prev maps PID to its Ticks +// at time then, for the CPU column. +func readProcs(pids []int, prev map[int]uint64, dt float64) []Proc { + out := make([]Proc, 0, len(pids)) + for _, pid := range pids { + p, ok := readProc(pid) + if !ok { + continue // exited between docker top and now + } + if t, ok := prev[pid]; ok && dt > 0 && p.Ticks >= t { + p.CPU, p.HasCPU = float64(p.Ticks-t)/userHZ/dt*100, true + } + out = append(out, p) + } + return out +} + +func readProc(pid int) (Proc, bool) { + dir := "/proc/" + strconv.Itoa(pid) + b, err := os.ReadFile(dir + "/stat") + if err != nil { + return Proc{}, false + } + p, ok := parseStat(b) + if !ok { + return Proc{}, false + } + p.PID = pid + // stat's rss is the kernel's approximate counter (0 for small processes on + // recent kernels); status has the exact VmRSS, and the UID. + for _, line := range strings.Split(string(readFile(dir+"/status")), "\n") { + k, v, _ := strings.Cut(line, ":") + f := strings.Fields(v) + if len(f) == 0 { + continue + } + switch k { + case "Uid": + p.UID, _ = strconv.Atoi(f[0]) + case "VmRSS": + kb, _ := strconv.ParseUint(f[0], 10, 64) + p.RSS = kb * 1024 + } + } + cmd := bytes.TrimRight(readFile(dir+"/cmdline"), "\x00") + p.Cmd = string(bytes.ReplaceAll(cmd, []byte{0}, []byte{' '})) + if p.Cmd == "" { + p.Cmd = "[" + p.Comm + "]" + } + return p, true +} + +// parseStat parses /proc/PID/stat. comm is in parentheses and may itself +// contain spaces and parentheses, so fields are counted from the last ')'. +func parseStat(b []byte) (Proc, bool) { + s := string(b) + l, r := strings.IndexByte(s, '('), strings.LastIndexByte(s, ')') + if l < 0 || r < l { + return Proc{}, false + } + f := strings.Fields(s[r+1:]) + if len(f) < 22 { + return Proc{}, false + } + n := func(i int) uint64 { v, _ := strconv.ParseUint(f[i], 10, 64); return v } + i := func(i int) int { v, _ := strconv.Atoi(f[i]); return v } + return Proc{ + Comm: s[l+1 : r], State: f[0][0], PPID: i(1), + Ticks: n(11) + n(12), Pri: i(15), Nice: i(16), Threads: i(17), + Virt: n(20), RSS: n(21) * pageSize, + }, true +} + +// isLocal says whether pid is in this host's /proc and belongs to container id, +// i.e. docker runs on this machine and we can read process details ourselves. +func isLocal(pid int, id string) bool { + return bytes.Contains(readFile("/proc/"+strconv.Itoa(pid)+"/cgroup"), []byte(id)) +} + +// containerUsers maps UIDs to names from the container's own /etc/passwd, +// reached through /proc/PID/root (needs root). Empty if unreadable. +func containerUsers(pid int) map[int]string { + m := map[int]string{} + for _, line := range strings.Split(string(readFile("/proc/"+strconv.Itoa(pid)+"/root/etc/passwd")), "\n") { + f := strings.Split(line, ":") + if len(f) > 2 { + if uid, err := strconv.Atoi(f[2]); err == nil { + m[uid] = f[0] + } + } + } + return m +} + +func readFile(path string) []byte { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + return b +} + +// cpuTime formats ticks as htop's TIME+: m:ss.cc, or h:mm:ss past an hour. +func cpuTime(ticks uint64) string { + d := time.Duration(ticks) * time.Second / userHZ + if d >= time.Hour { + return strconv.Itoa(int(d.Hours())) + "h" + pad2(int(d.Minutes())%60) + ":" + pad2(int(d.Seconds())%60) + } + return strconv.Itoa(int(d.Minutes())) + ":" + pad2(int(d.Seconds())%60) + "." + pad2(int(ticks%userHZ)) +} + +func pad2(n int) string { + if n < 10 { + return "0" + strconv.Itoa(n) + } + return strconv.Itoa(n) +}