diff --git a/README.md b/README.md index 735debd..52d7ad6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | +| [本机大盘 · Local Market Board](plugins/jzlikewei/kurumi-monitor/) | Watch your own machine as a stock terminal: CPU, GPU, temperature, memory, disk, network and every running process become listed stocks with live candlesticks, moving averages, a watchlist, a leaderboard and a trade tape, on macOS and Windows. Requires minimax Code 3.1.2 or later | [jzlikewei](https://github.com/jzlikewei) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ecb50a..b747bd7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | +| [本机大盘](plugins/jzlikewei/kurumi-monitor/README.zh-CN.md) | 把本机当成行情终端来看:CPU、GPU、温度、内存、磁盘、网络与每个运行中的进程都是一只个股,实时 K 线、均线、自选行情、主力动向与逐笔成交,支持 macOS 与 Windows。需要 minimax Code 3.1.2 或更高版本 | [jzlikewei](https://github.com/jzlikewei) |
预览:Cutline diff --git a/plugins/jzlikewei/kurumi-monitor/.minimax-plugin/plugin.json b/plugins/jzlikewei/kurumi-monitor/.minimax-plugin/plugin.json new file mode 100644 index 0000000..10ab840 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "kurumi-monitor", + "displayName": "本机大盘", + "version": "1.3.0", + "description": "把 macOS 与 Windows 的 CPU、GPU、温度、内存、磁盘、网络与进程搬进证券行情终端的界面语言:实时 K 线、主力动向、逐笔成交、自选行情与进程盯盘,历史采样本地累计。", + "author": "jzlikewei", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开本机大盘", + "看看现在 GPU 占用怎么样", + "哪个进程在吃 CPU" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/jzlikewei/kurumi-monitor/LICENSE b/plugins/jzlikewei/kurumi-monitor/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/LICENSE @@ -0,0 +1,674 @@ + 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/plugins/jzlikewei/kurumi-monitor/README.md b/plugins/jzlikewei/kurumi-monitor/README.md new file mode 100644 index 0000000..0a4bfc5 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/README.md @@ -0,0 +1,149 @@ +# 本机大盘 · Local Market Board + +A system monitor that renders your machine in the visual language of a Chinese A-share +trading terminal. CPU, GPU, temperature, memory, disk, network and every running process +become listed stocks, each with live candlesticks, moving averages, an index strip, a +watchlist, a leaderboard and a trade tape. + +[简体中文](README.zh-CN.md) + +## Screenshots + +![The full board: title bar, index strip, three-column body, process table and status bar](docs/screenshots/1-board.jpg) + +![Main chart: quote header, candlesticks, MA5/10/20 and a stacked memory-composition subchart](docs/screenshots/2-chart.jpg) + +![Index strip: the app icon followed by eight quotes, each with a mini candlestick](docs/screenshots/3-indexbar.jpg) + +![Process table sorted by resident memory, one mini candlestick per row](docs/screenshots/4-procs.jpg) + +Application names in the leaderboard and the process table are mosaicked. Everything else is +live output from the machine described under **Tested environment**. + +## Platform support + +| Platform | Collection | Fixed metrics | Status | +| --- | --- | --- | --- | +| macOS | `vm_stat` / `ioreg` / `netstat` / `ps` | 34 + per-core CPU | Verified on real hardware | +| Windows | Long-lived PowerShell + performance counters | 34 + per-core CPU | See the note below | + +**Windows verification scope.** The process-collection path was executed on macOS under +PowerShell 7.6.9 through the cross-platform .NET implementation. Memory, GPU, disk, +network and thermal readings depend on Windows-only `PerformanceCounter`, +`Get-CimInstance` and `nvidia-smi`; they were tested end to end against samples in the +real output format and have yet to run on a physical Windows machine. + +GPU temperature, GPU power draw and the ACPI thermal-zone temperature are Windows-only. +On macOS the three codes in the 温度 board stay empty. + +## Install + +Requires the MiniMax Code desktop app, 3.1.2 or later, because the package declares +`surface.window`. + +1. Copy this whole directory to `~/.minimax/plugins/kurumi-monitor/`, including the + hidden `.minimax-plugin/` directory. +2. Restart MiniMax Code and confirm the plugin is recognised and enabled. +3. Say `打开本机大盘` in a conversation, or start it from the plugin list. + +The window opens at 1480x940 and asks for at least 1280x720. Below that width the page +shows a hint to pop the panel out into its own window. + +## Using it + +- **Click a code in 大盘行情** to switch the main chart. Candles aggregate at + 2s / 5s / 10s / 30s / 1min. +- **Click a row in the process table** to chart that process; its CPU share is the price. +- **Click ☆** to add a process to 我的自选. Its history keeps accumulating and survives + a restart. +- **The search box** filters by process name or PID. +- **一键清仓** terminates the selected process after a confirmation dialog. + +Red means up and green means down, following the A-share convention; the title bar +switches to international colours. Every change carries an explicit `+` or `−` sign, so +direction never depends on colour alone. + +## Data & access + +This Mini App runs locally under your own account. It declares no `agentAccess`, +`toolAccess` or `deviceAccess`, and requests no browser permission. + +**Processes it spawns** + +| Platform | Command | Purpose | +| --- | --- | --- | +| macOS | `vm_stat`, `ioreg`, `netstat`, `ps`, `sysctl` | memory pages, GPU counters, sockets, process table, core count | +| Windows | one long-lived `powershell.exe` running the bundled `collector.ps1` | performance counters, CIM classes, process table | +| Windows | `nvidia-smi` when present | GPU utilisation, VRAM, temperature, power draw | + +Every child is spawned with `execFile` and an argument array. On Windows the collector +is started once and reused; its output is newline-delimited JSON. + +**Files it reads** + +- The process table and system counters listed above, through the commands above. +- `settings.json` in its own state directory, to restore the selected code, watchlist, + period, theme and colour scheme. + +**Files it writes** + +- Samples and events under the system temporary directory: + `$TMPDIR/kurumi-monitor/` on macOS, `%TEMP%\kurumi-monitor\` on Windows. Ticks are + sharded into `ticks/YYYYMMDD-HH.jsonl`; events append to `events.jsonl`. The operating + system reclaims these on its own schedule, and everything in them is derived data that + the app rebuilds by sampling again. +- `settings.json` in its own state directory, holding the selected code, watchlist, + candle period, theme and colour scheme. + +Measured on the machine in **Tested environment**: about 88 KB per minute of samples, so +roughly 253 MB across 48 hours of continuous running. + +**Processes it can terminate.** 一键清仓 sends `SIGTERM` to the selected process, or +`SIGKILL` when 强制清仓 is ticked. The button is enabled only for processes the current +user can signal, checked with `process.kill(pid, 0)`. Protected from termination are this +runtime, every ancestor of it and all of their descendants — which covers the rest of the +host app, other plugins' runtimes and anything the Agent started — plus a small per-platform +list of session-critical processes (`loginwindow` and `WindowServer` on macOS, `explorer` +and `winlogon` on Windows). The confirmation dialog carries the target's start time, so the +kill is refused when the original process has already exited and its PID has been reused. + +**Network.** No outbound request of any kind. No remote fonts, no CDN, no telemetry. All +collection and rendering happen on the machine. + +**Agent turns, conversations, tools, microphone.** None. The app never calls +`window.mcode.agent`, never reads a conversation, never invokes a tool and never opens +the microphone. + +## Tested environment + +| Item | Version | +| --- | --- | +| MiniMax Code | 3.1.10-preview.5019 | +| macOS | 26.5.2 (Darwin 25.5.0, arm64) | +| Node.js | 22.22.2 | +| PowerShell | 7.6.9, used to exercise the Windows branch | + +Verified on macOS: all 44 codes render with live values, the six macOS probes complete a +round in 44–58 ms, a watchlist of 20 entries survives a restart of the runtime, and the +console reports no error. The Windows branch was exercised with a stub `powershell.exe` +feeding recorded frames, covering 29 assertions across memory, GPU, disk, network, +process and thermal parsing. + +## Credits + +The interface language and interaction spec were inspired by +[PerfTicker](https://github.com/QiuSui1145/PerfTicker) by **QiuSui** (QiuSui_Studio), a +single-file C# program that turns the Windows Task Manager into a stock terminal, released +under GPL-3.0. + +This project is an independent implementation in JavaScript and Node, running inside the +MiniMax Code Mini App host. The two projects share no source code. GPL-3.0 is kept as a +tribute to the original author. + +## Source + +https://github.com/jzlikewei/Kurumi-monitor + +## License + +[GPL-3.0](LICENSE) © 2026 李白 diff --git a/plugins/jzlikewei/kurumi-monitor/README.zh-CN.md b/plugins/jzlikewei/kurumi-monitor/README.zh-CN.md new file mode 100644 index 0000000..015e5c3 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/README.zh-CN.md @@ -0,0 +1,128 @@ +# 本机大盘 + +把系统指标搬进 A 股行情终端的界面语言。CPU、GPU、温度、内存、磁盘、网络与每一个 +运行中的进程都成为一只「个股」,实时 K 线、均线、指数条、自选行情、主力动向与 +逐笔成交一应俱全。 + +[English](README.md) + +## 截图 + +![全景:顶栏、指数条、三栏主体、进程表与状态栏](docs/screenshots/1-board.jpg) + +![主图特写:品种头、K 线、MA5/10/20 与内存构成三色堆叠副图](docs/screenshots/2-chart.jpg) + +![指数条特写:应用图标领在最左,后接八格行情](docs/screenshots/3-indexbar.jpg) + +![进程表特写:按常驻内存降序,每行一条走势迷你 K 线](docs/screenshots/4-procs.jpg) + +主力动向与进程表里的应用名做了马赛克处理,其余内容是下方「测试环境」那台机器的实时输出。 + +## 平台支持 + +| 平台 | 采集方式 | 固定指标 | 验证状态 | +| --- | --- | --- | --- | +| macOS | `vm_stat` / `ioreg` / `netstat` / `ps` | 34 项 + 逐核 CPU | 真机实测 | +| Windows | 常驻 PowerShell + 性能计数器 | 34 项 + 逐核 CPU | 见下方说明 | + +**Windows 验证范围**:进程采集链路在 macOS 上用 PowerShell 7.6.9 实跑验证过,走的是 +.NET 跨平台实现。内存、GPU、磁盘、网络与温度几项依赖 Windows 专有的 +`PerformanceCounter`、`Get-CimInstance` 与 `nvidia-smi`,用真实输出格式的样本做了 +端到端测试,尚未在 Windows 真机上跑过。 + +GPU 温度、GPU 功耗与 ACPI 热区温度只在 Windows 上有值。macOS 上温度板块的三项留空。 + +## 安装 + +需要 MiniMax Code 桌面端 3.1.2 或更高版本,本包声明了 `surface.window`。 + +1. 把本目录整个复制到 `~/.minimax/plugins/kurumi-monitor/`,包含隐藏的 + `.minimax-plugin/` 目录。 +2. 重启 MiniMax Code,确认插件已识别并启用。 +3. 在对话里说「打开本机大盘」,或从插件列表里启动。 + +窗口默认 1480x940,最小 1280x720。视口窄于 1280 时页面会提示把面板弹出为独立窗口。 + +## 用法 + +- **点大盘行情里的品种**切换主图,K 线按 2 秒 / 5 秒 / 10 秒 / 30 秒 / 1 分聚合。 +- **点进程表里的行**把主图切到那个进程,它的 CPU 占用就是这只「个股」的行情。 +- **点 ☆ 加自选**,进程走势会被持续记录,重启后历史仍然在。 +- **搜索框**按进程名或 PID 过滤。 +- **一键清仓**终止选中进程,操作前有二次确认。 + +红涨绿跌遵循 A 股习惯,标题栏可切换为国际配色。涨跌始终带 `+` / `−` 符号, +方向不依赖颜色传达。 + +## 数据访问范围 + +这个 Mini App 以你的身份在本机运行,没有声明 `agentAccess`、`toolAccess` 与 +`deviceAccess`,也不申请任何浏览器权限。 + +**会启动的进程** + +| 平台 | 命令 | 用途 | +| --- | --- | --- | +| macOS | `vm_stat`、`ioreg`、`netstat`、`ps`、`sysctl` | 内存分页、GPU 计数器、套接字、进程表、核心数 | +| Windows | 一个常驻 `powershell.exe`,运行包内 `collector.ps1` | 性能计数器、CIM 类、进程表 | +| Windows | 存在时调用 `nvidia-smi` | GPU 占用、显存、温度、功耗 | + +所有子进程都用 `execFile` 加参数数组启动。Windows 上采集器只启动一次并常驻, +输出为按行分隔的 JSON。 + +**会读取的文件** + +- 上述命令返回的进程表与系统计数器。 +- 自身状态目录里的 `settings.json`,用于恢复选中品种、自选、周期、主题与配色。 + +**会写入的文件** + +- 系统临时目录下的采样与事件:macOS 为 `$TMPDIR/kurumi-monitor/`,Windows 为 + `%TEMP%\kurumi-monitor\`。采样按 `ticks/YYYYMMDD-HH.jsonl` 小时分片,事件追加到 + `events.jsonl`。这些由操作系统按自己的策略回收,内容全部是重新采样即可重建的 + 派生数据。 +- 自身状态目录里的 `settings.json`,保存选中品种、自选列表、K 线周期、主题与配色。 + +在下方「测试环境」那台机器上实测:采样落盘约 88 KB/分钟,连续运行 48 小时约 253 MB。 + +**可以终止的进程**:一键清仓向选中进程发送 `SIGTERM`,勾选强制清仓则发送 +`SIGKILL`。按钮只对当前用户有权发信号的进程可用,判定方式是 +`process.kill(pid, 0)`。保护名单包含本进程、它的全部祖先及这些祖先的全部后代 +(覆盖宿主程序的其余进程、其他插件的运行时与 Agent 起的进程),另加一份按平台的 +会话关键进程名单(macOS 的 `loginwindow`、`WindowServer`,Windows 的 `explorer`、 +`winlogon`)。确认框会带上目标进程的启动时刻,原进程已退出、PID 被复用时清仓被拒绝。 + +**网络**:没有任何对外请求,无远程字体、无 CDN、无遥测。采集与渲染全部在本机完成。 + +**Agent 任务、会话、工具、麦克风**:全部不使用。不调用 `window.mcode.agent`, +不读取会话,不调用工具,不打开麦克风。 + +## 测试环境 + +| 项目 | 版本 | +| --- | --- | +| MiniMax Code | 3.1.10-preview.5019 | +| macOS | 26.5.2(Darwin 25.5.0,arm64) | +| Node.js | 22.22.2 | +| PowerShell | 7.6.9,用于验证 Windows 分支 | + +macOS 上已验证:44 项指标全部有值并实时刷新,六个 macOS 采集源单轮 44–58 毫秒, +20 项自选跨运行时重启完整恢复,控制台无报错。Windows 分支用桩 `powershell.exe` +喂入录制帧验证,覆盖内存、GPU、磁盘、网络、进程与温度解析共 29 项断言。 + +## 致谢 + +界面语言与交互规格的灵感来自 [PerfTicker](https://github.com/QiuSui1145/PerfTicker), +作者 **QiuSui**(QiuSui_Studio),一个把 Windows 任务管理器做成炒股软件行情终端的 +C# 单文件程序,采用 GPL-3.0 协议。 + +本项目是独立实现:技术栈为 JavaScript / Node,运行在 MiniMax Code 的 Mini App 宿主里, +两边没有共享任何源码。沿用 GPL-3.0 是出于对原作者的尊重。 + +## 源码 + +https://github.com/jzlikewei/Kurumi-monitor + +## 许可证 + +[GPL-3.0](LICENSE) © 2026 李白 diff --git a/plugins/jzlikewei/kurumi-monitor/docs/screenshots/1-board.jpg b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/1-board.jpg new file mode 100644 index 0000000..63de50d Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/1-board.jpg differ diff --git a/plugins/jzlikewei/kurumi-monitor/docs/screenshots/2-chart.jpg b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/2-chart.jpg new file mode 100644 index 0000000..99fdd78 Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/2-chart.jpg differ diff --git a/plugins/jzlikewei/kurumi-monitor/docs/screenshots/3-indexbar.jpg b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/3-indexbar.jpg new file mode 100644 index 0000000..512e6af Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/3-indexbar.jpg differ diff --git a/plugins/jzlikewei/kurumi-monitor/docs/screenshots/4-procs.jpg b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/4-procs.jpg new file mode 100644 index 0000000..390079f Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/docs/screenshots/4-procs.jpg differ diff --git a/plugins/jzlikewei/kurumi-monitor/icon.png b/plugins/jzlikewei/kurumi-monitor/icon.png new file mode 100644 index 0000000..3ebd5ab Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/icon.png differ diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.css b/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.css new file mode 100644 index 0000000..b3b3e48 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.css @@ -0,0 +1,490 @@ +/* 本机大盘 · 主题与布局 + 主题三态:auto 跟随系统、dark 夜间、light 浅色。涨跌同时用符号与颜色表达。 */ + +:root { + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + + --bg: #0d1117; + --panel: #141a22; + --sunk: #0f141b; + --seam: #070a0e; + --line: #212a35; + --line-soft: #1a212a; + --fg: #e6edf5; + --dim: #99a6b5; + --mute: #6b7889; + --hover: #1a212b; + --sel: #1e2836; + --accent: #ffb020; + --focus: #6cb6ff; + --danger: #f85149; + --danger-bg: rgba(248, 81, 73, .14); + --shadow: 0 8px 28px -8px rgba(0, 0, 0, .5); + + /* 副图内存构成:活动页 / 联动页 / 压缩页。压缩页用暖色,它是内存吃紧的信号 */ + --mem-a: #4a9eff; + --mem-w: #a78bfa; + --mem-c: #ff7a45; +} + +[data-theme="light"] { + --bg: #f7f7f5; + --panel: #ffffff; + --sunk: #f1f2ef; + --seam: #dededa; + --line: #e4e4df; + --line-soft: #eeeee9; + --fg: #16191d; + --dim: #4e555e; + --mute: #767d87; + --hover: #f4f5f2; + --sel: #eaeef4; + --accent: #a86a12; + --focus: #1f6feb; + --danger: #c8302b; + --danger-bg: rgba(200, 48, 43, .1); + --shadow: 0 8px 28px -10px rgba(0, 0, 0, .22); + + --mem-a: #1f6feb; + --mem-w: #7c3aed; + --mem-c: #d2541f; +} + +@media (prefers-color-scheme: light) { + [data-theme="auto"] { + --bg: #f7f7f5; --panel: #ffffff; --sunk: #f1f2ef; --seam: #dededa; + --line: #e4e4df; --line-soft: #eeeee9; --fg: #16191d; --dim: #4e555e; + --mute: #767d87; --hover: #f4f5f2; --sel: #eaeef4; --accent: #a86a12; + --focus: #1f6feb; --danger: #c8302b; --danger-bg: rgba(200, 48, 43, .1); + --shadow: 0 8px 28px -10px rgba(0, 0, 0, .22); + --mem-a: #1f6feb; --mem-w: #7c3aed; --mem-c: #d2541f; + } +} + +/* 红涨绿跌(A 股)/ 绿涨红跌(国际) */ +[data-scheme="redUp"] { --up: #ff4d4f; --down: #00b96b; } +[data-scheme="greenUp"] { --up: #00b96b; --down: #ff4d4f; } +[data-theme="light"][data-scheme="redUp"] { --up: #d92f2f; --down: #0a8f52; } +[data-theme="light"][data-scheme="greenUp"] { --up: #0a8f52; --down: #d92f2f; } +@media (prefers-color-scheme: light) { + [data-theme="auto"][data-scheme="redUp"] { --up: #d92f2f; --down: #0a8f52; } + [data-theme="auto"][data-scheme="greenUp"] { --up: #0a8f52; --down: #d92f2f; } +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 13px; + line-height: 1.45; + overflow: auto; + -webkit-font-smoothing: antialiased; +} + +.sr, .skip:not(:focus) { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} +.skip:focus { + position: fixed; top: 8px; left: 8px; z-index: 50; + padding: 8px 14px; background: var(--panel); color: var(--fg); + border: 1px solid var(--focus); border-radius: 8px; +} + +:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } + +/* ---------------------------------------------------------- 外壳 */ + +.terminal { + min-width: 1280px; + height: 100dvh; + display: grid; + /* 主图区与进程区按 2:1 分剩余高度,各自内部滚动,整页不出纵向滚动条 */ + grid-template-rows: 46px auto minmax(230px, 2fr) minmax(120px, 1fr) 28px; + gap: 1px; + background: var(--seam); + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------- 标题栏 */ + +.titlebar { + display: flex; align-items: center; gap: 14px; + padding: 0 14px; background: var(--panel); + min-width: 0; overflow: hidden; +} +.brand { + display: flex; align-items: center; gap: 9px; + font-family: var(--sans); font-size: 14.5px; font-weight: 700; + letter-spacing: .01em; flex: none; white-space: nowrap; +} +.brand small { font-weight: 500; font-size: 11px; color: var(--dim); } +.pulse { + width: 9px; height: 9px; border-radius: 50%; background: var(--up); flex: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--up) 22%, transparent); +} +.live, .samplerate, .clock { + display: flex; align-items: center; gap: 6px; + font-size: 11.5px; color: var(--dim); white-space: nowrap; flex: none; +} +.live .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--mute); } +.live[data-state="on"] .dot { background: var(--up); animation: pulse 1.9s infinite; } +.live[data-state="off"] .dot { background: var(--danger); } +@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .3 } } +.samplerate label { color: var(--mute); } +select, .search { + font-family: var(--mono); font-size: 11px; + background: var(--sunk); color: var(--fg); + border: 1px solid var(--line); border-radius: 6px; + padding: 4px 7px; +} +.spacer { flex: 1 1 auto; min-width: 8px; } +.controls { display: flex; gap: 5px; flex: none; } + +.btn { + font-family: var(--mono); font-size: 11px; line-height: 1.3; + padding: 5px 10px; border-radius: 6px; + border: 1px solid var(--line); background: var(--sunk); color: var(--dim); + cursor: pointer; white-space: nowrap; + transition: background .14s, color .14s, border-color .14s; +} +.btn:hover:not(:disabled) { background: var(--hover); color: var(--fg); } +.btn[aria-pressed="false"] { color: var(--mute); } +.btn:disabled { opacity: .42; cursor: not-allowed; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #10151c; font-weight: 600; } +.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); background: var(--danger-bg); } +.btn.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 24%, transparent); color: var(--danger); } +.clock { font-size: 12.5px; color: var(--fg); font-weight: 500; } + +/* ---------------------------------------------------------- 指数条 */ + +.indexbar { background: var(--panel); display: flex; align-items: center; } +/* logo 领在指数条最左边,和 8 格行情并排 */ +.brand-logo { + width: 76px; height: 76px; flex: none; margin: 0 8px 0 10px; + border-radius: 12px; display: block; object-fit: cover; background: var(--sunk); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 70%, transparent); +} +.index-list { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(8, 1fr); list-style: none; gap: 1px; background: var(--seam); } +.ix { + background: var(--panel); padding: 8px 11px 9px; + /* button 即使 display:grid 也按内容收缩,必须显式撑满轨道,否则露出底色 */ + width: 100%; + display: grid; gap: 2px; cursor: pointer; min-width: 0; overflow: hidden; + border: 0; text-align: left; font-family: var(--mono); color: inherit; +} +.ix:hover { background: var(--hover); } +.ix[aria-current="true"] { background: var(--sel); box-shadow: inset 0 -2px 0 var(--accent); } +.ix .n { font-size: 10.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ix .p { font-size: 16px; font-weight: 700; letter-spacing: -.01em; white-space: nowrap; } +.ix .d { font-size: 10.5px; white-space: nowrap; } +.ix canvas { width: 100%; max-width: 100%; min-width: 0; height: 20px; display: block; margin-top: 2px; } + +/* ---------------------------------------------------------- 三栏主体 */ + +.board { + display: grid; + grid-template-columns: 238px minmax(0, 1fr) 252px; + gap: 1px; background: var(--seam); min-height: 0; +} +.pane-head { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 7px 11px; border-bottom: 1px solid var(--line); flex: none; + background: var(--panel); +} +.pane-head h2 { font-family: var(--sans); font-size: 12px; font-weight: 600; } +.pane-head .sub { font-size: 10px; color: var(--mute); white-space: nowrap; } +.pane-tools { display: flex; align-items: center; gap: 8px; } +.search { width: 150px; } + +/* 左栏:上「我的自选」下「大盘行情」。自选最多 20 项,大盘固定 41 项, + 按 2:3 分高度,各自内部滚动,谁也不把谁挤没。 */ +.leftcol { + display: grid; grid-template-rows: minmax(72px, 2fr) minmax(120px, 3fr); + gap: 1px; background: var(--seam); min-height: 0; +} +.watchlist, .market { background: var(--panel); display: flex; flex-direction: column; min-height: 0; } +.wl-scroll { overflow-y: auto; flex: 1; min-height: 0; } +.wl-empty { + padding: 14px 12px; font-size: 11px; line-height: 1.6; color: var(--mute); + font-family: var(--sans); +} +.wl-empty b { color: var(--dim); font-weight: 600; } +.grp { + font-size: 10px; color: var(--mute); padding: 6px 11px 4px; + letter-spacing: .06em; background: var(--sunk); + border-bottom: 1px solid var(--line); font-weight: 500; + position: sticky; top: 0; z-index: 1; +} +.wrow { + display: grid; grid-template-columns: 46px minmax(0, 1fr) 48px 50px; + gap: 5px; align-items: center; + padding: 4.5px 11px; font-size: 11px; + border: 0; border-bottom: 1px solid var(--line-soft); + background: none; color: inherit; font-family: var(--mono); + width: 100%; text-align: left; cursor: pointer; +} +.wrow:hover { background: var(--hover); } +.wrow[aria-current="true"] { background: var(--sel); box-shadow: inset 2px 0 0 var(--accent); } +.wrow .cd { color: var(--mute); font-size: 10.5px; } +.wrow .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wrow .px { text-align: right; font-weight: 500; } +.wrow .ch { text-align: right; font-size: 10.5px; } +/* 自选行是容器:里面「切主图」与「移除」是两个平级按钮,按钮不嵌套 */ +.wrow--proc { grid-template-columns: minmax(0, 1fr) 18px; gap: 4px; padding: 0 9px 0 0; align-items: center; } +.wrow--proc .wmain { + display: grid; grid-template-columns: 50px minmax(0, 1fr) 44px 46px; + gap: 5px; align-items: center; min-width: 0; + padding: 4.5px 0 4.5px 11px; + border: 0; background: none; color: inherit; + font-family: var(--mono); font-size: 11px; text-align: left; cursor: pointer; +} +.wrow--proc .wmain:hover { background: none; } +.wrow--proc .wmain[aria-current="true"] { background: var(--sel); box-shadow: inset 2px 0 0 var(--accent); } +.wrow--proc .cd { font-size: 10px; } +.wrow--proc[data-exited="true"] .nm { color: var(--mute); text-decoration: line-through; } + +/* 星标按钮:已收藏用实心,未收藏用空心。状态同时写进 aria-pressed,不只靠颜色 */ +.star { + width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px; + background: none; color: var(--mute); cursor: pointer; + font-size: 12px; line-height: 18px; text-align: center; font-family: var(--mono); + transition: color .14s, background .14s; +} +.star:hover { background: var(--hover); color: var(--accent); } +.star[aria-pressed="true"] { color: var(--accent); } +.ptable td.c-star, .ptable th.c-star { width: 30px; padding: 4.5px 0 4.5px 8px; text-align: left; } + +/* 中栏 */ +.center { background: var(--panel); display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.quote { + padding: 10px 14px 11px; border-bottom: 1px solid var(--line); + display: grid; grid-template-columns: auto 1fr auto; gap: 16px; align-items: center; flex: none; +} +.qid { display: grid; gap: 2px; } +.qid { grid-template-columns: auto auto; justify-content: start; align-items: center; column-gap: 8px; } +.qid .code { font-size: 11px; color: var(--mute); grid-column: 1 / -1; } +.qid .qname { font-family: var(--sans); font-size: 15px; font-weight: 600; white-space: nowrap; } +.qtag { + font-size: 10px; padding: 2px 6px; border-radius: 4px; white-space: nowrap; + background: var(--sunk); color: var(--mute); font-family: var(--mono); +} +.qtag[data-kind="exited"] { background: var(--danger-bg); color: var(--danger); } +.qwatch { padding: 3px 9px; } +.qwatch[aria-pressed="true"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +.qlast { display: flex; align-items: baseline; gap: 11px; } +.qval { font-size: 30px; font-weight: 700; letter-spacing: -.02em; line-height: 1; } +.qdelta { font-size: 13.5px; font-weight: 500; } +.qpct { font-size: 12px; padding: 2.5px 7px; border-radius: 5px; font-weight: 500; background: var(--sunk); } +.qohlc { display: flex; gap: 16px; font-size: 10.5px; color: var(--mute); } +.qohlc div { display: flex; gap: 5px; } +.qohlc dd { color: var(--fg); font-weight: 500; } + +.periods { + display: flex; align-items: center; gap: 4px; + padding: 6px 14px; border-bottom: 1px solid var(--line); flex: none; +} +.ptabs { display: flex; gap: 4px; } +.pd { + font-family: var(--mono); font-size: 10.5px; + padding: 4px 9px; border-radius: 6px; border: 1px solid transparent; + background: none; color: var(--dim); cursor: pointer; +} +.pd:hover { background: var(--sunk); } +.pd[aria-selected="true"] { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); border-color: color-mix(in srgb, var(--accent) 42%, transparent); +} +.legend { display: flex; gap: 11px; margin-left: auto; font-size: 10px; color: var(--mute); } +.legend i { display: inline-block; width: 9px; height: 2px; vertical-align: middle; margin-right: 4px; border-radius: 1px; } +.sw-ma5 { background: #ffb020; } .sw-ma10 { background: #4d9fff; } .sw-ma20 { background: #b07cff; } + +.chartwrap { flex: 1; position: relative; min-height: 120px; } +.chartwrap canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; } +.chart-empty { + position: absolute; inset: 0; display: grid; place-items: center; + font-size: 12px; color: var(--mute); +} +/* 作者样式的 display 会盖掉 UA 的 [hidden]{display:none},必须显式关掉 */ +.chart-empty[hidden] { display: none; } + +/* 右栏 */ +.side { display: grid; grid-template-rows: minmax(0, 250px) minmax(80px, 1fr); gap: 1px; background: var(--seam); min-height: 0; } +.depth, .tape { background: var(--panel); display: flex; flex-direction: column; min-height: 0; } +.depth-list { list-style: none; padding: 8px 11px; display: grid; gap: 2px; align-content: start; flex: 1; min-height: 0; overflow-y: auto; } +.dr { display: grid; grid-template-columns: 20px minmax(0, 1fr) 46px; gap: 8px; align-items: center; font-size: 11px; } +.dr .rk { + font-size: 9.5px; font-weight: 700; text-align: center; + border-radius: 4px; padding: 1.5px 0; background: var(--sunk); color: var(--dim); +} +.dr:nth-child(1) .rk { background: color-mix(in srgb, var(--up) 82%, transparent); color: #fff; } +.dr:nth-child(2) .rk { background: color-mix(in srgb, var(--up) 55%, transparent); color: #fff; } +.dr:nth-child(3) .rk { background: color-mix(in srgb, var(--up) 32%, transparent); color: var(--fg); } +.dr .nm { position: relative; height: 17px; display: flex; align-items: center; padding-left: 6px; min-width: 0; } +.dr .bar { + position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; + background: color-mix(in srgb, var(--up) 15%, transparent); +} +.dr .nm span { position: relative; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; } +.dr .vl { text-align: right; font-weight: 500; font-size: 11px; } + +.tape-list { list-style: none; overflow-y: auto; flex: 1; min-height: 0; } +.tp { + display: grid; grid-template-columns: 54px minmax(0, 1fr) auto; + gap: 8px; padding: 4.5px 11px; font-size: 10.5px; + border-bottom: 1px solid var(--line-soft); align-items: baseline; +} +.tp .tm { color: var(--mute); font-size: 10px; } +.tp .ev { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tp .vl { font-weight: 500; } + +/* ---------------------------------------------------------- 进程表 */ + +.procs { background: var(--panel); display: flex; flex-direction: column; } +.selinfo { font-size: 10.5px; color: var(--mute); max-width: 230px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.proc-scroll { flex: 1; min-height: 0; overflow-y: auto; } +.ptable { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 11px; } +.ptable th { + text-align: right; font-weight: 500; font-size: 10px; color: var(--mute); + padding: 0; border-bottom: 1px solid var(--line); background: var(--sunk); + white-space: nowrap; position: sticky; top: 0; z-index: 1; +} +.ptable th button { + width: 100%; padding: 6px 11px; background: none; border: 0; + font: inherit; color: inherit; cursor: pointer; text-align: inherit; +} +.ptable th button:hover { color: var(--fg); } +.ptable th[aria-sort] button { color: var(--accent); } +.ptable th.c-pid, .ptable th.c-name, .ptable th.c-user { text-align: left; } +.ptable th.c-pid, .ptable td.c-pid { width: 66px; } +.ptable th.c-user, .ptable td.c-user { width: 92px; } +.ptable th.c-num, .ptable td.c-num { width: 74px; } +.ptable th.c-spark, .ptable td.c-spark { width: 96px; } +.ptable td { + padding: 4.5px 11px; text-align: right; + border-bottom: 1px solid var(--line-soft); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ptable td.c-pid, .ptable td.c-name, .ptable td.c-user { text-align: left; } +.ptable tbody tr { cursor: pointer; } +.ptable tbody tr:hover td { background: var(--hover); } +.ptable tbody tr[aria-selected="true"] td { background: var(--sel); } +.ptable tbody tr[aria-selected="true"] td:first-child { box-shadow: inset 3px 0 0 var(--accent); } +.ptable tbody tr[data-killable="false"] td.c-name { color: var(--dim); } +/* 主图当前展示的进程,名字染成强调色,与「已选中待清仓」的底色区分开 */ +.ptable tbody tr[data-chart="true"] td.c-name { color: var(--accent); } +.ptable .spark { width: 100%; max-width: 100%; height: 16px; display: block; margin-left: auto; } +.proc-empty { padding: 18px; text-align: center; color: var(--mute); font-size: 12px; } + +/* ---------------------------------------------------------- 状态栏 */ + +.statusbar { + display: flex; align-items: center; background: var(--sunk); + overflow: hidden; font-family: var(--mono); +} +.st { + display: flex; gap: 5px; padding: 0 11px; font-size: 10px; + color: var(--mute); white-space: nowrap; border-right: 1px solid var(--line); +} +.st b { font-weight: 500; color: var(--dim); } +.marquee { + flex: 1; overflow: hidden; position: relative; height: 100%; + mask-image: linear-gradient(90deg, transparent, #000 20px, #000 calc(100% - 20px), transparent); +} +.mq { + display: flex; gap: 26px; position: absolute; white-space: nowrap; + top: 50%; transform: translateY(-50%); animation: scroll 52s linear infinite; + font-size: 10.5px; +} +@keyframes scroll { from { left: 100% } to { left: -220% } } +.mq span b { font-weight: 500; margin-left: 5px; } + +/* ---------------------------------------------------------- 涨跌色 */ + +.up { color: var(--up); } +.down { color: var(--down); } +.flat { color: var(--dim); } + +/* ---------------------------------------------------------- 对话框 */ + +.dialog { + border: 1px solid var(--line); border-radius: 12px; + background: var(--panel); color: var(--fg); + padding: 0; box-shadow: var(--shadow); + min-width: 380px; max-width: 460px; + font-family: var(--sans); + /* 上面的 * reset 把 UA 样式表给 dialog 的 margin: auto 抹掉了, + 四个 inset 归零会让模态框贴死左上角。这里补回来,两轴重新居中。 */ + margin: auto; + max-height: calc(100% - 32px); +} +.dialog::backdrop { background: rgba(0, 0, 0, .5); } +.dialog form { padding: 20px 22px 18px; display: grid; gap: 13px; } +.dialog h2 { font-size: 16px; font-weight: 600; } +.cf-body { font-size: 13px; line-height: 1.6; color: var(--dim); } +.cf-body strong { color: var(--fg); font-family: var(--mono); } +.cf-warn { + font-size: 12px; line-height: 1.55; color: var(--danger); + background: var(--danger-bg); border-radius: 8px; padding: 9px 11px; +} +.cf-force { display: flex; gap: 8px; align-items: flex-start; font-size: 12.5px; color: var(--dim); cursor: pointer; } +.cf-force input { margin-top: 2px; } +.cf-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 3px; } +.set-row { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: center; font-size: 13px; } +.set-row label { color: var(--dim); } +.set-row select { font-size: 12px; padding: 5px 8px; width: 100%; } +.set-note { font-size: 11.5px; color: var(--mute); font-family: var(--mono); } + +/* ---------------------------------------------------------- 窄屏引导 + 布局最小 1280px,面板视口比它窄时右栏整个在可视区外。 + Host 没有给页面主动弹窗的能力,只能指向 File Panel 的弹出按钮。 */ + +.wide-hint { + /* 压在标题栏上会挡住暂停/设置,落在标题栏下方,箭头仍指右上角的弹出按钮 */ + position: fixed; top: 52px; right: 14px; z-index: 40; + width: 292px; padding: 12px 14px 11px; + display: flex; flex-direction: column; gap: 7px; + background: var(--panel); color: var(--fg); + border: 1px solid var(--line); border-radius: 10px; + box-shadow: 0 10px 28px rgb(0 0 0 / 26%); + animation: wh-in .18s ease-out; +} +.wide-hint[hidden] { display: none; } +.wide-hint::before { + content: ''; position: absolute; top: -7px; right: 26px; + width: 12px; height: 12px; transform: rotate(45deg); + background: var(--panel); + border-left: 1px solid var(--line); border-top: 1px solid var(--line); +} +.wh-title { font-family: var(--sans); font-size: 12.5px; font-weight: 600; } +.wh-body { font-size: 11.5px; line-height: 1.55; color: var(--dim); } +.wh-icon { + display: inline-block; min-width: 18px; text-align: center; + border-radius: 4px; background: var(--sunk); + color: var(--accent); font-family: var(--mono); +} +.wh-act { display: flex; justify-content: flex-end; } +@keyframes wh-in { from { opacity: 0; transform: translateY(-6px); } } +@media (prefers-reduced-motion: reduce) { .wide-hint { animation: none; } } + +.toast { + position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); + background: var(--panel); color: var(--fg); + border: 1px solid var(--line); border-radius: 8px; + padding: 9px 16px; font-size: 12.5px; box-shadow: var(--shadow); + z-index: 60; max-width: 60ch; +} +.toast[data-kind="error"] { border-color: var(--danger); color: var(--danger); } + +@media (prefers-reduced-motion: reduce) { + .mq { animation: none; left: 0; } + .live .dot { animation: none; } + * { transition-duration: .01ms !important; } +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.js b/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.js new file mode 100644 index 0000000..3678314 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/client/app.js @@ -0,0 +1,1016 @@ +// 本机大盘 · Client +// 数据流:bootstrap 全量 -> SSE 增量 -> 切品种/周期时重新拉聚合 + +import { Chart, drawSpark } from './chart.js'; + +const $ = (id) => document.getElementById(id); + +const UNITS = { + pct: { d: 1, s: '%' }, load: { d: 2, s: '' }, gib: { d: 2, s: 'G' }, + mib: { d: 0, s: 'M' }, mbs: { d: 2, s: 'M/s' }, kbs: { d: 1, s: 'K/s' }, + ps: { d: 0, s: '/s' }, cnt: { d: 0, s: '' }, hrs: { d: 1, s: 'h' }, +}; +const unitRule = (u) => UNITS[u] ?? UNITS.cnt; + +function fmt(v, unit) { + if (v == null || !Number.isFinite(v)) return '—'; + const r = unitRule(unit); + return v.toFixed(r.d) + r.s; +} +/** 涨跌始终带符号:方向不只靠颜色传达 */ +function fmtDelta(v, unit) { + if (v == null || !Number.isFinite(v)) return '—'; + const r = unitRule(unit); + const sign = v > 0 ? '+' : v < 0 ? '−' : ''; + return sign + Math.abs(v).toFixed(r.d) + r.s; +} +function fmtPct(v) { + if (v == null || !Number.isFinite(v)) return '—'; + return (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v).toFixed(2) + '%'; +} +const dirOf = (v) => (v == null || !Number.isFinite(v) || v === 0 ? 'flat' : v > 0 ? 'up' : 'down'); +const fmtBytes = (n) => { + if (!Number.isFinite(n)) return '—'; + const u = ['B', 'KiB', 'MiB', 'GiB']; + let i = 0, v = n; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; } + return v.toFixed(i === 0 ? 0 : 1) + ' ' + u[i]; +}; +const fmtRss = (kb) => (Number.isFinite(kb) ? (kb >= 1048576 ? (kb / 1048576).toFixed(1) + 'G' : (kb / 1024).toFixed(0) + 'M') : '—'); + +const VIEW_KEY = 'kurumi.view.v1'; +const HINT_KEY = 'kurumi.wideHint.dismissed.v1'; +// 布局最小宽度。视口比它窄时右栏整个落在可视区外,只能引导用户弹出独立窗口 +const LAYOUT_MIN_WIDTH = 1280; +const PROC_CODE_RE = /^P(\d+)-(\d+)$/; +/** 进程品种代码 -> { pid, startedAt }。代码里带启动时刻,pid 复用不会串线 */ +const procRef = (code) => { + const m = PROC_CODE_RE.exec(code ?? ''); + return m ? { pid: Number(m[1]), startedAt: Number(m[2]) } : null; +}; + +/** 副图内存三段:活动页 / 联动页 / 压缩页,单位 GiB */ +const MEM_CODES = { a: '300002', w: '300003', c: '300006' }; +const readMem = (v) => { + if (!v) return null; + const a = v[MEM_CODES.a], w = v[MEM_CODES.w], c = v[MEM_CODES.c]; + if (a == null && w == null && c == null) return null; + return { a: a ?? 0, w: w ?? 0, c: c ?? 0 }; +}; + +const state = { + catalog: [], byCode: new Map(), boards: [], + // 进程品种不在静态目录里,meta 只能从进程表的行里攒 + procMeta: new Map(), watchedCodes: new Set(), watchMax: 20, alive: true, livePids: new Set(), + settings: null, periods: [], sampleTiers: [], maxBars: 180, + currentUser: '', todayStart: 0, + candles: null, bars: [], ma5: [], ma10: [], ma20: [], quote: null, memTotalGiB: 0, + memSegments: [], + snapshot: null, events: [], procs: [], allProcs: [], procFetchAt: 0, + selectedPid: null, sortKey: 'cpu', sortDir: 'desc', query: '', + usage: null, host: null, es: null, clockTimer: null, +}; + +const chart = new Chart($('chart'), $('chartEmpty')); + +/** 品种元信息。自选进程会进 catalog,非自选进程只存在于 procMeta */ +const metaOf = (code) => state.byCode.get(code) ?? state.procMeta.get(code) ?? null; + +/** 用一批进程行刷新 procMeta。自选项无条件保留,其余按新快照重建,避免 Map 无限增长 */ +function syncProcMeta(rows) { + const next = new Map(); + const cur = state.settings?.selectedCode; + for (const [code, m] of state.procMeta) { + // 自选项与当前主图品种无条件留下:SSE 快照只带 top 50,选中的进程可能不在里面 + if (state.watchedCodes.has(code) || code === cur) next.set(code, m); + } + for (const r of rows) { + const ref = procRef(r.code); + if (!ref) continue; + next.set(r.code, { + code: r.code, pid: ref.pid, startedAt: ref.startedAt, + name: r.name, user: r.user, board: 'proc', unit: 'pct', + }); + } + state.procMeta = next; +} + +// ---------------------------------------------------------------- 网络 + +async function api(path, options) { + const res = await fetch(path, { + ...options, + headers: options?.body ? { 'content-type': 'application/json' } : undefined, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(body.error ?? 'request_failed'), { code: body.error }); + return body; +} + +function toast(msg, kind) { + const el = $('toast'); + el.textContent = msg; + el.dataset.kind = kind ?? 'info'; + el.hidden = false; + clearTimeout(toast._t); + toast._t = setTimeout(() => { el.hidden = true; }, 3600); +} + +// ---------------------------------------------------------------- 主题 + +function applyTheme() { + const s = state.settings; + document.documentElement.dataset.theme = s.theme === 'auto' ? 'auto' : s.theme; + document.documentElement.dataset.scheme = s.colorScheme; + $('btnTheme').textContent = s.theme === 'light' ? '浅色' : s.theme === 'dark' ? '夜间' : '跟随'; + $('btnScheme').textContent = s.colorScheme === 'redUp' ? '红涨' : '绿涨'; + // 主题变量变了,画布要重画 + requestAnimationFrame(() => { chart.paint(); repaintSparks(); }); +} + +async function patchSettings(patch) { + const { settings } = await api('/api/settings', { method: 'POST', body: JSON.stringify(patch) }); + state.settings = settings; + return settings; +} + +// ---------------------------------------------------------------- 指数条 + +function renderIndex(index) { + const list = $('indexList'); + list.replaceChildren(); + for (const item of index) { + const meta = item.meta ?? state.byCode.get(item.code); + if (!meta) continue; + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ix'; + btn.setAttribute('aria-current', String(item.code === state.settings.selectedCode)); + // index[].quote 与 quotes[code] 在服务端同源,但过 JSON 之后是两个独立对象, + // 增量更新只写 quotes。指数条必须回 quotes 取,否则永远停在首帧。 + const q = state.candles?.quotes?.[item.code] ?? item.quote ?? { last: null, delta: null }; + const dir = dirOf(q.delta); + btn.innerHTML = + ''; + btn.querySelector('.n').textContent = meta.name; + btn.querySelector('.p').textContent = fmt(q.last, meta.unit); + btn.querySelector('.d').textContent = fmtDelta(q.delta, meta.unit); + btn.setAttribute('aria-label', meta.name + ' ' + fmt(q.last, meta.unit) + ',涨跌 ' + fmtDelta(q.delta, meta.unit)); + const cv = document.createElement('canvas'); + btn.appendChild(cv); + btn.addEventListener('click', () => selectCode(item.code)); + li.appendChild(btn); + list.appendChild(li); + requestAnimationFrame(() => drawSpark(cv, item.spark, q.delta >= 0)); + } +} + +function repaintSparks() { + const cvs = document.querySelectorAll('.ix canvas'); + const idx = state.candles?.index ?? []; + cvs.forEach((cv, i) => { + const item = idx[i]; + if (item) drawSpark(cv, item.spark, item.quote.delta >= 0); + }); + document.querySelectorAll('.ptable .spark').forEach((cv) => { + const vals = (cv.dataset.v ?? '').split(',').map(Number).filter(Number.isFinite); + drawSpark(cv, vals, cv.dataset.u === '1'); + }); +} + +// ---------------------------------------------------------------- 我的自选 / 大盘行情 + +const BOARD_NAMES = { + cpu: 'CPU 板块', gpu: 'GPU 板块', thermal: '温度 板块', mem: '内存 板块', + disk: '磁盘 板块', net: '网络 板块', sys: '系统 板块', +}; + +/** + * 列表每秒整批重建。重建前记住焦点落在哪一行、列表滚到哪,重建后放回去, + * 否则键盘用户每秒钟被弹回列表开头。 + */ +function withFocusKept(el, rebuild) { + const scroller = el.closest('.wl-scroll'); + const top = scroller?.scrollTop ?? 0; + const fk = document.activeElement?.dataset?.fk ?? null; + const hadFocus = fk != null && el.contains(document.activeElement); + rebuild(); + if (scroller) scroller.scrollTop = top; + if (hadFocus) el.querySelector('[data-fk="' + fk + '"]')?.focus(); +} + +/** + * 一行行情。大盘行整行是一个按钮;自选行拆成「切主图」与「移除」两个平级按钮 —— + * 按钮不能嵌按钮,嵌套交互元素屏幕阅读器会读乱。 + */ +function quoteRow(m, q, proc) { + const dir = dirOf(q.delta); + const cur = String(m.code === state.settings.selectedCode); + const cells = + '' + + ''; + const label = m.name + ' ' + fmt(q.last, m.unit) + ',涨跌 ' + fmtDelta(q.delta, m.unit); + + if (!proc) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'wrow'; + btn.dataset.fk = m.code; + btn.setAttribute('aria-current', cur); + btn.setAttribute('aria-label', label); + btn.innerHTML = cells; + btn.querySelector('.cd').textContent = m.code; + btn.querySelector('.nm').textContent = m.name; + btn.querySelector('.px').textContent = fmt(q.last, m.unit); + btn.querySelector('.ch').textContent = fmtDelta(q.delta, m.unit); + btn.addEventListener('click', () => selectCode(m.code)); + return btn; + } + + const row = document.createElement('div'); + row.className = 'wrow wrow--proc'; + row.dataset.exited = String(proc.exited); + + const main = document.createElement('button'); + main.type = 'button'; + main.className = 'wmain'; + main.dataset.fk = m.code; + main.setAttribute('aria-current', cur); + main.setAttribute('aria-label', label + ',查看走势'); + main.innerHTML = cells; + main.querySelector('.cd').textContent = 'PID ' + proc.pid; + main.querySelector('.nm').textContent = m.name; + main.querySelector('.px').textContent = fmt(q.last, m.unit); + main.querySelector('.ch').textContent = fmtDelta(q.delta, m.unit); + main.addEventListener('click', () => selectCode(m.code)); + + const star = document.createElement('button'); + star.type = 'button'; + star.className = 'star'; + star.dataset.fk = m.code + ':star'; + star.setAttribute('aria-pressed', 'true'); + star.setAttribute('aria-label', '移除自选 ' + m.name + ' PID ' + proc.pid); + star.textContent = '★'; + star.addEventListener('click', () => toggleWatch(m, false)); + + row.append(main, star); + return row; +} + +/** 我的自选:只有进程品种。加了自选才有持久历史,退出后历史也还在 */ +function renderWatchlist() { + const body = $('watchBody'); + const quotes = state.candles?.quotes ?? {}; + const items = state.catalog.filter((m) => m.board === 'proc'); + withFocusKept(body, () => { + body.replaceChildren(); + for (const m of items) { + const ref = procRef(m.code); + const pid = ref?.pid ?? m.pid; + body.appendChild(quoteRow(m, quotes[m.code] ?? { last: null, delta: null }, { + pid, exited: !state.livePids.has(pid), + })); + } + if (!items.length) { + const p = document.createElement('p'); + p.className = 'wl-empty'; + p.innerHTML = '还没有自选进程。
在下方个股列表点 ☆ 收藏,' + + '进程走势会被持续记录,重启后历史仍然在。'; + body.appendChild(p); + } + }); + $('wlCount').textContent = items.length + '/' + state.watchMax + ' 项'; +} + +/** 大盘行情:41 项系统指标,按板块分组 */ +function renderMarket() { + const body = $('marketBody'); + const quotes = state.candles?.quotes ?? {}; + const groups = new Map(); + for (const m of state.catalog) { + if (m.board === 'proc') continue; + if (!groups.has(m.board)) groups.set(m.board, []); + groups.get(m.board).push(m); + } + let count = 0; + withFocusKept(body, () => { + body.replaceChildren(); + for (const [board, items] of groups) { + const h = document.createElement('p'); + h.className = 'grp'; + h.textContent = '【' + (BOARD_NAMES[board] ?? board) + '】'; + body.appendChild(h); + for (const m of items) { + count += 1; + body.appendChild(quoteRow(m, quotes[m.code] ?? { last: null, delta: null }, null)); + } + } + }); + $('mkCount').textContent = count + ' 项'; +} + +/** 加 / 移除自选。只有自选进程的采样值会写进 tick 落盘,移除后主图退回内存序列 */ +async function toggleWatch(meta, on) { + try { + const d = await api('/api/watch', { + method: 'POST', + body: JSON.stringify({ op: on ? 'add' : 'remove', code: meta.code, name: meta.name, user: meta.user }), + }); + state.catalog = d.catalog; + state.byCode = new Map(d.catalog.map((m) => [m.code, m])); + state.watchedCodes = new Set(d.watchCodes); + await refreshProcs(); + // 必须重拉:quotes 的 key 集合跟着 catalog 走,不重拉的话刚加进来的进程 + // 在这一帧拿不到报价,列表上会一直挂着「—」。顺带把当前品种的历史来源也刷新了。 + await loadCandles(); + renderQuote(); + toast(on ? '已加自选 · ' + meta.name : '已移除自选 · ' + meta.name, 'info'); + } catch (e) { + toast(e.code === 'watch_full' + ? '自选已满,最多 ' + state.watchMax + ' 个进程' + : '自选操作失败:' + (e.code ?? '未知错误'), 'error'); + } +} + +// ---------------------------------------------------------------- 品种头与主图 + +function renderQuote() { + const code = state.settings.selectedCode; + const meta = metaOf(code); + const q = state.quote; + if (!meta || !q) return; + $('qCode').textContent = meta.code; + $('quote-h').textContent = meta.name; + $('qLast').textContent = fmt(q.last, meta.unit); + $('qLast').className = 'qval ' + dirOf(q.delta); + $('qDelta').textContent = fmtDelta(q.delta, meta.unit); + $('qDelta').className = 'qdelta ' + dirOf(q.delta); + $('qPct').textContent = fmtPct(q.pct); + $('qPct').className = 'qpct ' + dirOf(q.delta); + $('qOpen').textContent = fmt(q.open, meta.unit); + $('qHigh').textContent = fmt(q.high, meta.unit); + $('qLow').textContent = fmt(q.low, meta.unit); + $('qAvg').textContent = fmt(q.avg, meta.unit); + + // 进程品种要多说两件事:进程还在不在、收藏了没 + const ref = procRef(code); + const tag = $('qTag'); + const btn = $('btnWatch'); + if (!ref) { + tag.hidden = true; + btn.hidden = true; + return; + } + const on = state.watchedCodes.has(code); + tag.hidden = false; + tag.dataset.kind = state.alive ? 'proc' : 'exited'; + tag.textContent = state.alive ? 'PID ' + ref.pid : 'PID ' + ref.pid + ' 已退出'; + btn.hidden = false; + btn.setAttribute('aria-pressed', String(on)); + btn.textContent = on ? '★ 已自选' : '☆ 加自选'; + btn.setAttribute('aria-label', (on ? '移除自选 ' : '加自选 ') + meta.name + ' PID ' + ref.pid); +} + +function renderChart() { + const meta = metaOf(state.settings.selectedCode); + chart.render({ + bars: state.bars, ma5: state.ma5, ma10: state.ma10, ma20: state.ma20, + memTotalGiB: state.memTotalGiB || state.snapshot?.totalMemGiB || 0, + memSegments: state.memSegments, + }, meta); +} + +function renderPeriods() { + const box = $('periodTabs'); + box.replaceChildren(); + state.periods.forEach((p, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'pd'; + b.role = 'tab'; + b.textContent = p.label; + b.setAttribute('aria-selected', String(i === state.settings.periodIndex)); + b.addEventListener('click', async () => { + await patchSettings({ periodIndex: i }); + await loadCandles(); + renderPeriods(); + }); + box.appendChild(b); + }); +} + +async function loadCandles() { + const code = state.settings.selectedCode; + const period = state.periods[state.settings.periodIndex]?.seconds ?? 5; + const data = await api('/api/candles?code=' + encodeURIComponent(code) + '&period=' + period); + state.candles = data; + state.memTotalGiB = Number(data.memTotalGiB) || 0; + state.bars = data.bars ?? []; + state.ma5 = data.ma5 ?? []; + state.ma10 = data.ma10 ?? []; + state.ma20 = data.ma20 ?? []; + state.quote = data.quote; + state.alive = data.alive !== false; + renderQuote(); + renderChart(); + renderIndex(data.index ?? []); + renderWatchlist(); + renderMarket(); + renderMarquee(data.index ?? []); +} + +async function selectCode(code) { + if (code === state.settings.selectedCode) return; + await patchSettings({ selectedCode: code }); + await loadCandles(); +} + +// ---------------------------------------------------------------- 主力动向 / 逐笔 + +function renderDepth(procs) { + const list = $('depthList'); + list.replaceChildren(); + const top = [...procs].sort((a, b) => b.cpu - a.cpu).slice(0, 10); + const max = top[0]?.cpu ?? 1; + for (let i = 0; i < top.length; i += 1) { + const p = top[i]; + const li = document.createElement('li'); + li.className = 'dr'; + li.innerHTML = ''; + li.querySelector('.rk').textContent = String(i + 1); + li.querySelector('.bar').style.width = (max > 0 ? (p.cpu / max) * 100 : 0) + '%'; + li.querySelector('.nm span:last-child').textContent = p.name; + li.querySelector('.vl').textContent = p.cpu.toFixed(1); + li.setAttribute('aria-label', '第 ' + (i + 1) + ' 名 ' + p.name + ',CPU ' + p.cpu.toFixed(1) + '%'); + list.appendChild(li); + } +} + +function renderTape(events) { + const list = $('tapeList'); + list.replaceChildren(); + for (const ev of events.slice(-80).reverse()) { + list.appendChild(tapeRow(ev)); + } + $('tapeCount').textContent = events.length + ' 条'; +} + +function tapeRow(ev) { + const li = document.createElement('li'); + li.className = 'tp'; + const dir = ev.up ? 'up' : 'down'; + li.innerHTML = ''; + li.querySelector('.tm').textContent = ev.time; + li.querySelector('.ev').textContent = ev.label; + li.querySelector('.vl').textContent = ev.value; + return li; +} + +// ---------------------------------------------------------------- 进程表 + +function sortProcs(rows) { + const k = state.sortKey; + const dir = state.sortDir === 'asc' ? 1 : -1; + return [...rows].sort((a, b) => { + const av = a[k], bv = b[k]; + if (typeof av === 'string') return av.localeCompare(bv) * dir; + return ((av ?? 0) - (bv ?? 0)) * dir; + }); +} + +function renderProcs() { + const body = $('procBody'); + const q = state.query.toLowerCase(); + let rows = state.allProcs.length ? state.allProcs : state.procs; + if (q) rows = rows.filter((p) => p.name.toLowerCase().includes(q) || String(p.pid) === q); + rows = sortProcs(rows).slice(0, 300); + + // 行会整批重建,焦点与滚动位置必须自己接住,否则键盘用户每 2 秒被弹回页首 + const focusedPid = document.activeElement?.closest?.('#procBody tr')?.dataset.pid ?? null; + const scroller = body.closest('.proc-scroll'); + const keepTop = scroller?.scrollTop ?? 0; + + body.replaceChildren(); + for (const p of rows) { + const tr = document.createElement('tr'); + tr.dataset.pid = String(p.pid); + tr.dataset.killable = String(!!p.killable); + tr.dataset.chart = String(p.code === state.settings.selectedCode); + tr.tabIndex = 0; + tr.setAttribute('aria-selected', String(state.selectedPid === p.pid)); + tr.innerHTML = + '' + + '' + + '' + + ''; + const star = tr.querySelector('.star'); + star.textContent = p.watched ? '★' : '☆'; + star.setAttribute('aria-pressed', String(!!p.watched)); + star.setAttribute('aria-label', (p.watched ? '移除自选 ' : '加自选 ') + p.name + ' PID ' + p.pid); + // 星标只管收藏,不能冒泡到行上,否则点收藏会顺带切主图 + star.addEventListener('click', (e) => { + e.stopPropagation(); + void toggleWatch(state.procMeta.get(p.code) ?? { code: p.code, name: p.name, user: p.user }, !p.watched); + }); + + const tds = tr.querySelectorAll('td'); + tds[1].textContent = String(p.pid); + tds[2].textContent = p.name; + tds[3].textContent = p.user; + tds[4].textContent = p.cpu.toFixed(1); + tds[5].textContent = p.mem.toFixed(1); + tds[6].textContent = fmtRss(p.rss); + tds[7].textContent = p.etime; + const cv = tds[8].querySelector('canvas'); + cv.dataset.v = (p.spark ?? []).join(','); + cv.dataset.u = (p.spark?.length ?? 0) > 1 && p.spark[p.spark.length - 1] >= p.spark[0] ? '1' : '0'; + + const pick = () => selectProc(p); + tr.addEventListener('click', pick); + tr.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } + }); + body.appendChild(tr); + } + $('procEmpty').hidden = rows.length > 0; + if (scroller) scroller.scrollTop = keepTop; + if (focusedPid) body.querySelector('tr[data-pid="' + focusedPid + '"]')?.focus(); + requestAnimationFrame(() => { + body.querySelectorAll('canvas.spark').forEach((cv) => { + const vals = (cv.dataset.v ?? '').split(',').map(Number).filter(Number.isFinite); + drawSpark(cv, vals, cv.dataset.u === '1'); + }); + }); +} + +function selectProc(p) { + state.selectedPid = p.pid; + const info = $('selInfo'); + const btn = $('btnKill'); + if (p.killable) { + info.textContent = p.name + ' · PID ' + p.pid + ' · ' + p.user; + btn.disabled = false; + } else { + const why = !p.owned ? '属主 ' + p.user + ',无权清仓' : '受保护进程,不可清仓'; + info.textContent = p.name + ' · ' + why; + btn.disabled = true; + } + document.querySelectorAll('#procBody tr').forEach((tr) => { + tr.setAttribute('aria-selected', String(Number(tr.dataset.pid) === p.pid)); + }); + saveView(); + // 个股列表里的每一行都是一个可看走势的品种,点行等于切主图 + if (p.code) void selectCode(p.code); +} + +// ---------------------------------------------------------------- 跑马灯与状态栏 + +function renderMarquee(index) { + const box = $('mq'); + const parts = index.map((i) => { + const meta = i.meta ?? state.byCode.get(i.code); + if (!meta) return ''; + const dir = dirOf(i.quote.delta); + return '' + meta.name + ' ' + + '' + fmt(i.quote.last, meta.unit) + '' + + '' + fmtDelta(i.quote.delta, meta.unit) + ''; + }); + box.innerHTML = parts.join('') + parts.join(''); +} + +function renderStatus() { + const s = state.settings; + $('stSample').textContent = (s.sampleMs / 1000).toFixed(s.sampleMs < 1000 ? 1 : 0) + 's' + (s.running ? '' : ' 暂停'); + $('stProcs').textContent = state.snapshot?.procCount ?? '—'; + $('stHistory').textContent = state.usage ? state.usage.hours + ' 小时' : '—'; + $('stDisk').textContent = state.usage ? fmtBytes(state.usage.bytes) : '—'; + $('stUser').textContent = state.currentUser; +} + +// ---------------------------------------------------------------- 增量更新 + +function applyTick(tick, snapshot, freshEvents) { + state.snapshot = snapshot; + state.procs = snapshot.procs ?? []; + if (state.procs.length) syncProcMeta(state.procs); + + // 当前品种的 K 线增量 + const code = state.settings.selectedCode; + const meta = metaOf(code); + const period = state.periods[state.settings.periodIndex]?.seconds ?? 5; + const periodMs = period * 1000; + + // 进程退出后 livePids 里就没有它了,品种头要立刻从「PID xxx」翻成「已退出」 + const curRef = procRef(code); + if (curRef && state.livePids.size > 0) { + const alive = state.livePids.has(curRef.pid); + if (alive !== state.alive) { + state.alive = alive; + renderQuote(); + } + } + + let val = tick.v?.[code]; + if (val == null || !Number.isFinite(val)) { + // 非自选进程的采样值不写进 tick(写进去就得落盘),只能从进程快照里现取。 + // 不取的话点开任何一个进程,主图都是一条不动的线。 + const ref = procRef(code); + if (ref) { + const row = state.procs.find((x) => x.pid === ref.pid) + ?? state.allProcs.find((x) => x.pid === ref.pid); + if (row && Number.isFinite(row.cpu)) val = row.cpu; + } + } + let dirty = false; + if (meta && val != null && Number.isFinite(val)) { + const key = Math.floor(tick.t / periodMs) * periodMs; + const last = state.bars[state.bars.length - 1]; + if (last && last.t === key) { + last.h = Math.max(last.h, val); + last.l = Math.min(last.l, val); + last.c = val; + last.v += tick.w ?? 0; + } else { + state.bars.push({ t: key, o: val, h: val, l: val, c: val, v: tick.w ?? 0, n: 1, mem: null }); + if (state.bars.length > state.maxBars) state.bars.shift(); + } + const closes = state.bars.map((b) => b.c); + state.ma5 = movingAverage(closes, 5); + state.ma10 = movingAverage(closes, 10); + state.ma20 = movingAverage(closes, 20); + + const q = state.quote ?? {}; + q.last = val; + if (q.open == null) q.open = val; + q.high = q.high == null ? val : Math.max(q.high, val); + q.low = q.low == null ? val : Math.min(q.low, val); + q.delta = val - q.open; + q.pct = q.open === 0 ? null : (q.delta / Math.abs(q.open)) * 100; + state.quote = q; + renderQuote(); + dirty = true; + } + + // 副图内存与 K 线共用时间轴,每拍把最新的内存构成贴到当前这根柱子上 + const curBar = state.bars[state.bars.length - 1]; + const mem = readMem(tick.v); + if (mem && curBar) { + curBar.mem = mem; + dirty = true; + } + if (dirty) renderChart(); + + // 全量品种头:自选列表 40+ 项都要跟着 tick 走 + const quotes = state.candles?.quotes ?? {}; + for (const code in tick.v) { + const q = quotes[code]; + const v = tick.v[code]; + if (q == null || v == null || !Number.isFinite(v)) continue; + q.last = v; + if (q.open == null) q.open = v; + q.delta = v - q.open; + q.pct = q.open === 0 ? null : (q.delta / Math.abs(q.open)) * 100; + } + const idx = state.candles?.index ?? []; + for (const item of idx) { + const v = tick.v?.[item.code]; + if (v == null) continue; + item.spark.push(v); + if (item.spark.length > 48) item.spark.shift(); + } + renderIndex(idx); + renderWatchlist(); + renderMarket(); + renderMarquee(idx); + renderDepth(state.procs); + // 个股列表要全量行和当前搜索词,SSE 快照只带 top N,所以另拉;限流 2 秒 + // 拉到新数据才重绘:每秒重建 300 行会把键盘焦点和滚动位置反复清掉 + if (tick.t - state.procFetchAt > 2000) { + state.procFetchAt = tick.t; + void refreshProcs(); + } + renderStatus(); + + if (freshEvents?.length) { + state.events.push(...freshEvents); + if (state.events.length > 500) state.events.splice(0, state.events.length - 500); + const list = $('tapeList'); + for (const ev of freshEvents) list.prepend(tapeRow(ev)); + while (list.children.length > 80) list.lastElementChild.remove(); + $('tapeCount').textContent = state.events.length + ' 条'; + } +} + +function movingAverage(values, n) { + const out = new Array(values.length).fill(null); + let sum = 0; + for (let i = 0; i < values.length; i += 1) { + sum += values[i]; + if (i >= n) sum -= values[i - n]; + if (i >= n - 1) out[i] = sum / n; + } + return out; +} + +// ---------------------------------------------------------------- SSE + +function connect() { + if (state.es) state.es.close(); + const es = new EventSource('/api/stream'); + state.es = es; + es.addEventListener('open', () => setLive('on', '实时采样')); + es.addEventListener('tick', (e) => { + try { + const d = JSON.parse(e.data); + if (d.tick) applyTick(d.tick, d.snapshot, d.events ?? []); + } catch (err) { + console.error('[kurumi] applyTick failed', err); + } + }); + es.addEventListener('error', () => setLive('off', '连接中断,重试中')); +} + +function setLive(kind, text) { + const el = document.querySelector('.live'); + el.dataset.state = kind; + $('liveText').textContent = text; +} + +// ---------------------------------------------------------------- 清仓 + +async function openKillDialog() { + const pid = state.selectedPid; + if (pid == null) return; + let pre; + try { + pre = await api('/api/kill/preflight', { method: 'POST', body: JSON.stringify({ pid }) }); + } catch (e) { + toast('无法校验目标进程:' + (e.code ?? '请求失败'), 'error'); + return; + } + if (!pre.ok) { + toast('该进程不可清仓:' + killReason(pre.reason), 'error'); + return; + } + $('cfBody').innerHTML = '即将终止 ' + pre.name + '(PID ' + pre.pid + + ',属主 ' + pre.user + '),当前 CPU ' + + (pre.cpu ?? 0).toFixed(1) + '%,常驻内存 ' + fmtRss(pre.rss) + '。'; + $('cfForce').checked = false; + const dlg = $('confirmDlg'); + dlg.returnValue = ''; + dlg.showModal(); + dlg.addEventListener('close', async () => { + if (dlg.returnValue !== 'confirm') return; + const force = $('cfForce').checked; + try { + const r = await api('/api/kill', { + method: 'POST', + body: JSON.stringify({ pid, force, startedAt: pre.startedAt }), + }); + toast('已向 ' + r.name + ' (PID ' + r.pid + ') 发送 ' + r.signal, 'info'); + state.selectedPid = null; + $('selInfo').textContent = '未选中'; + $('btnKill').disabled = true; + setTimeout(refreshProcs, 400); + } catch (e) { + toast('清仓失败:' + killReason(e.code), 'error'); + } + }, { once: true }); +} + +function killReason(code) { + return { + not_owned: '进程不属于当前用户', + protected: '受保护进程,不能终止', + not_found: '进程已不存在', + invalid_pid: 'PID 不合法', + pid_reused: '该进程已退出或 PID 被复用,请重新选择', + bad_request: '请求参数不完整', + not_permitted: '系统拒绝该操作', + gone: '进程已经退出', + kill_failed: '发送信号失败', + }[code] ?? '未知错误'; +} + +async function refreshProcs() { + try { + const q = state.query ? '&q=' + encodeURIComponent(state.query) : ''; + const d = await api('/api/procs?t=' + Date.now() + q); + state.allProcs = d.procs; + syncProcMeta(d.procs); + // 搜索会把结果过滤掉,存活判断只能在无搜索词时更新,否则自选区会误标「已退出」 + if (!state.query) state.livePids = new Set(d.procs.map((p) => p.pid)); + renderProcs(); + } catch (err) { + console.error('[kurumi] refreshProcs failed', err); + } +} + +// ---------------------------------------------------------------- 视图状态 + +function saveView() { + try { + localStorage.setItem(VIEW_KEY, JSON.stringify({ + query: state.query, sortKey: state.sortKey, sortDir: state.sortDir, + selectedPid: state.selectedPid, + procScroll: document.querySelector('.proc-scroll')?.scrollTop ?? 0, + wlScroll: document.querySelector('#watchlist .wl-scroll')?.scrollTop ?? 0, + mkScroll: document.querySelector('.market .wl-scroll')?.scrollTop ?? 0, + })); + } catch { /* 存储不可用时忽略 */ } +} + +function restoreView() { + try { + const raw = localStorage.getItem(VIEW_KEY); + if (!raw) return; + const v = JSON.parse(raw); + state.query = v.query ?? ''; + state.sortKey = v.sortKey ?? 'cpu'; + state.sortDir = v.sortDir ?? 'desc'; + state.selectedPid = v.selectedPid ?? null; + $('procSearch').value = state.query; + requestAnimationFrame(() => { + const ps = document.querySelector('.proc-scroll'); + const ws = document.querySelector('#watchlist .wl-scroll'); + const ms = document.querySelector('.market .wl-scroll'); + if (ps && v.procScroll) ps.scrollTop = v.procScroll; + if (ws && v.wlScroll) ws.scrollTop = v.wlScroll; + if (ms && v.mkScroll) ms.scrollTop = v.mkScroll; + }); + } catch { /* 数据损坏时用默认值 */ } +} + +// ---------------------------------------------------------------- 事件绑定 + +function bind() { + $('btnTheme').addEventListener('click', async () => { + const order = ['auto', 'dark', 'light']; + const next = order[(order.indexOf(state.settings.theme) + 1) % order.length]; + await patchSettings({ theme: next }); + applyTheme(); + }); + $('btnScheme').addEventListener('click', async () => { + await patchSettings({ colorScheme: state.settings.colorScheme === 'redUp' ? 'greenUp' : 'redUp' }); + applyTheme(); + }); + $('btnRun').addEventListener('click', async () => { + const next = !state.settings.running; + await patchSettings({ running: next }); + $('btnRun').textContent = next ? '暂停' : '继续'; + $('btnRun').setAttribute('aria-pressed', String(next)); + setLive(next ? 'on' : 'off', next ? '实时采样' : '已暂停'); + }); + $('btnKill').addEventListener('click', openKillDialog); + $('btnWatch').addEventListener('click', () => { + const code = state.settings.selectedCode; + const meta = metaOf(code); + if (meta) void toggleWatch(meta, !state.watchedCodes.has(code)); + }); + + const sel = $('sampleSel'); + sel.replaceChildren(); + for (const ms of state.sampleTiers) { + const o = document.createElement('option'); + o.value = String(ms); + o.textContent = ms < 1000 ? (ms / 1000).toFixed(1) + 's' : (ms / 1000) + 's'; + if (ms === state.settings.sampleMs) o.selected = true; + sel.appendChild(o); + } + sel.addEventListener('change', async () => { + await patchSettings({ sampleMs: Number(sel.value) }); + renderStatus(); + }); + + $('procSearch').addEventListener('input', (e) => { + state.query = e.target.value.trim(); + refreshProcs(); + saveView(); + }); + + document.querySelectorAll('.ptable th button[data-sort]').forEach((b) => { + b.addEventListener('click', () => { + const k = b.dataset.sort; + if (state.sortKey === k) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + else { state.sortKey = k; state.sortDir = 'desc'; } + document.querySelectorAll('.ptable th').forEach((th) => th.removeAttribute('aria-sort')); + b.closest('th').setAttribute('aria-sort', state.sortDir === 'asc' ? 'ascending' : 'descending'); + renderProcs(); + saveView(); + }); + }); + + $('btnSettings').addEventListener('click', () => { + $('setTheme').value = state.settings.theme; + $('setScheme').value = state.settings.colorScheme; + $('setRetention').value = String(state.settings.retentionHours); + $('setUsage').textContent = '历史占用 ' + fmtBytes(state.usage?.bytes ?? 0) + + ' · ' + (state.usage?.hours ?? 0) + ' 个分片 · ' + (state.host?.cores ?? '?') + ' 核 · ' + + (state.host?.arch ?? ''); + $('settingsDlg').showModal(); + }); + $('setTheme').addEventListener('change', async (e) => { + await patchSettings({ theme: e.target.value }); + applyTheme(); + }); + $('setScheme').addEventListener('change', async (e) => { + await patchSettings({ colorScheme: e.target.value }); + applyTheme(); + }); + $('setRetention').addEventListener('change', async (e) => { + await patchSettings({ retentionHours: Number(e.target.value) }); + state.usage = (await api('/api/bootstrap')).usage; + renderStatus(); + }); + + // 页面可能被 Host 回收后重建,隐藏时立刻保存视图状态 + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') saveView(); + }); + $('whClose').addEventListener('click', () => { + try { sessionStorage.setItem(HINT_KEY, '1'); } catch { /* 隐私模式下写不进去 */ } + updateWideHint(); + }); + window.addEventListener('resize', () => { + updateWideHint(); + clearTimeout(bind._rt); + bind._rt = setTimeout(() => { chart.paint(); repaintSparks(); }, 140); + }); + // 系统主题变化时,auto 模式要跟着重画 + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (state.settings.theme === 'auto') applyTheme(); + }); +} + +// ---------------------------------------------------------------- 窄屏引导 + +function updateWideHint() { + const el = $('wideHint'); + if (!el) return; + let dismissed = false; + try { dismissed = sessionStorage.getItem(HINT_KEY) === '1'; } catch { /* 读不到就当没关过 */ } + el.hidden = window.innerWidth >= LAYOUT_MIN_WIDTH || dismissed; +} + +// ---------------------------------------------------------------- 启动 + +async function boot() { + const d = await api('/api/bootstrap'); + state.catalog = d.catalog; + state.byCode = new Map(d.catalog.map((m) => [m.code, m])); + state.watchedCodes = new Set(d.watchCodes ?? []); + state.watchMax = d.watchMax ?? 20; + state.settings = d.settings; + state.periods = d.periods; + state.sampleTiers = d.sampleTiers; + state.maxBars = d.maxBars; + state.currentUser = d.currentUser; + state.host = d.host; + state.snapshot = d.snapshot; + state.events = d.events ?? []; + state.usage = d.usage; + state.todayStart = d.todayStart; + state.memSegments = d.memSegments ?? []; + + state.candles = d.candles; + state.memTotalGiB = Number(d.candles.memTotalGiB) || 0; + state.bars = d.candles.bars ?? []; + state.ma5 = d.candles.ma5 ?? []; + state.ma10 = d.candles.ma10 ?? []; + state.ma20 = d.candles.ma20 ?? []; + state.quote = d.candles.quote; + state.alive = d.candles.alive !== false; + + restoreView(); + applyTheme(); + renderPeriods(); + renderQuote(); + renderChart(); + renderIndex(d.candles.index ?? []); + renderWatchlist(); + renderMarket(); + renderMarquee(d.candles.index ?? []); + renderTape(state.events); + renderStatus(); + $('btnRun').textContent = state.settings.running ? '暂停' : '继续'; + $('btnRun').setAttribute('aria-pressed', String(state.settings.running)); + bind(); + updateWideHint(); + await refreshProcs(); + // 存活判断依赖进程表,首帧自选区全是「已退出」,拿到真实 pid 集合后重绘 + renderWatchlist(); + connect(); + + state.clockTimer = setInterval(() => { + const now = new Date(); + const p = (n) => String(n).padStart(2, '0'); + $('clock').textContent = p(now.getHours()) + ':' + p(now.getMinutes()) + ':' + p(now.getSeconds()); + $('clock').dateTime = now.toISOString(); + }, 1000); +} + +boot().catch((e) => { + setLive('off', '启动失败'); + toast('启动失败:' + (e?.code ?? e?.message ?? '未知错误'), 'error'); +}); diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/client/chart.js b/plugins/jzlikewei/kurumi-monitor/miniapp/client/chart.js new file mode 100644 index 0000000..1f2859e --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/client/chart.js @@ -0,0 +1,384 @@ +// K 线渲染:蜡烛 + MA + 成交量 + 十字光标 OHLC 浮窗 +// 颜色全部来自 CSS 变量,切换主题后重绘即跟随。 + +const cssVar = (el, name) => getComputedStyle(el).getPropertyValue(name).trim(); + +/** 单位 -> 小数位,与 Node 侧 metrics.mjs 的 UNITS 保持一致 */ +const DEC = { pct: 1, load: 2, gib: 2, mib: 0, mbs: 2, kbs: 1, ps: 0, cnt: 0, hrs: 1 }; +export const decimalsOf = (unit) => DEC[unit] ?? 1; + +function palette(root) { + return { + up: cssVar(root, '--up'), + down: cssVar(root, '--down'), + accent: cssVar(root, '--accent'), + line: cssVar(root, '--line'), + mute: cssVar(root, '--mute'), + dim: cssVar(root, '--dim'), + fg: cssVar(root, '--fg'), + sunk: cssVar(root, '--sunk'), + panel: cssVar(root, '--panel'), + memA: cssVar(root, '--mem-a'), + memW: cssVar(root, '--mem-w'), + memC: cssVar(root, '--mem-c'), + }; +} + +function fitCanvas(cv) { + const dpr = window.devicePixelRatio || 1; + const r = cv.getBoundingClientRect(); + if (r.width < 2 || r.height < 2) return null; + const w = Math.round(r.width * dpr); + const h = Math.round(r.height * dpr); + if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; } + const g = cv.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + return { g, W: r.width, H: r.height }; +} + +/** 内存三段的兜底显示名。服务端按平台下发真实名称,这份只在首帧到达前顶一下 */ +const DEFAULT_SEGS = [ + { name: '活动页', short: '活' }, + { name: '联动页', short: '联' }, + { name: '压缩页', short: '压' }, +]; + +/** 一根柱子的已用内存(GiB):三段之和 */ +const memUsed = (m) => (m ? (m.a ?? 0) + (m.w ?? 0) + (m.c ?? 0) : 0); + +/** 浮窗里的内存三段明细 */ +const memText = (m) => { + if (!m || memUsed(m) === 0) return '—'; + const f = (x) => (x ?? 0).toFixed(1); + return `${f(m.a)}/${f(m.w)}/${f(m.c)}`; +}; + +const fmtTime = (t) => { + const d = new Date(t); + const p = (n) => String(n).padStart(2, '0'); + return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); +}; + +export class Chart { + constructor(canvas, emptyEl) { + this.cv = canvas; + this.emptyEl = emptyEl; + this.data = null; + this.meta = null; + this.cursorX = null; + this.segs = () => (this.data?.memSegments?.length === 3 ? this.data.memSegments : DEFAULT_SEGS); + this.onMove = (e) => { + const r = this.cv.getBoundingClientRect(); + this.cursorX = e.clientX - r.left; + this.paint(); + }; + this.onLeave = () => { this.cursorX = null; this.paint(); }; + canvas.addEventListener('mousemove', this.onMove); + canvas.addEventListener('mouseleave', this.onLeave); + } + + destroy() { + this.cv.removeEventListener('mousemove', this.onMove); + this.cv.removeEventListener('mouseleave', this.onLeave); + } + + render(data, meta) { + this.data = data; + this.meta = meta ?? data?.meta ?? null; + this.paint(); + } + + paint() { + const cv = this.cv; + const root = document.documentElement; + const pal = palette(root); + const fit = fitCanvas(cv); + if (!fit) return; + const { g, W, H } = fit; + g.clearRect(0, 0, W, H); + + const bars = this.data?.bars ?? []; + if (bars.length < 2) { + if (this.emptyEl) this.emptyEl.hidden = false; + return; + } + if (this.emptyEl) this.emptyEl.hidden = true; + + const ma5 = this.data.ma5 ?? []; + const ma10 = this.data.ma10 ?? []; + const ma20 = this.data.ma20 ?? []; + const dec = this.meta?.decimals ?? decimalsOf(this.meta?.unit); + + const PL = 8, PR = 56, PT = 10, PB = 20; + const memH = Math.max(30, H * 0.17); + const priceH = H - PT - memH - PB - 8; + const plotW = W - PL - PR; + if (plotW < 40 || priceH < 40) return; + + let lo = Infinity, hi = -Infinity, memPeak = 0; + for (const b of bars) { + if (b.l < lo) lo = b.l; + if (b.h > hi) hi = b.h; + const u = memUsed(b.mem); + if (u > memPeak) memPeak = u; + } + // 柱高按总内存归一化,柱子高度直接就是内存水位;拿不到总量时退回历史峰值 + const memTotal = Number(this.data.memTotalGiB) || 0; + const memCap = memTotal > 0 ? memTotal : memPeak; + for (const arr of [ma5, ma10, ma20]) { + for (const v of arr) { + if (v == null) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return; + if (hi === lo) { hi += 1; lo -= 1; } + const pad = (hi - lo) * 0.08; + lo -= pad; hi += pad; + + const Y = (v) => PT + priceH - ((v - lo) / (hi - lo)) * priceH; + const bw = plotW / bars.length; + const cw = Math.max(1.5, Math.min(10, bw * 0.66)); + + // 网格与价格轴 + g.strokeStyle = pal.line; + g.lineWidth = 1; + g.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'; + g.textAlign = 'left'; + g.textBaseline = 'middle'; + for (let i = 0; i <= 4; i += 1) { + const y = Math.round(PT + (priceH / 4) * i) + 0.5; + g.beginPath(); g.moveTo(PL, y); g.lineTo(PL + plotW, y); g.stroke(); + g.fillStyle = pal.mute; + g.fillText((hi - ((hi - lo) / 4) * i).toFixed(dec), PL + plotW + 7, y); + } + + const memTop = PT + priceH + 8; + g.beginPath(); + g.moveTo(PL, Math.round(memTop) + 0.5); + g.lineTo(PL + plotW, Math.round(memTop) + 0.5); + g.stroke(); + + // 蜡烛与事件带 + for (let i = 0; i < bars.length; i += 1) { + const b = bars[i]; + const cx = PL + bw * i + bw / 2; + const rise = b.c >= b.o; + const col = rise ? pal.up : pal.down; + g.strokeStyle = col; + g.fillStyle = col; + g.lineWidth = 1; + const px = Math.round(cx) + 0.5; + g.beginPath(); g.moveTo(px, Y(b.h)); g.lineTo(px, Y(b.l)); g.stroke(); + const yo = Y(b.o), yc = Y(b.c); + const top = Math.min(yo, yc); + const hgt = Math.max(1.1, Math.abs(yc - yo)); + g.fillRect(cx - cw / 2, top, cw, hgt); + // 内存构成:三段自下而上堆叠,与 K 线共用 x 轴。段名由服务端按平台下发 + if (memCap > 0 && b.mem) { + let base = memTop + memH; + g.globalAlpha = 0.82; + for (const [n, col] of [[b.mem.a, pal.memA], [b.mem.w, pal.memW], [b.mem.c, pal.memC]]) { + if (!n) continue; + const h = Math.min(memH, (n / memCap) * memH); + g.fillStyle = col; + g.fillRect(cx - cw / 2, base - h, cw, h); + base -= h; + } + g.globalAlpha = 1; + } + } + + // 副图标签与图例:柱子从底部往上长,左上角这一带通常是空的 + g.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace'; + g.textAlign = 'left'; + g.textBaseline = 'top'; + g.fillStyle = pal.mute; + g.fillText('内存构成', PL + 2, memTop + 3); + const lastMem = memUsed(bars[bars.length - 1]?.mem); + let lx = PL + 2 + g.measureText('内存构成').width + 9; + const segs = this.segs(); + const segCols = [pal.memA, pal.memW, pal.memC]; + for (let i = 0; i < segs.length; i += 1) { + const label = segs[i].short; + g.fillStyle = segCols[i]; + g.fillRect(lx, memTop + 5, 5, 5); + g.fillStyle = pal.mute; + g.fillText(label, lx + 7, memTop + 3); + lx += 7 + g.measureText(label).width + 8; + } + if (memCap > 0) { + g.textAlign = 'right'; + g.fillStyle = pal.dim; + const cap = memTotal > 0 ? ` / ${memTotal.toFixed(0)}G` : ''; + g.fillText(lastMem.toFixed(1) + 'G' + cap, PL + plotW - 2, memTop + 3); + g.textAlign = 'left'; + } + + // 均线 + const strokeMA = (arr, col) => { + g.strokeStyle = col; + g.lineWidth = 1.3; + g.lineJoin = 'round'; + g.beginPath(); + let started = false; + for (let i = 0; i < arr.length; i += 1) { + const v = arr[i]; + if (v == null) { started = false; continue; } + const x = PL + bw * i + bw / 2; + const y = Y(v); + if (started) g.lineTo(x, y); else { g.moveTo(x, y); started = true; } + } + g.stroke(); + }; + strokeMA(ma5, '#ffb020'); + strokeMA(ma10, '#4d9fff'); + strokeMA(ma20, '#b07cff'); + + // 最新价横线 + const last = bars[bars.length - 1]; + const ly = Y(last.c); + const lc = last.c >= last.o ? pal.up : pal.down; + g.save(); + g.setLineDash([3, 3]); + g.strokeStyle = lc; + g.lineWidth = 1; + g.beginPath(); g.moveTo(PL, Math.round(ly) + 0.5); g.lineTo(PL + plotW, Math.round(ly) + 0.5); g.stroke(); + g.restore(); + g.fillStyle = lc; + g.fillRect(PL + plotW + 3, ly - 8, PR - 6, 16); + g.fillStyle = '#fff'; + g.font = '700 10px ui-monospace, SFMono-Regular, Menlo, monospace'; + g.fillText(last.c.toFixed(dec), PL + plotW + 8, ly); + + // 时间轴 + g.fillStyle = pal.mute; + g.font = '9.5px ui-monospace, SFMono-Regular, Menlo, monospace'; + g.textBaseline = 'top'; + // 标签数跟着柱子数走:固定 4 个会在柱子少于 4 根时把同一个 idx 画两遍,文字错位叠在一起 + const ticks = Math.min(4, bars.length); + for (let i = 0; i < ticks; i += 1) { + const idx = ticks === 1 ? 0 : Math.round((bars.length - 1) * (i / (ticks - 1))); + const x = PL + bw * idx + bw / 2; + g.textAlign = i === 0 ? 'left' : i === ticks - 1 ? 'right' : 'center'; + g.fillText(fmtTime(bars[idx].t).slice(0, 5), Math.min(Math.max(x, PL), PL + plotW), memTop + memH + 5); + } + g.textAlign = 'left'; + g.textBaseline = 'alphabetic'; + + // 十字光标 + if (this.cursorX != null && this.cursorX > PL && this.cursorX < PL + plotW) { + let idx = Math.floor((this.cursorX - PL) / bw); + idx = Math.max(0, Math.min(bars.length - 1, idx)); + const b = bars[idx]; + const cx = PL + bw * idx + bw / 2; + g.save(); + g.setLineDash([2, 3]); + g.strokeStyle = pal.mute; + g.lineWidth = 1; + g.beginPath(); g.moveTo(Math.round(cx) + 0.5, PT); g.lineTo(Math.round(cx) + 0.5, memTop + memH); g.stroke(); + const cy = Y(b.c); + g.beginPath(); g.moveTo(PL, Math.round(cy) + 0.5); g.lineTo(PL + plotW, Math.round(cy) + 0.5); g.stroke(); + g.restore(); + this.drawTip(g, b, idx, cx, cy, pal, PL, PT, plotW, W, H, dec, ma5); + } + } + + drawTip(g, b, idx, cx, cy, pal, PL, PT, plotW, W, H, dec, ma5) { + const prev = idx > 0 ? this.barsPrev(idx) : b.o; + const chg = b.c - prev; + const rise = chg >= 0; + const rows = [ + ['时间', fmtTime(b.t), null], + ['开', b.o.toFixed(dec), null], + ['高', b.h.toFixed(dec), null], + ['低', b.l.toFixed(dec), null], + ['收', b.c.toFixed(dec), null], + ['涨跌', (chg >= 0 ? '+' : '') + chg.toFixed(dec), rise ? pal.up : pal.down], + ['MA5', ma5[idx] == null ? '—' : ma5[idx].toFixed(dec), null], + ['内存', memUsed(b.mem) > 0 ? memUsed(b.mem).toFixed(1) + 'G' : '—', null], + [this.segs().map((s) => s.short).join('/'), memText(b.mem), null], + ]; + const bw = 138; + const bh = rows.length * 15 + 12; + let bx = cx + 13; + if (bx + bw > PL + plotW) bx = cx - bw - 13; + bx = Math.max(PL + 2, Math.min(bx, W - bw - 2)); + const by = Math.max(PT + 2, Math.min(cy - 46, H - bh - 6)); + + g.fillStyle = pal.sunk; + g.globalAlpha = 0.97; + g.beginPath(); g.roundRect(bx, by, bw, bh, 7); g.fill(); + g.globalAlpha = 1; + g.strokeStyle = pal.line; + g.lineWidth = 1; + g.stroke(); + + g.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'; + g.textBaseline = 'middle'; + rows.forEach((r, k) => { + const y = by + 12 + k * 15; + g.fillStyle = pal.mute; + g.textAlign = 'left'; + g.fillText(r[0], bx + 9, y); + g.fillStyle = r[2] ?? pal.fg; + g.textAlign = 'right'; + g.fillText(r[1], bx + bw - 9, y); + }); + g.textAlign = 'left'; + g.textBaseline = 'alphabetic'; + } + + barsPrev(idx) { + const bars = this.data?.bars ?? []; + return idx > 0 ? bars[idx - 1].c : bars[0].o; + } +} + +/** 迷你走势线:指数条与进程行共用 */ +export function drawSpark(canvas, values, up) { + const root = document.documentElement; + const pal = palette(root); + const fit = fitCanvas(canvas); + if (!fit) return; + const { g, W, H } = fit; + g.clearRect(0, 0, W, H); + if (!values || values.length < 2) return; + + let lo = Infinity, hi = -Infinity; + for (const v of values) { + if (!Number.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (!Number.isFinite(lo)) return; + if (hi === lo) { hi += 1; lo -= 1; } + + const P = 2; + const col = up ? pal.up : pal.down; + const x = (i) => P + (W - P * 2) * (i / (values.length - 1)); + const y = (v) => P + (H - P * 2) * (1 - (v - lo) / (hi - lo)); + + g.strokeStyle = col; + g.lineWidth = 1.3; + g.lineJoin = 'round'; + g.beginPath(); + let started = false; + for (let i = 0; i < values.length; i += 1) { + const v = values[i]; + if (!Number.isFinite(v)) { started = false; continue; } + if (started) g.lineTo(x(i), y(v)); else { g.moveTo(x(i), y(v)); started = true; } + } + g.stroke(); + + if (started) { + g.lineTo(W - P, H); + g.lineTo(P, H); + g.closePath(); + g.globalAlpha = 0.13; + g.fillStyle = col; + g.fill(); + g.globalAlpha = 1; + } +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/client/index.html b/plugins/jzlikewei/kurumi-monitor/miniapp/client/index.html new file mode 100644 index 0000000..ece0af7 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/client/index.html @@ -0,0 +1,221 @@ + + + + + +本机大盘 · 实时行情 + + + + + +
+ +
+

本机大盘实时行情

+

连接中

+

+ +

+
+
+ + + + +
+

+
+ +
+

指数条

+ +
    +
    + +
    + +
    +
    +
    +

    我的自选

    + — +
    +
    +
    +
    +
    + +
    +
    +

    大盘行情

    + — +
    +
    +
    +
    +
    +
    + +
    +
    +
    + ------ +

    —

    + + +
    +
    + — + — + — +
    +
    +
    今开
    —
    +
    最高
    —
    +
    最低
    —
    +
    均价
    —
    +
    +
    + +
    +
    + +
    + +
    + + +
    +
    + +
    +
    +
    +

    主力动向

    + CPU 占用 +
    +
      +
      + +
      +
      +

      逐笔成交

      + — +
      +
        +
        +
        +
        + +
        +
        +

        个股列表 · 进程

        +
        + + + 未选中 + +
        +
        +
        + + + + + + + + + + + + + + + + +
        进程列表,可排序,点行查看该进程走势,点星标加自选,选中后可清仓
        自选走势
        + +
        +
        + +
        +

        采样 —

        +

        进程 —

        +

        历史 —

        +

        占用 —

        +

        当前用户 —

        + +
        +
        + + +
        +

        确认清仓

        +

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

        设置

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

        —

        +
        + +
        +
        +
        + + + + + + + + + diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/client/logo.png b/plugins/jzlikewei/kurumi-monitor/miniapp/client/logo.png new file mode 100644 index 0000000..e3f1a93 Binary files /dev/null and b/plugins/jzlikewei/kurumi-monitor/miniapp/client/logo.png differ diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/miniapp.json b/plugins/jzlikewei/kurumi-monitor/miniapp/miniapp.json new file mode 100644 index 0000000..d21848e --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/miniapp.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/board", + "window": { + "width": 1480, + "height": 940, + "minWidth": 1280, + "minHeight": 720, + "resizable": true, + "frame": true + } + }, + "mcpEndpoints": [] +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/candles.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/candles.mjs new file mode 100644 index 0000000..bb39e16 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/candles.mjs @@ -0,0 +1,193 @@ +// K 线聚合:采样点序列 -> OHLC + 内存构成 + 均线 +// 副图是内存构成:每根柱子按活动页 / 联动页 / 压缩页三段堆叠,与 K 线共用时间轴。 + +const DAY = 86400_000; + +export function todayStart(ts = Date.now()) { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** 内存构成三段 -> 指标代码。单位都是 GiB */ +const MEM_CODES = { a: '300002', w: '300003', c: '300006' }; + +/** 从一条 tick 的采样值里读内存三段,缺项按 0 */ +function readMem(v) { + if (!v) return null; + const a = v[MEM_CODES.a], w = v[MEM_CODES.w], c = v[MEM_CODES.c]; + if (a == null && w == null && c == null) return null; + return { a: a ?? 0, w: w ?? 0, c: c ?? 0 }; +} + +/** + * 副图内存构成按 K 线周期归桶。内存是全局量,进程品种的 series 里没有这些字段, + * 所以从主 tick 序列取,再按时间桶贴回 bars。 + * @param {Array<{t:number, v:Record}>} globalTicks + * @param {number} periodMs + * @returns {Map} 时间桶起点 -> 内存三段 + */ +function memByBucket(globalTicks, periodMs) { + const out = new Map(); + for (const tick of globalTicks ?? []) { + const m = readMem(tick.v); + if (m) out.set(Math.floor(tick.t / periodMs) * periodMs, m); + } + return out; +} + +/** + * 把 tick 序列聚合成 K 线。 + * @param {Array<{t:number, v:Record, w:number}>} ticks + * @param {string} code 指标代码 + * @param {number} periodSeconds K 线周期 + * @param {number} maxBars 最多返回多少根(取最新的) + * @param {Array<{t:number, v:Record}>} [globalTicks] 副图内存的来源,缺省用 ticks 本身 + */ +export function aggregate(ticks, code, periodSeconds, maxBars = 180, globalTicks = null) { + const periodMs = Math.max(1, periodSeconds) * 1000; + const buckets = new Map(); + + for (const tick of ticks) { + const val = tick.v?.[code]; + if (val == null || !Number.isFinite(val)) continue; + const key = Math.floor(tick.t / periodMs) * periodMs; + let b = buckets.get(key); + if (!b) { + b = { t: key, o: val, h: val, l: val, c: val, v: 0, n: 0, mem: null }; + buckets.set(key, b); + } + b.h = Math.max(b.h, val); + b.l = Math.min(b.l, val); + b.c = val; + b.v += tick.w ?? 0; + b.n += 1; + } + + const bars = [...buckets.values()].sort((a, b) => a.t - b.t).slice(-maxBars); + const memMap = memByBucket(globalTicks ?? ticks, periodMs); + for (const b of bars) b.mem = memMap.get(b.t) ?? null; + const closes = bars.map((b) => b.c); + + return { + bars, + ma5: movingAverage(closes, 5), + ma10: movingAverage(closes, 10), + ma20: movingAverage(closes, 20), + }; +} + +export function movingAverage(values, n) { + const out = new Array(values.length).fill(null); + let sum = 0; + for (let i = 0; i < values.length; i += 1) { + sum += values[i]; + if (i >= n) sum -= values[i - n]; + if (i >= n - 1) out[i] = sum / n; + } + return out; +} + +/** + * 品种头:现价 / 涨跌 / 涨跌幅 / 今开 / 最高 / 最低 / 均价 + * 「今开」取当日第一个有效采样点,跨进程重启保持连续。 + */ +export function quoteOf(ticks, code) { + const start = todayStart(); + let open = null, last = null, high = null, low = null, sum = 0, n = 0, volume = 0; + + for (const tick of ticks) { + const val = tick.v?.[code]; + if (val == null || !Number.isFinite(val)) continue; + if (tick.t >= start) { + if (open == null) open = val; + high = high == null ? val : Math.max(high, val); + low = low == null ? val : Math.min(low, val); + sum += val; + n += 1; + volume += tick.w ?? 0; + } + last = val; + } + + if (last == null) { + return { last: null, open: null, high: null, low: null, avg: null, delta: null, pct: null, volume: 0 }; + } + + const base = open ?? last; + const delta = last - base; + // 基准为 0 的指标(速率类常从 0 起)不展示涨跌幅,避免除零放大 + const pct = base === 0 ? null : (delta / Math.abs(base)) * 100; + + return { + last, + open: base, + high: high ?? last, + low: low ?? last, + avg: n > 0 ? sum / n : last, + delta, + pct, + volume, + }; +} + +/** + * 批量品种头:一次遍历算出所有指标的现价/今开/高低/均价/涨跌。 + * 自选列表有 40+ 项,逐个调用 quoteOf 会重复遍历整段历史。 + */ +export function quotesOf(ticks, codes) { + const start = todayStart(); + const acc = new Map(); + for (const c of codes) acc.set(c, { open: null, last: null, high: null, low: null, sum: 0, n: 0, volume: 0 }); + + for (const tick of ticks) { + const day = tick.t >= start; + const v = tick.v; + if (!v) continue; + for (const c in v) { + const a = acc.get(c); + if (!a) continue; + const val = v[c]; + if (val == null || !Number.isFinite(val)) continue; + a.last = val; + if (!day) continue; + if (a.open == null) a.open = val; + a.high = a.high == null ? val : Math.max(a.high, val); + a.low = a.low == null ? val : Math.min(a.low, val); + a.sum += val; + a.n += 1; + a.volume += tick.w ?? 0; + } + } + + const out = {}; + for (const [c, a] of acc) { + if (a.last == null) { + out[c] = { last: null, open: null, high: null, low: null, avg: null, delta: null, pct: null, volume: 0 }; + continue; + } + const base = a.open ?? a.last; + const delta = a.last - base; + out[c] = { + last: a.last, + open: base, + high: a.high ?? a.last, + low: a.low ?? a.last, + avg: a.n > 0 ? a.sum / a.n : a.last, + delta, + pct: base === 0 ? null : (delta / Math.abs(base)) * 100, + volume: a.volume, + }; + } + return out; +} + +/** 迷你走势:给指数条与进程行用 */ +export function sparkline(ticks, code, limit = 60) { + const out = []; + for (let i = Math.max(0, ticks.length - limit); i < ticks.length; i += 1) { + const v = ticks[i].v?.[code]; + if (v != null && Number.isFinite(v)) out.push(v); + } + return out; +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/metrics.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/metrics.mjs new file mode 100644 index 0000000..c4ef5b3 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/metrics.mjs @@ -0,0 +1,179 @@ +// 指标目录:交易所风格代码 -> 系统指标 +// 每项指标是一只「个股」,代码只用于界面显示与历史序列索引。 + +/** 板块定义(顺序即自选列表的分组顺序) */ +export const BOARDS = [ + { id: 'cpu', name: 'CPU 板块', prefix: '0000' }, + { id: 'gpu', name: 'GPU 板块', prefix: '6000' }, + { id: 'thermal', name: '温度 板块', prefix: '7000' }, + { id: 'mem', name: '内存 板块', prefix: '3000' }, + { id: 'disk', name: '磁盘 板块', prefix: '0020' }, + { id: 'net', name: '网络 板块', prefix: '0010' }, + { id: 'sys', name: '系统 板块', prefix: '9000' }, +]; + +/** + * 内存三段。两个平台的内存模型不同,各用自己的三段填同样的槽位: + * macOS 是活动页 / 联动页 / 压缩页,Windows 是使用中 / 已修改 / 待机。 + * 指标代码与历史序列跨平台保持稳定,只有显示名跟着平台走, + * 副图的柱形、图例与浮窗全部读这份定义。 + */ +export const MEM_SEGMENTS = process.platform === 'win32' + ? [ + { code: '300002', name: '使用中', short: '用' }, + { code: '300003', name: '已修改', short: '改' }, + { code: '300006', name: '待机', short: '待' }, + ] + : [ + { code: '300002', name: '活动页', short: '活' }, + { code: '300003', name: '联动页', short: '联' }, + { code: '300006', name: '压缩页', short: '压' }, + ]; + +/** 三段之外那一段的显示名。Windows 上没有可清除内存,这一项恒为空 */ +const MEM_EXTRA_NAME = process.platform === 'win32' ? '系统缓存' : '非活跃页'; + +/** 单位渲染规则 */ +const UNITS = { + pct: { d: 1, suffix: '%', ref: 'pct' }, + load: { d: 2, suffix: '', ref: 'raw' }, + gib: { d: 2, suffix: 'G', ref: 'raw' }, + mib: { d: 0, suffix: 'M', ref: 'raw' }, + mbs: { d: 2, suffix: 'M/s', ref: 'rate' }, + kbs: { d: 1, suffix: 'K/s', ref: 'rate' }, + ps: { d: 0, suffix: '/s', ref: 'rate' }, + cnt: { d: 0, suffix: '', ref: 'raw' }, + hrs: { d: 1, suffix: 'h', ref: 'raw' }, + c: { d: 1, suffix: '°C', ref: 'raw' }, + w: { d: 1, suffix: 'W', ref: 'raw' }, +}; + +export const unitOf = (u) => UNITS[u] ?? UNITS.cnt; + +/** 静态指标目录。逐核指标在运行时按核心数追加。 */ +export const METRICS = [ + { code: '000001', name: 'CPU 综合占用', board: 'cpu', unit: 'pct' }, + { code: '000002', name: '1 分负载', board: 'cpu', unit: 'load' }, + { code: '000003', name: '5 分负载', board: 'cpu', unit: 'load' }, + { code: '000004', name: '15 分负载', board: 'cpu', unit: 'load' }, + { code: '000005', name: '用户态占比', board: 'cpu', unit: 'pct' }, + { code: '000006', name: '系统态占比', board: 'cpu', unit: 'pct' }, + { code: '000007', name: '空闲占比', board: 'cpu', unit: 'pct' }, + + { code: '600001', name: '设备占用', board: 'gpu', unit: 'pct' }, + { code: '600002', name: 'Renderer 占用', board: 'gpu', unit: 'pct' }, + { code: '600003', name: 'Tiler 占用', board: 'gpu', unit: 'pct' }, + { code: '600004', name: '显存占用', board: 'gpu', unit: 'gib' }, + { code: '600005', name: '显存分配', board: 'gpu', unit: 'gib' }, + + // 温度与功耗只有 Windows 能采到:GPU 读数走 nvidia-smi,热区走 ACPI。 + // macOS 上这三项恒为空,页面显示「—」。 + { code: '700001', name: 'GPU 温度', board: 'thermal', unit: 'c' }, + { code: '700002', name: 'GPU 功耗', board: 'thermal', unit: 'w' }, + { code: '700003', name: '热区温度', board: 'thermal', unit: 'c' }, + + { code: '300001', name: '内存占用率', board: 'mem', unit: 'pct' }, + { code: '300002', name: MEM_SEGMENTS[0].name, board: 'mem', unit: 'gib' }, + { code: '300003', name: MEM_SEGMENTS[1].name, board: 'mem', unit: 'gib' }, + { code: '300004', name: '空闲页', board: 'mem', unit: 'gib' }, + { code: '300005', name: MEM_EXTRA_NAME, board: 'mem', unit: 'gib' }, + { code: '300006', name: MEM_SEGMENTS[2].name, board: 'mem', unit: 'gib' }, + { code: '300007', name: '可清除', board: 'mem', unit: 'mib' }, + + { code: '002001', name: '磁盘读', board: 'disk', unit: 'mbs' }, + { code: '002002', name: '磁盘写', board: 'disk', unit: 'mbs' }, + { code: '002003', name: '读操作', board: 'disk', unit: 'ps' }, + { code: '002004', name: '写操作', board: 'disk', unit: 'ps' }, + + { code: '001001', name: '网络下行', board: 'net', unit: 'kbs' }, + { code: '001002', name: '网络上行', board: 'net', unit: 'kbs' }, + { code: '001003', name: '下行包', board: 'net', unit: 'ps' }, + { code: '001004', name: '上行包', board: 'net', unit: 'ps' }, + + { code: '900001', name: '进程数', board: 'sys', unit: 'cnt' }, + { code: '900002', name: '线程数', board: 'sys', unit: 'cnt' }, + { code: '900003', name: '负载率', board: 'sys', unit: 'pct' }, + { code: '900004', name: '运行时间', board: 'sys', unit: 'hrs' }, +]; + +/** 逐核指标代码:核心 0 -> 000010,核心 9 -> 000019,超过 90 核用 000100+ */ +export function coreCode(i) { + return '0000' + String(10 + i).padStart(2, '0'); +} + +/** 运行时完整目录 = 静态目录 + 按核心数生成的逐核项 */ +export function buildCatalog(coreCount) { + const cores = []; + for (let i = 0; i < coreCount; i += 1) { + cores.push({ code: coreCode(i), name: '核心 ' + i, board: 'cpu', unit: 'pct', core: i }); + } + return [...METRICS, ...cores]; +} + +/** + * ps 的 etime 格式 [[dd-]hh:]mm:ss,返回秒数。 + * 拿不到就返回 null,调用方自己决定回退。 + */ +export function parseEtime(s) { + const m = String(s ?? '').trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/); + if (!m) return null; + return Number(m[1] ?? 0) * 86400 + Number(m[2] ?? 0) * 3600 + Number(m[3]) * 60 + Number(m[4]); +} + +/** + * 进程品种代码。pid 会被系统回收复用,只用 pid 会把先后两个进程的历史混成一条线, + * 所以带上由 etime 反推的启动时刻。 + */ +export function procCode(pid, startedAtSec) { + return 'P' + pid + '-' + startedAtSec; +} + +/** 由一条 ps 记录构造目录项,字段与静态指标对齐,前端可以当成普通品种渲染。 + * startedAtSec 由调用方缓存后传入 —— etime 只有秒精度,每次现算会漂。 */ +export function procMetaOf(proc, startedAtSec) { + return { + code: procCode(proc.pid, startedAtSec), + pid: proc.pid, + startedAt: startedAtSec, + name: proc.name, + user: proc.user, + board: 'proc', + unit: 'pct', + }; +} + +/** 自选进程上限。每多一个自选,每条 tick 就多一个字段,这个上限是拿来兜住存储增长的 */ +export const WATCH_MAX = 20; + +/** 指数条固定展示的 8 项(与 PerfTicker 的指数条对应) */ +export const INDEX_CODES = ['000001', '000002', '600001', '600004', '300001', '600002', '001001', '900001']; + +/** 主图默认选中品种 */ +export const DEFAULT_CODE = '600001'; + +/** K 线周期档位(秒)。周期不低于采样间隔的 2 倍,避免退化成一条线。 */ +export const PERIODS = [ + { label: '2秒K', seconds: 2 }, + { label: '5秒K', seconds: 5 }, + { label: '10秒K', seconds: 10 }, + { label: '30秒K', seconds: 30 }, + { label: '1分K', seconds: 60 }, +]; + +/** 采样档位(毫秒) */ +export const SAMPLE_TIERS = [500, 1000, 2000, 5000, 10000]; + +/** 按单位格式化数值 */ +export function formatValue(v, unit) { + if (v == null || !Number.isFinite(v)) return '—'; + const u = unitOf(unit); + return v.toFixed(u.d) + u.suffix; +} + +/** 格式化涨跌:始终带符号,不依赖颜色传达方向 */ +export function formatDelta(v, unit) { + if (v == null || !Number.isFinite(v)) return '—'; + const u = unitOf(unit); + const sign = v > 0 ? '+' : v < 0 ? '-' : ''; + return sign + Math.abs(v).toFixed(u.d) + u.suffix; +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/collector.ps1 b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/collector.ps1 new file mode 100644 index 0000000..da6c1bd --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/collector.ps1 @@ -0,0 +1,266 @@ +# Windows resident collector for the system board. +# +# Launched by the Node side with -File. Emits one JSON line per round on stdout +# and is reaped when the parent exits. Every counter object is built once at +# startup, then each round only reads RawValue / NextValue: Get-Counter +# enumeration costs 1 to 4 seconds per call, a prebuilt counter reads in microseconds. +# Unavailable counters report -1, which the Node side maps to null. + +param([int]$IntervalMs = 1000) + +$ErrorActionPreference = 'SilentlyContinue' +$ProgressPreference = 'SilentlyContinue' + +function New-Ctr([string]$Category, [string]$Counter, [string]$Instance) { + try { + $c = New-Object System.Diagnostics.PerformanceCounter($Category, $Counter) + if ($Instance) { $c.InstanceName = $Instance } + $null = $c.NextValue() + return $c + } catch { + return $null + } +} + +# RawValue yields cumulative totals, NextValue yields instantaneous values. +function Get-Raw($c) { if ($null -eq $c) { return -1 } try { return [double]$c.RawValue } catch { return -1 } } +function Get-Nxt($c) { if ($null -eq $c) { return -1 } try { return [double]$c.NextValue() } catch { return -1 } } + +function Get-JsonString([string]$s) { + if ([string]::IsNullOrEmpty($s)) { return '' } + $s = $s.Replace('\', '\\\\').Replace('"', '\\"') + $s = $s.Replace([string][char]13, '').Replace([string][char]10, '').Replace([string][char]9, ' ') + return $s +} + +# ---------------------------------------------------------------- startup + +# Memory: the Memory category is single-instance, so all seven counters build directly +$cAvail = New-Ctr 'Memory' 'Available Bytes' $null +$cModified = New-Ctr 'Memory' 'Modified Page List Bytes' $null +$cFreeZero = New-Ctr 'Memory' 'Free & Zero Page List Bytes' $null +$cSbCore = New-Ctr 'Memory' 'Standby Cache Core Bytes' $null +$cSbNorm = New-Ctr 'Memory' 'Standby Cache Normal Priority Bytes' $null +$cSbRes = New-Ctr 'Memory' 'Standby Cache Reserve Bytes' $null +$cCache = New-Ctr 'Memory' 'Cache Bytes' $null + +# Load: Windows has no load average, processor queue length is the closest stand-in +$cQueue = New-Ctr 'System' 'Processor Queue Length' $null + +# Disk: the _Total instance RawValue is cumulative bytes and operations since boot +$cDiskRead = New-Ctr 'PhysicalDisk' 'Disk Read Bytes/sec' '_Total' +$cDiskWrite = New-Ctr 'PhysicalDisk' 'Disk Write Bytes/sec' '_Total' +$cDiskOpR = New-Ctr 'PhysicalDisk' 'Disk Reads/sec' '_Total' +$cDiskOpW = New-Ctr 'PhysicalDisk' 'Disk Writes/sec' '_Total' + +# Network: instance names are adapter descriptions, so pick the busiest one at +# startup and keep it for the rest of the run. +$netName = $null +$cNetIn = $null +$cNetOut = $null +$cNetPktIn = $null +$cNetPktOut = $null +try { + $cat = New-Object System.Diagnostics.PerformanceCounterCategory('Network Interface') + $best = -1 + foreach ($inst in $cat.GetInstanceNames()) { + $probe = New-Object System.Diagnostics.PerformanceCounter('Network Interface', 'Bytes Total/sec', $inst) + $v = [double]$probe.RawValue + if ($v -gt $best) { $best = $v; $netName = $inst } + } + if ($netName) { + $cNetIn = New-Ctr 'Network Interface' 'Bytes Received/sec' $netName + $cNetOut = New-Ctr 'Network Interface' 'Bytes Sent/sec' $netName + $cNetPktIn = New-Ctr 'Network Interface' 'Packets Received/sec' $netName + $cNetPktOut = New-Ctr 'Network Interface' 'Packets Sent/sec' $netName + } +} catch { $netName = $null } + +$totalMem = 0 +try { $totalMem = [double](Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory } catch { $totalMem = 0 } + +# ---------------------------------------------------------------- GPU sampling + +# GPU Engine instance names carry pid and LUID and come and go as processes start +# and stop, so counter objects cannot be prebuilt and the whole class must be +# queried. That costs an order of magnitude more than the rest, so refresh every +# 5 rounds and reuse the cached values in between, keeping every tick populated. +$script:gpuCache = @{ device = -1; render = -1; tiler = -1; vramUsed = -1; vramAlloc = -1; temp = -1; power = -1; zone = -1 } + +# nvidia-smi lives next to the driver and is not always on PATH +$script:nvSmi = $null +foreach ($cand in @('nvidia-smi', "$env:SystemRoot\System32\nvidia-smi.exe", "$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe")) { + if ($script:nvSmi) { break } + try { + $found = Get-Command $cand -ErrorAction SilentlyContinue + if ($found) { $script:nvSmi = $found.Source } + } catch { } +} + +function Update-Gpu { + $r = @{ device = 0.0; render = 0.0; tiler = 0.0; vramUsed = 0.0; vramAlloc = 0.0; temp = -1; power = -1; zone = -1 } + $ok = $false + try { + $engines = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine + foreach ($e in $engines) { + $u = [double]$e.UtilizationPercentage + if ($u -le 0) { continue } + $r.device += $u + # Instance names look like pid_1234_luid_0x00_0x00_phys_0_eng_0_engtype_3D + if ($e.Name -match '_engtype_(.+)$') { + switch ($Matches[1]) { + '3D' { $r.render += $u } + 'Copy' { $r.tiler += $u } + 'VideoDecode' { $r.tiler += $u } + 'VideoEncode' { $r.tiler += $u } + } + } + } + $ok = $true + } catch { } + + try { + $mem = Get-CimInstance -ClassName Win32_PerfFormattedData_GPUPerformanceCounters_GPUAdapterMemory + foreach ($m in $mem) { + $r.vramUsed += [double]$m.DedicatedUsage + $r.vramAlloc += [double]$m.DedicatedUsage + [double]$m.SharedUsage + } + $ok = $true + } catch { } + + # GPU temperature and power come from the NVIDIA driver. Cards that do not + # report power answer [N/A], which fails the cast and leaves the field at -1. + if ($script:nvSmi) { + try { + $line = & $script:nvSmi --query-gpu=temperature.gpu,power.draw --format=csv,noheader,nounits 2>$null | Select-Object -First 1 + if ($line) { + $parts = $line -split ',' + try { $r.temp = [double]$parts[0].Trim() } catch { } + if ($parts.Count -gt 1) { try { $r.power = [double]$parts[1].Trim() } catch { } } + $ok = $true + } + } catch { } + } + + # ACPI thermal zone. CurrentTemperature is in tenths of a kelvin. + # Many machines expose no zone at all, which leaves the field at -1. + try { + $tz = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue + if ($tz) { + $max = ($tz | Measure-Object -Property CurrentTemperature -Maximum).Maximum + if ($max -gt 0) { $r.zone = $max / 10 - 273.15 } + } + } catch { } + + if ($ok) { + $r.device = [Math]::Min(100.0, $r.device) + $r.render = [Math]::Min(100.0, $r.render) + $r.tiler = [Math]::Min(100.0, $r.tiler) + $script:gpuCache = $r + } +} + +# ---------------------------------------------------------- parent process ids + +# Get-Process in Windows PowerShell 5.1 exposes no Parent property, so the parent +# has to come from the CIM process table. That query walks every process object and +# costs far more than the rest of a round, so refresh it every 5 rounds and reuse +# the map in between, the same way the GPU block above does. +$script:ppidMap = @{} + +function Update-Ppid { + $map = @{} + try { + foreach ($w in Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue) { + $map[[int]$w.ProcessId] = [int]$w.ParentProcessId + } + } catch { } + if ($map.Count -gt 0) { $script:ppidMap = $map } +} + +# ---------------------------------------------------------------- main loop + +$round = 0 +$bootMs = 0 +try { $bootMs = [DateTimeOffset]::new((Get-CimInstance Win32_OperatingSystem).LastBootUpTime).ToUnixTimeMilliseconds() } catch { $bootMs = 0 } + +while ($true) { + $t = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + + if (($round % 5) -eq 0) { Update-Gpu } + # Offset from the GPU refresh so the two heavy CIM queries never land on the same + # round and stretch a single tick. + if (($round % 5) -eq 2) { Update-Ppid } + + # ---- Process table. TotalProcessorTime is cumulative seconds since boot, the + # Node side diffs it into a utilisation rate. + $sb = New-Object System.Text.StringBuilder + $threadTotal = 0 + $procCount = 0 + $procs = Get-Process -ErrorAction SilentlyContinue + foreach ($p in $procs) { + $threads = 0 + try { $threads = $p.Threads.Count } catch { $threads = 0 } + $threadTotal += $threads + $cpu = 0 + try { $cpu = [double]$p.TotalProcessorTime.TotalSeconds } catch { $cpu = 0 } + $rss = 0 + try { $rss = [double]$p.WorkingSet64 } catch { $rss = 0 } + $start = 0 + try { $start = [DateTimeOffset]::new($p.StartTime).ToUnixTimeMilliseconds() } catch { $start = 0 } + $user = '' + try { $user = $p.UserName } catch { $user = '' } + $ppid = 0 + if ($script:ppidMap.ContainsKey([int]$p.Id)) { $ppid = $script:ppidMap[[int]$p.Id] } + if ($procCount -gt 0) { [void]$sb.Append(',') } + [void]$sb.Append('{"pid":').Append($p.Id) + [void]$sb.Append(',"ppid":').Append($ppid) + [void]$sb.Append(',"user":"').Append((Get-JsonString $user)).Append('"') + [void]$sb.Append(',"cpu":').Append($cpu.ToString('R', [Globalization.CultureInfo]::InvariantCulture)) + [void]$sb.Append(',"rss":').Append($rss.ToString('R', [Globalization.CultureInfo]::InvariantCulture)) + [void]$sb.Append(',"th":').Append($threads) + [void]$sb.Append(',"st":').Append($start) + [void]$sb.Append(',"name":"').Append((Get-JsonString $p.ProcessName)).Append('"}') + $procCount += 1 + } + + # ---- Memory + $memJson = '"total":' + $totalMem + + ',"avail":' + (Get-Raw $cAvail) + + ',"mod":' + (Get-Raw $cModified) + + ',"free":' + (Get-Raw $cFreeZero) + + ',"sbCore":' + (Get-Raw $cSbCore) + + ',"sbNorm":' + (Get-Raw $cSbNorm) + + ',"sbRes":' + (Get-Raw $cSbRes) + + ',"cache":' + (Get-Raw $cCache) + + $g = $script:gpuCache + $gpuJson = '"device":' + $g.device + ',"render":' + $g.render + ',"tiler":' + $g.tiler + + ',"vramUsed":' + $g.vramUsed + ',"vramAlloc":' + $g.vramAlloc + + ',"temp":' + $g.temp + ',"power":' + $g.power + ',"zone":' + $g.zone + + $diskJson = '"read":' + (Get-Raw $cDiskRead) + ',"write":' + (Get-Raw $cDiskWrite) + + ',"opR":' + (Get-Raw $cDiskOpR) + ',"opW":' + (Get-Raw $cDiskOpW) + + $netJson = '"name":"' + (Get-JsonString $netName) + '"' + + ',"ib":' + (Get-Raw $cNetIn) + ',"ob":' + (Get-Raw $cNetOut) + + ',"ip":' + (Get-Raw $cNetPktIn) + ',"op":' + (Get-Raw $cNetPktOut) + + $line = '{"t":' + $t + + ',"boot":' + $bootMs + + ',"queue":' + (Get-Nxt $cQueue) + + ',"mem":{' + $memJson + '}' + + ',"gpu":{' + $gpuJson + '}' + + ',"disk":{' + $diskJson + '}' + + ',"net":{' + $netJson + '}' + + ',"threads":' + $threadTotal + + ',"procs":[' + $sb.ToString() + ']}' + + [Console]::Out.WriteLine($line) + [Console]::Out.Flush() + + $round += 1 + $spent = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $t + $rest = $IntervalMs - $spent + if ($rest -gt 0) { Start-Sleep -Milliseconds $rest } +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/darwin.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/darwin.mjs new file mode 100644 index 0000000..15ccd3a --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/darwin.mjs @@ -0,0 +1,170 @@ +// macOS 采集实现。内存分段走 vm_stat,GPU 与磁盘走 ioreg,网络走 netstat,进程走 ps。 + +import os from 'node:os'; +import { num, run } from './util.mjs'; + +export const loadAvg = () => os.loadavg(); + +// ---------------------------------------------------------------- 内存 + +export async function sampleMem() { + const totalBytes = os.totalmem(); + const out = await run('vm_stat', []); + if (!out) return { totalGiB: totalBytes / 2 ** 30, pct: null }; + + const pageSize = num(/page size of (\d+) bytes/.exec(out)?.[1]) ?? 4096; + const page = (label) => num(new RegExp(label + ':\\s+(\\d+)').exec(out)?.[1]); + + const toGiB = (pages) => (pages == null ? null : (pages * pageSize) / 2 ** 30); + + const free = page('Pages free'); + const active = page('Pages active'); + const inactive = page('Pages inactive'); + const wired = page('Pages wired down'); + const purgeable = page('Pages purgeable'); + const compressed = page('Pages occupied by compressor'); + + // 活动监视器口径:活动 + 联动 + 压缩,扣除可清除部分 + const usedPages = + active == null || wired == null + ? null + : active + wired + (compressed ?? 0) - (purgeable ?? 0); + + return { + totalGiB: totalBytes / 2 ** 30, + pct: usedPages == null ? null : (usedPages * pageSize * 100) / totalBytes, + // 三段槽位依次对应 300002 / 300003 / 300006,macOS 填活动页、联动页、压缩页 + segments: [toGiB(active), toGiB(wired), toGiB(compressed)], + free: toGiB(free), + extra: toGiB(inactive), + purgeableMiB: purgeable == null ? null : (purgeable * pageSize) / 2 ** 20, + }; +} + +// ---------------------------------------------------------------- GPU + +export async function sampleGpu() { + const out = await run('ioreg', ['-r', '-d', '1', '-w', '0', '-c', 'AGXAccelerator']); + if (!out) return null; + // 只取 PerformanceStatistics,避开体积巨大的 IOReportLegend + const m = /"PerformanceStatistics"\s*=\s*\{([^}]*)\}/.exec(out); + if (!m) return null; + const body = m[1]; + const pick = (key) => num(new RegExp('"' + key + '"=(\\d+)').exec(body)?.[1]); + + const device = pick('Device Utilization %'); + const renderer = pick('Renderer Utilization %'); + const tiler = pick('Tiler Utilization %'); + const inUse = pick('In use system memory'); + const alloc = pick('Alloc system memory'); + if (device == null && inUse == null) return null; + + return { + device, + renderer, + tiler, + vramUsedGiB: inUse == null ? null : inUse / 2 ** 30, + vramAllocGiB: alloc == null ? null : alloc / 2 ** 30, + }; +} + +// ---------------------------------------------------------------- 温度 + +// Apple Silicon 的 GPU 温度与功耗要走 IOReport,读取需要特权 helper; +// CPU 温度要从 SMC 键读,Node 里没有原生模块也拿不到。 +// 这条路径上三项恒为空,页面显示「—」。 +export async function sampleThermal() { + return { gpuTempC: null, gpuPowerW: null, zoneTempC: null }; +} + +// ---------------------------------------------------------------- 磁盘 + +export async function sampleDiskRaw() { + const out = await run('ioreg', ['-c', 'IOBlockStorageDriver', '-r', '-w', '0']); + if (!out) return null; + const grab = (key) => { + const re = new RegExp('"' + key + '"=(\\d+)', 'g'); + let sum = 0, hit = false, mm; + while ((mm = re.exec(out)) !== null) { sum += Number(mm[1]); hit = true; } + return hit ? sum : null; + }; + const read = grab('Bytes \\(Read\\)') ?? 0; + const write = grab('Bytes \\(Write\\)') ?? 0; + const opR = grab('Operations \\(Read\\)') ?? 0; + const opW = grab('Operations \\(Write\\)') ?? 0; + if (read <= 0 && write <= 0) return null; + return { read, write, opR, opW }; +} + +// ---------------------------------------------------------------- 网络 + +export async function sampleNetRaw() { + const out = await run('netstat', ['-ibn']); + if (!out) return null; + const lines = out.split('\n'); + let best = null, bestBytes = -1; + for (const line of lines) { + // 只统计 行,跳过每个接口的地址重复行 + if (!/\ bestBytes) { bestBytes = total; best = { name, ib, ob, ip: num(f[4]), op: num(f[7]) }; } + } + return best; +} + +// ---------------------------------------------------------------- 进程 + +/** 取路径的最后一段作为进程名 */ +function basename(p) { + const clean = String(p).replace(/\s*\(.*\)\s*$/, ''); + const seg = clean.split('/'); + return seg[seg.length - 1] || clean; +} + +export async function sampleProcs(cap) { + const out = await run('ps', ['-Ao', 'pid,ppid,user,pcpu,pmem,rss,state,etime,comm', '-r']); + if (!out) return []; + const lines = out.split('\n').slice(1); + const rows = []; + for (const line of lines) { + const f = line.trim().split(/\s+/); + if (f.length < 9) continue; + const [pid, ppid, user, pcpu, pmem, rss, state, etime, ...rest] = f; + const command = rest.join(' '); + rows.push({ + pid: num(pid), + ppid: num(ppid), + user, + cpu: num(pcpu) ?? 0, + mem: num(pmem) ?? 0, + rss: num(rss) ?? 0, + state, + etime, + // 进程在 ps 遍历期间就退出时,COMM 会是空串 + name: basename(command) || '未命名进程', + command, + }); + if (rows.length >= cap) break; + } + return rows; +} + +// ---------------------------------------------------------------- 线程 + +// kern.num_tasks 与 kern.num_taskthreads 在 macOS 上是静态上限:实测两键同为 6144, +// 与 kern.maxproc(6000)、kern.maxprocperuid(4000) 均不同,且永不变化,涨跌恒为 0。 +// 实时线程数只能靠 ps -M —— 它给每个线程输出一行,整表行数减去进程数即线程总数。 +export async function sampleThreadRows() { + const out = await run('ps', ['-axM']); + if (!out) return null; + const rows = out.split('\n').length - 1; + return rows > 0 ? rows : null; +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/index.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/index.mjs new file mode 100644 index 0000000..3f3aee0 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/index.mjs @@ -0,0 +1,72 @@ +// 采集层入口:按平台分发实现,对外保持一套函数签名与返回形状。 +// +// CPU 采样、核心数、运行时间三样直接走 Node 内置 API,跨平台共用; +// 内存分段、GPU、磁盘、网络、进程、线程交给平台模块。 +// 拿不到的指标返回 null,前端渲染成「—」。 + +import os from 'node:os'; +import * as darwin from './darwin.mjs'; +import * as win32 from './win32.mjs'; + +/** 平台实现。win32 之外一律走 macOS 实现,Linux 上命令会失败并逐项返回 null。 */ +const impl = process.platform === 'win32' ? win32 : darwin; + +/** 进程表行数上限。要高于真实进程数,否则 CPU 排序边界每秒抖动,会被逐笔成交误判成大批进程启停。 */ +export const PROC_ROW_CAP = 2000; + +let cpuPrev = null; + +/** + * CPU 占用。os.cpus() 的 times 是自开机累计值,取两次差分才是这段时间的占用率。 + * @returns {{total:number, user:number, sys:number, idle:number, cores:number[], work:number}} + */ +export function sampleCpu() { + const list = os.cpus(); + const now = list.map((c) => ({ ...c.times })); + const prev = cpuPrev; + cpuPrev = now; + + const cores = []; + let dUser = 0, dNice = 0, dSys = 0, dIdle = 0; + + for (let i = 0; i < now.length; i += 1) { + const p = prev?.[i]; + if (!p) { cores.push(null); continue; } + const du = Math.max(0, now[i].user - p.user); + const dn = Math.max(0, now[i].nice - p.nice); + const ds = Math.max(0, now[i].sys - p.sys); + const di = Math.max(0, now[i].idle - p.idle); + const tot = du + dn + ds + di; + if (tot <= 0) { cores.push(null); continue; } + cores.push((100 * (tot - di)) / tot); + dUser += du; dNice += dn; dSys += ds; dIdle += di; + } + + const tot = dUser + dNice + dSys + dIdle; + if (!prev || tot <= 0) { + return { total: null, user: null, sys: null, idle: null, cores: now.map(() => null), work: 0 }; + } + return { + total: (100 * (tot - dIdle)) / tot, + user: (100 * (dUser + dNice)) / tot, + sys: (100 * dSys) / tot, + idle: (100 * dIdle) / tot, + cores, + work: (dUser + dNice + dSys) / 1000, + }; +} + +export const coreCount = () => os.cpus().length; +export const uptimeHours = () => os.uptime() / 3600; + +export const loadAvg = () => impl.loadAvg(); +export const sampleMem = () => impl.sampleMem(); +export const sampleGpu = () => impl.sampleGpu(); +export const sampleThermal = () => impl.sampleThermal(); +export const sampleDiskRaw = () => impl.sampleDiskRaw(); +export const sampleNetRaw = () => impl.sampleNetRaw(); +export const sampleThreadRows = () => impl.sampleThreadRows(); +export const sampleProcs = (intervalMs) => impl.sampleProcs(PROC_ROW_CAP, intervalMs); + +/** 释放平台资源。Windows 的常驻采集进程在这里回收,必须在 2 秒内返回。 */ +export const disposeProbes = () => impl.dispose?.() ?? Promise.resolve(); diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/util.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/util.mjs new file mode 100644 index 0000000..d705565 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/util.mjs @@ -0,0 +1,25 @@ +// 采集层共用工具。只读查询命令走 execFile + 参数数组,不拼接 shell。 + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const pexec = promisify(execFile); + +export const CMD_TIMEOUT = 4000; +export const CMD_MAXBUF = 8 * 1024 * 1024; + +/** 执行一条只读查询命令,失败返回 null 而不是抛错 */ +export async function run(cmd, args) { + try { + const { stdout } = await pexec(cmd, args, { timeout: CMD_TIMEOUT, maxBuffer: CMD_MAXBUF }); + return stdout; + } catch { + return null; + } +} + +/** 从任意文本里抠出第一个数字,抠不出返回 null */ +export const num = (v) => { + const n = Number(String(v ?? '').replace(/[^0-9.eE+-]/g, '')); + return Number.isFinite(n) ? n : null; +}; diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/win32.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/win32.mjs new file mode 100644 index 0000000..e2760ce --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/probes/win32.mjs @@ -0,0 +1,254 @@ +// Windows 采集实现。 +// +// 采集本身跑在一个常驻 PowerShell 进程里:脚本启动时预建全部计数器对象, +// 之后每轮只读 RawValue / NextValue 并输出一行 JSON。 +// 这样绕开了 Get-Counter 每次枚举 1 到 4 秒的开销, +// 也避免了每秒拉起六次 powershell.exe 把被测机器的 CPU 吃掉。 + +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT = fileURLToPath(new URL('./collector.ps1', import.meta.url)); + +/** 第一帧最多等这么久。拿不到就降级成全 null,让界面显示「—」而不是卡住 */ +const FIRST_FRAME_TIMEOUT = 15_000; +/** 采集进程意外退出后的重启次数上限 */ +const MAX_RESTARTS = 5; + +let child = null; +let buffer = ''; +let latest = null; +let ready = null; +let restarts = 0; +let disposed = false; +let intervalMs = 1000; + +// 进程 CPU 是开机以来的累计秒,靠两次采样差分换算成占用率 +let prevCpu = new Map(); +// 处理器队列长度是瞬时值,1 分取原值,5 分与 15 分走指数平滑 +let load5 = 0; +let load15 = 0; +let loadAt = 0; + +function parseLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function launch() { + return new Promise((resolve) => { + let settled = false; + const done = () => { if (!settled) { settled = true; resolve(); } }; + + const ps = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', SCRIPT, '-IntervalMs', String(intervalMs)], + { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + child = ps; + + const timer = setTimeout(done, FIRST_FRAME_TIMEOUT); + + ps.stdout.setEncoding('utf8'); + ps.stdout.on('data', (chunk) => { + buffer += chunk; + let nl = buffer.indexOf('\n'); + while (nl >= 0) { + const frame = parseLine(buffer.slice(0, nl)); + buffer = buffer.slice(nl + 1); + if (frame) { + latest = frame; + clearTimeout(timer); + done(); + } + nl = buffer.indexOf('\n'); + } + // 采集进程崩了却没换行,缓冲会无限涨 + if (buffer.length > 4 * 1024 * 1024) buffer = ''; + }); + + ps.on('error', () => { clearTimeout(timer); child = null; done(); }); + ps.on('exit', () => { + clearTimeout(timer); + child = null; + done(); + if (!disposed && restarts < MAX_RESTARTS) { + restarts += 1; + ready = launch(); + } + }); + }); +} + +/** 保证采集进程在跑。采样间隔变了要重启,脚本的 param 只在启动时读一次。 */ +function ensure(ms) { + if (ms && ms !== intervalMs) { + intervalMs = ms; + stopChild(); + ready = null; + restarts = 0; + } + if (!ready) ready = launch(); + return ready; +} + +function stopChild() { + if (!child) return; + const ps = child; + child = null; + try { ps.kill(); } catch { /* 已经退出 */ } +} + +export async function dispose() { + disposed = true; + stopChild(); + latest = null; + ready = null; + prevCpu.clear(); +} + +/** 把 [[dd-]hh:]mm:ss 交给 metrics.parseEtime 解析,两个平台的格式保持一致 */ +function etimeOf(startedMs, nowMs) { + if (!startedMs) return ''; + const total = Math.max(0, Math.floor((nowMs - startedMs) / 1000)); + const d = Math.floor(total / 86400); + const h = Math.floor((total % 86400) / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n) => String(n).padStart(2, '0'); + if (d > 0) return d + '-' + pad(h) + ':' + pad(m) + ':' + pad(s); + if (h > 0) return pad(h) + ':' + pad(m) + ':' + pad(s); + return pad(m) + ':' + pad(s); +} + +// ---------------------------------------------------------------- 各指标 + +const GiB = 2 ** 30; +const ok = (v) => (typeof v === 'number' && v >= 0 ? v : null); + +export const loadAvg = () => { + const frame = latest; + if (!frame) return [0, 0, 0]; + const q = ok(frame.queue) ?? 0; + const now = frame.t; + if (loadAt && now > loadAt) { + const dt = now - loadAt; + load5 += (1 - Math.exp(-dt / 300_000)) * (q - load5); + load15 += (1 - Math.exp(-dt / 900_000)) * (q - load15); + } else { + load5 = q; + load15 = q; + } + loadAt = now; + return [q, load5, load15]; +}; + +export async function sampleMem() { + await ensure(); + const m = latest?.mem; + const totalBytes = ok(m?.total) ?? os.totalmem(); + const totalGiB = totalBytes / GiB; + const avail = ok(m?.avail); + const standby = [m?.sbCore, m?.sbNorm, m?.sbRes].every((v) => ok(v) != null) + ? (m.sbCore + m.sbNorm + m.sbRes) / GiB + : null; + // 任务管理器口径:使用中 = 总量 − 可用;可用 = 空闲 + 待机 + const inUse = avail == null ? null : (totalBytes - avail) / GiB; + return { + totalGiB, + pct: avail == null ? null : ((totalBytes - avail) * 100) / totalBytes, + segments: [inUse, ok(m?.mod) == null ? null : m.mod / GiB, standby], + free: ok(m?.free) == null ? null : m.free / GiB, + extra: ok(m?.cache) == null ? null : m.cache / GiB, + purgeableMiB: null, + }; +} + +export async function sampleGpu() { + await ensure(); + const g = latest?.gpu; + if (!g || ok(g.device) == null) return null; + return { + device: g.device, + renderer: ok(g.render), + tiler: ok(g.tiler), + vramUsedGiB: ok(g.vramUsed) == null ? null : g.vramUsed / GiB, + vramAllocGiB: ok(g.vramAlloc) == null ? null : g.vramAlloc / GiB, + }; +} + +/** + * 温度与功耗。GPU 读数来自 NVIDIA 驱动,热区来自 ACPI, + * 两者都只在每 5 轮的整类查询里刷新,中间几轮沿用缓存值。 + * 没有 N 卡或机器不暴露热区时,对应字段为 null。 + */ +export async function sampleThermal() { + await ensure(); + const g = latest?.gpu; + return { gpuTempC: ok(g?.temp), gpuPowerW: ok(g?.power), zoneTempC: ok(g?.zone) }; +} + +export async function sampleDiskRaw() { + await ensure(); + const d = latest?.disk; + if (!d || ok(d.read) == null) return null; + return { read: d.read, write: ok(d.write) ?? 0, opR: ok(d.opR) ?? 0, opW: ok(d.opW) ?? 0 }; +} + +export async function sampleNetRaw() { + await ensure(); + const n = latest?.net; + if (!n || ok(n.ib) == null) return null; + return { name: n.name || '网卡', ib: n.ib, ob: ok(n.ob) ?? 0, ip: ok(n.ip) ?? 0, op: ok(n.op) ?? 0 }; +} + +export async function sampleThreadRows() { + await ensure(); + return ok(latest?.threads); +} + +export async function sampleProcs(cap, ms) { + await ensure(ms); + const frame = latest; + if (!frame) return []; + + const now = frame.t; + const totalMem = ok(frame.mem?.total) ?? os.totalmem(); + const cores = os.cpus().length || 1; + const prev = prevCpu; + const next = new Map(); + const rows = []; + + for (const p of frame.procs) { + if (!p.pid || !p.name) continue; + next.set(p.pid, { cpu: p.cpu, t: now }); + const before = prev.get(p.pid); + // 累计 CPU 秒的差分除以时间差与核心数,得到与 ps pcpu 同口径的占用率 + const cpu = before && now > before.t + ? Math.max(0, ((p.cpu - before.cpu) * 1000) / (now - before.t) / cores * 100) + : 0; + rows.push({ + pid: p.pid, + ppid: ok(p.ppid) ?? 0, + user: '', + cpu, + mem: totalMem > 0 ? (p.rss * 100) / totalMem : 0, + rss: Math.round(p.rss / 1024), + state: '', + etime: etimeOf(p.st, now), + name: p.name, + command: p.name, + }); + if (rows.length >= cap) break; + } + + prevCpu = next; + return rows; +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/server.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/server.mjs new file mode 100644 index 0000000..513c4b7 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/server.mjs @@ -0,0 +1,719 @@ +// 本机大盘 · Node 运行时 +// 职责:采样循环、tick 持久化、HTTP 路由、SSE 广播、进程清仓校验 + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import { extname, join, normalize, sep } from 'node:path'; + +import { + coreCount, disposeProbes, loadAvg, sampleCpu, sampleDiskRaw, sampleGpu, sampleMem, + sampleNetRaw, sampleProcs, sampleThermal, sampleThreadRows, PROC_ROW_CAP, uptimeHours, +} from './probes/index.mjs'; +import { + buildCatalog, DEFAULT_CODE, INDEX_CODES, MEM_SEGMENTS, PERIODS, SAMPLE_TIERS, + parseEtime, procCode, procMetaOf, WATCH_MAX, +} from './metrics.mjs'; +import { Store } from './store.mjs'; +import { aggregate, quotesOf, sparkline, todayStart } from './candles.mjs'; + +const MAX_BARS = 180; +const MEM_TICK_WINDOW_MS = 4 * 3600_000; // 内存里保留 4 小时采样点 +const PROC_HISTORY = 600; // 尾部进程保留的采样点数 +const SSE_PROC_TOP = 50; // SSE 每次推送的进程行数 +// 进程表按 CPU 排序,用户翻表时先看到的就是 SSE 推的这批。给它们留 1 小时形态, +// 点开任意一个都有完整 K 线;尾部进程保持 10 分钟,省内存。 +const HOT_HISTORY = 3600; // 热门进程的采样点数(1 小时) +const ALERT_COOLDOWN = 60_000; // 同类告警的最小间隔 + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', +}; + +// 会话关键进程:杀掉这些会登出用户或让桌面失去响应,按平台各一份。 +// 名字取采集器给的裸进程名,Windows 侧是 ProcessName,本身不带 .exe。 +const SESSION_CRITICAL = process.platform === 'win32' + ? ['explorer', 'winlogon', 'csrss', 'services', 'lsass', 'smss', 'wininit', 'dwm'] + : ['launchd', 'kernel_task', 'loginwindow', 'windowserver', 'sshd', 'diskarbitrationd']; + +/** 进程名归一后比对,.exe 后缀两边都剥一次 */ +function bareName(name) { + return String(name ?? '').toLowerCase().replace(/\.exe$/, ''); +} + +/** + * 清仓保护集。先从板子自己沿 ppid 向上收齐整条祖先链,再把链上每个进程的后代 + * 全部收进来——宿主主界面的其他进程、其他插件的 runtime、Agent 起的子进程都落在 + * 这条链上,杀掉任意一个都会连带掀掉整块板子。最后按名字兜住会话关键进程。 + * 每一拍重算:新起的兄弟 runtime 与 Agent 子进程立刻进保护集,退出的自然消失。 + */ +function computeProtected(byPid) { + const protectedPids = new Set([0, 1, process.pid, process.ppid]); + const roots = []; + // 0 与 1 是会话根,预先标记为已走:它们只保护自己,绝不能当 BFS 起点, + // 否则 launchd 的整棵进程树都会被收进保护集,全表都不可清仓。 + const walked = new Set([0, 1]); + // 进程表撞到 PROC_ROW_CAP 时自己可能不在表里,用合成的一行起步照样接得上宿主 + let cur = byPid.get(process.pid) ?? { pid: process.pid, ppid: process.ppid }; + for (let guard = 0; cur != null && guard < 64 && !walked.has(cur.pid); guard += 1) { + walked.add(cur.pid); + protectedPids.add(cur.pid); + roots.push(cur.pid); + cur = cur.ppid == null ? null : byPid.get(cur.ppid); + } + // 宿主被表截断时它自己也要当根,靠父子表仍能收齐它的后代 + if (!walked.has(process.ppid)) roots.push(process.ppid); + + const children = new Map(); + for (const p of byPid.values()) { + // ppid 指向自身是采集异常,不建这条边 + if (p.ppid == null || p.ppid === p.pid) continue; + const list = children.get(p.ppid); + if (list) list.push(p.pid); else children.set(p.ppid, [p.pid]); + } + // roots 兼作 BFS 队列,边遍历边追加,走完就是这些祖先的全部后代 + for (let i = 0; i < roots.length; i += 1) { + for (const child of children.get(roots[i]) ?? []) { + if (protectedPids.has(child)) continue; + protectedPids.add(child); + roots.push(child); + } + } + + const critical = new Set(SESSION_CRITICAL); + for (const p of byPid.values()) { + if (critical.has(bareName(p.name))) protectedPids.add(p.pid); + } + return protectedPids; +} + +/** + * 能不能向这个进程发信号。signal 0 不投递任何信号,只做权限检查。 + * Windows 上拿不到可靠的进程属主,靠这个探测出来的才是真实能力; + * macOS 上它与 ps 的 user 字段结论一致,两个平台因此统一到同一个口径。 + */ +function canSignal(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +class Hub { + constructor() { + this.clients = new Set(); + } + add(res) { + this.clients.add(res); + return () => this.clients.delete(res); + } + send(event, data) { + if (this.clients.size === 0) return; + const frame = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n'; + for (const res of this.clients) { + try { res.write(frame); } catch { this.clients.delete(res); } + } + } + closeAll() { + for (const res of this.clients) { try { res.end(); } catch { /* 已断开 */ } } + this.clients.clear(); + } + get size() { return this.clients.size; } +} + +export async function start(context) { + const log = context.logger; + // 采样与事件每小时落盘,48 小时保留下会涨到几十 MB,放系统临时目录由系统清空; + // 设置只有几 KB,留在 dataDir 让主题与自选跨重启保留。 + const store = new Store(context.dataDir, join(os.tmpdir(), 'kurumi-monitor')); + await store.init(); + + const baseCatalog = buildCatalog(coreCount()); + const byCode = new Map(baseCatalog.map((m) => [m.code, m])); + const currentUser = os.userInfo().username; + + // 自选进程是动态品种:代码由 pid + 启动时刻构成,只存在于本次运行与设置里。 + // 它们的采样值直接写进同一条 tick 的 v,聚合、今开、均线、持久化全部复用现有链路。 + const watchMeta = new Map(); // code -> 目录项,只装自选进程 + let catalog = baseCatalog; + + function rebuildCatalog() { + catalog = [...baseCatalog, ...watchMeta.values()]; + return catalog; + } + + const PROC_CODE_RE = /^P(\d+)-(\d+)$/; + + function parseProcCode(code) { + const m = PROC_CODE_RE.exec(code); + return m ? { pid: Number(m[1]), startedAt: Number(m[2]) } : null; + } + + /** + * 进程启动时刻。etime 只有秒精度,每条采样现算会让同一个进程在相邻两拍里 + * 得出两个 code,历史被劈成两段。第一次见到某个 pid 时定下来,之后一直复用; + * 进程消失时丢弃,pid 被系统复用后重新定值,历史自然分段。 + */ + function startedAtOf(proc, nowMs) { + let s = procStart.get(proc.pid); + if (s == null) { + const secs = parseEtime(proc.etime); + s = secs == null ? Math.floor(nowMs / 1000) : Math.floor(nowMs / 1000) - secs; + procStart.set(proc.pid, s); + } + return s; + } + + /** + * 品种的采样序列。自选进程的 CPU 占用在 ticks 里(跨重启连续,4 小时); + * 没加自选的进程只有本次运行的内存序列,热门进程 1 小时、尾部进程 10 分钟。 + */ + function seriesFor(code) { + if (watchMeta.has(code)) return ticks; + const ref = parseProcCode(code); + if (!ref) return ticks; + const hist = procHist.get(ref.pid) ?? []; + return hist + .filter((x) => x.s === ref.startedAt) + .map((x) => ({ t: x.t, v: { [code]: x.c }, w: 0 })); + } + + const ticks = []; + const events = []; + const procHist = new Map(); + const procByPid = new Map(); + const procStart = new Map(); // pid -> 启动秒,稳住品种代码不被 etime 精度带偏 + const alertAt = new Map(); + // 保护集随采样重算,首拍之前先兜住自己与宿主 + let protectedPids = new Set([0, 1, process.pid, process.ppid]); + + let prevProcs = new Map(); + let diskPrev = null; + let netPrev = null; + let snapshot = null; + let timer = null; + let stopped = false; + const hub = new Hub(); + + // ------------------------------------------------ 启动后异步恢复历史 + // start() 只负责装路由 + 起监听。读 4 小时历史是重 IO,必须放到监听就绪之后, + // 否则 Host 的就绪探测会超时。 + async function restoreHistory() { + try { + const restoredEvents = await store.readEvents(); + events.push(...restoredEvents); + const since = Date.now() - MEM_TICK_WINDOW_MS; + const restored = await store.readTicks(since); + ticks.push(...restored); + // 恢复期间采集循环可能已经写入新 tick,合并后按时间排序并去掉同刻重复 + ticks.sort((a, b) => a.t - b.t); + const seen = new Set(); + for (let i = ticks.length - 1; i >= 0; i -= 1) { + if (seen.has(ticks[i].t)) ticks.splice(i, 1); + else seen.add(ticks[i].t); + } + log.info('kurumi.history.restored', { ticks: ticks.length, events: events.length }); + } catch { + log.warn('kurumi.history.restore_failed', {}); + } + } + + // ------------------------------------------------ 采样 + async function collect() { + const t0 = Date.now(); + + // CPU 先取差分基准,再并发跑外部命令 + const cpu = sampleCpu(); + const [mem, gpu, thermal, diskRaw, netRaw, procs, threadRows] = await Promise.all([ + sampleMem(), sampleGpu(), sampleThermal(), sampleDiskRaw(), sampleNetRaw(), + sampleProcs(store.settings.sampleMs), sampleThreadRows(), + ]); + + const dt = diskPrev ? (t0 - diskPrev.t) / 1000 : 0; + const nt = netPrev ? (t0 - netPrev.t) / 1000 : 0; + + const disk = diskRaw + ? { + readMBs: diskPrev && dt > 0 ? Math.max(0, (diskRaw.read - diskPrev.read) / dt / 2 ** 20) : null, + writeMBs: diskPrev && dt > 0 ? Math.max(0, (diskRaw.write - diskPrev.write) / dt / 2 ** 20) : null, + readOps: diskPrev && dt > 0 ? Math.max(0, (diskRaw.opR - diskPrev.opR) / dt) : null, + writeOps: diskPrev && dt > 0 ? Math.max(0, (diskRaw.opW - diskPrev.opW) / dt) : null, + } + : null; + if (diskRaw) diskPrev = { ...diskRaw, t: t0 }; + + const net = netRaw + ? { + name: netRaw.name, + downKBs: netPrev && nt > 0 ? Math.max(0, (netRaw.ib - netPrev.ib) / nt / 1024) : null, + upKBs: netPrev && nt > 0 ? Math.max(0, (netRaw.ob - netPrev.ob) / nt / 1024) : null, + downPkt: netPrev && nt > 0 ? Math.max(0, (netRaw.ip - netPrev.ip) / nt) : null, + upPkt: netPrev && nt > 0 ? Math.max(0, (netRaw.op - netPrev.op) / nt) : null, + } + : null; + if (netRaw) netPrev = { ...netRaw, t: t0 }; + + const [l1, l5, l15] = loadAvg(); + const cores = coreCount(); + + // ps 两次 spawn 之间存在毫秒级错位,进程表撞到上限时两个计数都不可信,宁可留空 + const procCount = procs.length >= PROC_ROW_CAP ? null : procs.length; + const threadCount = threadRows && procCount ? Math.max(0, threadRows - procCount) : null; + + const v = { + '000001': cpu.total, '000002': l1, '000003': l5, '000004': l15, + '000005': cpu.user, '000006': cpu.sys, '000007': cpu.idle, + '600001': gpu?.device ?? null, '600002': gpu?.renderer ?? null, '600003': gpu?.tiler ?? null, + '600004': gpu?.vramUsedGiB ?? null, '600005': gpu?.vramAllocGiB ?? null, + '700001': thermal.gpuTempC, '700002': thermal.gpuPowerW, '700003': thermal.zoneTempC, + // 内存三段是定长槽位,两个平台各填自己的分段,指标代码与历史序列保持稳定 + '300001': mem.pct, '300002': mem.segments?.[0], '300003': mem.segments?.[1], + '300004': mem.free, '300005': mem.extra, '300006': mem.segments?.[2], + '300007': mem.purgeableMiB, + '002001': disk?.readMBs ?? null, '002002': disk?.writeMBs ?? null, + '002003': disk?.readOps ?? null, '002004': disk?.writeOps ?? null, + '001001': net?.downKBs ?? null, '001002': net?.upKBs ?? null, + '001003': net?.downPkt ?? null, '001004': net?.upPkt ?? null, + // 进程数直接取 ps 解析出的行数;线程数是 ps -axM 全表行数减去进程数 + '900001': procCount, '900002': threadCount, + '900003': cores > 0 ? (l1 / cores) * 100 : null, + '900004': uptimeHours(), + }; + cpu.cores.forEach((c, i) => { v['0000' + String(10 + i).padStart(2, '0')] = c; }); + + for (const k of Object.keys(v)) if (v[k] == null || !Number.isFinite(v[k])) delete v[k]; + + const tick = { t: t0, v, w: cpu.work }; + + // ------------------------------------------------ 进程快照与历史 + // 保护集必须先于 rows 算出来:killable 是逐行字段,而保护范围取决于整张表的父子关系 + const now = new Map(); + for (const p of procs) if (p.pid != null) now.set(p.pid, p); + protectedPids = computeProtected(now); + + const rows = []; + let rank = 0; + for (const p of procs) { + if (p.pid == null) continue; + // 每轮探测一次,rows 与 /api/procs 共用同一个结论 + p.owned = canSignal(p.pid); + let hist = procHist.get(p.pid); + if (!hist) { hist = []; procHist.set(p.pid, hist); } + const startedAt = startedAtOf(p, t0); + hist.push({ t: t0, c: p.cpu, s: startedAt }); + // procs 按 CPU 降序,rank 就是用户翻进程表时看到它的先后。 + // 掉出热门名单的进程在这里一次收缩回短历史。 + const cap = rank < SSE_PROC_TOP ? HOT_HISTORY : PROC_HISTORY; + rank += 1; + while (hist.length > cap) hist.shift(); + + // 自选进程的 CPU 占用并入本条 tick。tick.v 就是 v,改 v 即改 tick, + // 聚合、今开、均线、落盘全都走同一条链路。 + const meta = procMetaOf(p, startedAt); + if (watchMeta.has(meta.code) && Number.isFinite(p.cpu)) { + watchMeta.set(meta.code, meta); // 进程可能改标题,目录项跟着刷新 + v[meta.code] = p.cpu; + } + + rows.push({ + pid: p.pid, name: p.name, user: p.user, cpu: p.cpu, mem: p.mem, + rss: p.rss, state: p.state, etime: p.etime, + spark: hist.slice(-24).map((x) => x.c), + code: meta.code, + watched: watchMeta.has(meta.code), + owned: p.owned, + killable: p.owned && !protectedPids.has(p.pid), + }); + } + // 清理已退出进程的历史,避免 Map 无限增长 + if (procHist.size > now.size * 3) { + for (const pid of procHist.keys()) if (!now.has(pid)) procHist.delete(pid); + } + for (const pid of procStart.keys()) if (!now.has(pid)) procStart.delete(pid); + procByPid.clear(); + for (const [pid, p] of now) procByPid.set(pid, p); + + // ------------------------------------------------ 逐笔成交 + const fresh = []; + const pushEvent = async (ev) => { + fresh.push(ev); + events.push(ev); + if (events.length > 500) events.shift(); + try { await store.appendEvent(ev); } catch { /* 事件落盘失败不阻断采样 */ } + }; + + const hhmmss = new Date(t0).toTimeString().slice(0, 8); + const selfPid = process.pid; + if (prevProcs.size > 0) { + const started = []; + const exited = []; + // 采集命令由本进程派生,每秒都会启停。不排除的话逐笔成交全是自己的 ps / ioreg + for (const [pid, proc] of now) { + if (!prevProcs.has(pid) && proc.ppid !== selfPid) started.push(proc); + } + for (const [pid, proc] of prevProcs) { + if (!now.has(pid) && proc.ppid !== selfPid) exited.push(proc); + } + + // 报具体进程名与 PID,最多 3 条,其余汇总成一条 + for (const proc of started.slice(0, 3)) { + await pushEvent({ t: t0, time: hhmmss, kind: 'start', label: '启动 · ' + proc.name, value: String(proc.pid), up: true }); + } + if (started.length > 3) { + await pushEvent({ t: t0, time: hhmmss, kind: 'start', label: '其余进程启动', value: '+' + (started.length - 3), up: true }); + } + for (const proc of exited.slice(0, 3)) { + await pushEvent({ t: t0, time: hhmmss, kind: 'exit', label: '退出 · ' + proc.name, value: String(proc.pid), up: false }); + } + if (exited.length > 3) { + await pushEvent({ t: t0, time: hhmmss, kind: 'exit', label: '其余进程退出', value: '-' + (exited.length - 3), up: false }); + } + } + + const alerts = [ + ['000001', 88, 'CPU 占用'], ['600001', 92, 'GPU 占用'], ['300001', 90, '内存占用'], + ['700001', 85, 'GPU 温度'], ['700003', 80, '热区温度'], + ]; + for (const [code, limit, label] of alerts) { + const val = v[code]; + if (val == null || val < limit) continue; + const last = alertAt.get(code) ?? 0; + if (t0 - last < ALERT_COOLDOWN) continue; + alertAt.set(code, t0); + await pushEvent({ + t: t0, time: hhmmss, kind: 'alert', code, + label: label + ' 突破 ' + limit, value: val.toFixed(1) + '%', up: true, + }); + } + + prevProcs = now; + + // ------------------------------------------------ 落盘与内存窗口 + ticks.push(tick); + while (ticks.length && ticks[0].t < t0 - MEM_TICK_WINDOW_MS) ticks.shift(); + try { await store.appendTick(tick); } catch (e) { log.warn('kurumi.tick.persist_failed', {}); } + + const top = rows.slice(0, SSE_PROC_TOP); + snapshot = { + t: t0, + net: net ? { name: net.name } : null, + gpuAvailable: gpu != null, + diskAvailable: disk != null, + cores, + totalMemGiB: mem.totalGiB, + procs: top, + procCount: rows.length, + }; + + hub.send('tick', { tick, snapshot, events: fresh }); + } + + function schedule() { + if (stopped) return; + const ms = store.settings.sampleMs ?? 1000; + timer = setTimeout(async () => { + if (stopped) return; + try { + if (store.settings.running !== false) await collect(); + } catch (e) { + log.warn('kurumi.sample.failed', { msg: String(e?.message ?? e).slice(0, 120) }); + } + schedule(); + }, ms); + if (timer.unref) timer.unref(); + } + + // ------------------------------------------------ 清仓校验 + /** + * 清仓校验。startedAt 传 null 表示只做基础校验并回传实时值,供确认框展示; + * 传了数字就必须与目标进程的启动时刻完全一致——确认框开着的那几秒里原进程可能 + * 已经退出、PID 被系统复用,这时发信号会打到一个用户从没确认过的进程上。 + */ + function validateKill(pid, startedAt) { + if (!Number.isInteger(pid) || pid <= 1) return { ok: false, reason: 'invalid_pid' }; + if (protectedPids.has(pid)) return { ok: false, reason: 'protected' }; + const p = procByPid.get(pid); + if (!p) return { ok: false, reason: 'not_found' }; + const live = startedAtOf(p, Date.now()); + if (startedAt != null && startedAt !== live) return { ok: false, reason: 'pid_reused' }; + if (!canSignal(pid)) return { ok: false, reason: 'not_owned' }; + return { ok: true, proc: p, startedAt: live }; + } + + for (const m of Array.isArray(store.settings.watchProcs) ? store.settings.watchProcs : []) { + if (m && typeof m.code === 'string' && parseProcCode(m.code)) watchMeta.set(m.code, m); + } + rebuildCatalog(); + + // ------------------------------------------------ HTTP + const clientRoot = join(context.pluginRoot, 'miniapp', 'client'); + + async function serveStatic(pathname, res) { + const rel = normalize(pathname).replace(/^([/\\])+/, ''); + if (!rel || rel.split(/[/\\]/).includes('..')) { + res.writeHead(404).end(); + return; + } + const abs = join(clientRoot, rel); + if (!abs.startsWith(clientRoot + sep) && abs !== clientRoot) { + res.writeHead(404).end(); + return; + } + try { + const buf = await readFile(abs); + res.writeHead(200, { 'content-type': MIME[extname(abs)] ?? 'application/octet-stream' }); + res.end(buf); + } catch { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + } + } + + function json(res, code, body) { + const payload = JSON.stringify(body); + res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) }); + res.end(payload); + } + + async function readBody(req, limit = 64 * 1024) { + const chunks = []; + let size = 0; + for await (const c of req) { + size += c.length; + if (size > limit) throw new Error('body_too_large'); + chunks.push(c); + } + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } + + const httpServer = createServer(async (req, res) => { + let url; + try { + url = new URL(req.url ?? '/', 'http://miniapp.local'); + } catch { + json(res, 400, { error: 'bad_request' }); + return; + } + const p = url.pathname; + + try { + if (req.method === 'GET' && p === '/board') { await serveStatic('index.html', res); return; } + + if (req.method === 'GET' && p === '/api/bootstrap') { + const period = PERIODS[store.settings.periodIndex] ?? PERIODS[1]; + const saved = store.settings.selectedCode; + const code = byCode.has(saved) || parseProcCode(saved) ? saved : DEFAULT_CODE; + json(res, 200, { + catalog: rebuildCatalog(), watchCodes: [...watchMeta.keys()], watchMax: WATCH_MAX, + boards: true, indexCodes: INDEX_CODES, periods: PERIODS, + memSegments: MEM_SEGMENTS, + sampleTiers: SAMPLE_TIERS, maxBars: MAX_BARS, + settings: store.settings, currentUser, + host: { cores: coreCount(), platform: process.platform, arch: process.arch }, + snapshot, events: events.slice(-120), + usage: await store.usage(), + candles: await packCandles(code, period.seconds), + todayStart: todayStart(), + }); + return; + } + + if (req.method === 'GET' && p === '/api/candles') { + const code = url.searchParams.get('code') ?? DEFAULT_CODE; + const seconds = Number(url.searchParams.get('period') ?? 5); + if (!byCode.has(code) && !parseProcCode(code)) { json(res, 400, { error: 'unknown_code' }); return; } + json(res, 200, await packCandles(code, seconds)); + return; + } + + if (req.method === 'GET' && p === '/api/procs') { + const q = (url.searchParams.get('q') ?? '').toLowerCase(); + const all = []; + for (const [, p2] of procByPid) { + if (q && !p2.name.toLowerCase().includes(q) && String(p2.pid) !== q) continue; + const hist = procHist.get(p2.pid) ?? []; + const code = procMetaOf(p2, startedAtOf(p2, Date.now())).code; + all.push({ + pid: p2.pid, name: p2.name, user: p2.user, cpu: p2.cpu, mem: p2.mem, + rss: p2.rss, state: p2.state, etime: p2.etime, + spark: hist.slice(-24).map((x) => x.c), + code, + watched: watchMeta.has(code), + owned: p2.owned === true, + killable: p2.owned === true && !protectedPids.has(p2.pid), + }); + } + all.sort((a, b) => b.cpu - a.cpu); + json(res, 200, { procs: all.slice(0, 400), total: all.length }); + return; + } + + if (req.method === 'GET' && p === '/api/stream') { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + }); + res.write('retry: 2000\n\n'); + const off = hub.add(res); + req.on('close', off); + // 只给这条新连接补一帧,不能广播给已连接的页面 + const last = ticks[ticks.length - 1]; + if (last) { + res.write('event: tick\ndata: ' + JSON.stringify({ tick: last, snapshot, events: [] }) + '\n\n'); + } + return; + } + + if (req.method === 'POST' && p === '/api/settings') { + const body = await readBody(req); + const patch = {}; + if (['auto', 'dark', 'light'].includes(body.theme)) patch.theme = body.theme; + if (['redUp', 'greenUp'].includes(body.colorScheme)) patch.colorScheme = body.colorScheme; + if (typeof body.selectedCode === 'string' && (byCode.has(body.selectedCode) || parseProcCode(body.selectedCode))) { + patch.selectedCode = body.selectedCode; + } + if (Number.isInteger(body.periodIndex) && body.periodIndex >= 0 && body.periodIndex < PERIODS.length) patch.periodIndex = body.periodIndex; + if (SAMPLE_TIERS.includes(body.sampleMs)) patch.sampleMs = body.sampleMs; + if (['cpu', 'mem', 'rss', 'pid', 'name'].includes(body.sortKey)) patch.sortKey = body.sortKey; + if (['asc', 'desc'].includes(body.sortDir)) patch.sortDir = body.sortDir; + if (Number.isInteger(body.retentionHours) && body.retentionHours >= 1 && body.retentionHours <= 720) patch.retentionHours = body.retentionHours; + if (typeof body.running === 'boolean') patch.running = body.running; + const saved = await store.saveSettings(patch); + json(res, 200, { settings: saved }); + return; + } + + if (req.method === 'POST' && p === '/api/kill') { + const body = await readBody(req); + const pid = Number(body.pid); + const startedAt = Number(body.startedAt); + // 启动时刻是确认框里那条进程的身份证,缺了就没法排除 PID 复用 + if (!Number.isInteger(startedAt) || startedAt <= 0) { json(res, 400, { error: 'bad_request' }); return; } + const check = validateKill(pid, startedAt); + if (!check.ok) { json(res, 403, { error: check.reason }); return; } + const signal = body.force === true ? 'SIGKILL' : 'SIGTERM'; + try { + process.kill(pid, signal); + } catch (e) { + const code = e?.code === 'EPERM' ? 'not_permitted' : e?.code === 'ESRCH' ? 'gone' : 'kill_failed'; + json(res, 403, { error: code }); + return; + } + json(res, 200, { ok: true, pid, signal, name: check.proc.name }); + return; + } + + if (req.method === 'POST' && p === '/api/watch') { + const body = await readBody(req); + const code = String(body.code ?? ''); + const ref = parseProcCode(code); + if (!ref) { json(res, 400, { error: 'bad_code' }); return; } + + if (body.op === 'remove') { + watchMeta.delete(code); + } else { + if (!watchMeta.has(code) && watchMeta.size >= WATCH_MAX) { + json(res, 409, { error: 'watch_full', max: WATCH_MAX }); + return; + } + const live = procByPid.get(ref.pid); + watchMeta.set(code, procMetaOf( + live ?? { pid: ref.pid, name: String(body.name ?? '未知进程'), user: String(body.user ?? '') }, + ref.startedAt, + )); + } + rebuildCatalog(); + await store.saveSettings({ watchProcs: [...watchMeta.values()] }); + json(res, 200, { catalog, watchCodes: [...watchMeta.keys()] }); + return; + } + + if (req.method === 'POST' && p === '/api/kill/preflight') { + const body = await readBody(req); + const check = validateKill(Number(body.pid), null); + json(res, 200, check.ok + ? { + ok: true, pid: Number(body.pid), name: check.proc.name, user: check.proc.user, + cpu: check.proc.cpu, rss: check.proc.rss, startedAt: check.startedAt, + } + : { ok: false, reason: check.reason }); + return; + } + + if (p.startsWith('/assets/')) { await serveStatic(p.slice('/assets/'.length), res); return; } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + } catch (e) { + const msg = String(e?.message ?? e); + json(res, msg === 'body_too_large' ? 413 : 500, { error: 'server_error' }); + log.warn('kurumi.request.failed', { path: p, msg: msg.slice(0, 120) }); + } + }); + + async function packCandles(code, seconds) { + const series = seriesFor(code); + // 副图内存是全局量,进程品种的 series 里没有这些字段,统一从主 tick 序列取 + const agg = aggregate(series, code, seconds, MAX_BARS, ticks); + // 一次遍历算出全部品种的品种头,自选列表每行都要用 + const quotes = quotesOf(ticks, catalog.map((m) => m.code)); + const ref = parseProcCode(code); + return { + code, period: seconds, + meta: byCode.get(code) ?? watchMeta.get(code) ?? null, + // 副图柱高按总内存归一化,柱子高度直接就是内存水位 + memTotalGiB: snapshot?.totalMemGiB ?? null, + bars: agg.bars, ma5: agg.ma5, ma10: agg.ma10, ma20: agg.ma20, + // 非自选进程的采样值不在 ticks 里,品种头只能现算 + quote: quotes[code] ?? (ref ? quotesOf(series, [code])[code] ?? null : null), + // 进程退出后历史还在,但不该假装它还在跳 + alive: ref ? procByPid.has(ref.pid) : true, + watched: watchMeta.has(code), + quotes, + index: INDEX_CODES.map((c) => ({ + code: c, + meta: byCode.get(c) ?? null, + quote: quotes[c] ?? null, + spark: sparkline(ticks, c, 48), + })), + }; + } + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(context.listen.port, context.listen.host, () => { + httpServer.off('error', reject); + resolve(); + }); + }); + + // 路由装好、监听就绪后再启动采样与历史恢复,不在 start 里同步拉数据 + schedule(); + void restoreHistory(); + log.info('kurumi.runtime.listening', { port: context.listen.port, cores: coreCount() }); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + stopped = true; + if (timer) clearTimeout(timer); + context.signal.removeEventListener('abort', onAbort); + hub.closeAll(); + await disposeProbes(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }; + const onAbort = () => { void dispose(); }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} diff --git a/plugins/jzlikewei/kurumi-monitor/miniapp/node/store.mjs b/plugins/jzlikewei/kurumi-monitor/miniapp/node/store.mjs new file mode 100644 index 0000000..3fa27b4 --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/miniapp/node/store.mjs @@ -0,0 +1,205 @@ +// 持久化:JSONL 按小时分片的 tick 序列 + 事件流 + 设置 +// 追加写天然原子;设置走临时文件 + rename。 +// +// 采样与事件按小时落盘,48 小时保留下会涨到几十 MB,放系统临时目录, +// 重启后由系统清空。设置只有几 KB 且是用户偏好,留在 dataDir 跨重启保留。 + +import { createReadStream } from 'node:fs'; +import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { join } from 'node:path'; + +const HOUR = 3600_000; +const EVENT_KEEP = 500; // 内存与重启后保留的事件条数(逐笔成交只显示 80 条) +// 事件带要覆盖长周期 K 线:实测约 268 条/分钟,60000 行≈3.7 小时,够 1 分 K 画满 180 根 +const EVENT_COMPACT_AT = 60000; + +const pad = (n) => String(n).padStart(2, '0'); + +export function hourKey(ts) { + const d = new Date(ts); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}`; +} + +export const DEFAULT_SETTINGS = { + theme: 'auto', // auto | dark | light + colorScheme: 'redUp', // redUp(A股红涨) | greenUp(国际绿涨) + selectedCode: '600001', + periodIndex: 1, + sampleMs: 1000, + sortKey: 'cpu', + sortDir: 'desc', + retentionHours: 48, + running: true, + watchProcs: [], // 自选进程目录项,进程版的"加自选" +}; + +export class Store { + /** + * @param {string} dataDir 用户目录,只放设置这种重启后要保留的小文件 + * @param {string} cacheDir 系统临时目录,放采样与事件这些可重建的大文件 + */ + constructor(dataDir, cacheDir) { + this.settingsPath = join(dataDir, 'settings.json'); + this.tickDir = join(cacheDir, 'ticks'); + this.eventsPath = join(cacheDir, 'events.jsonl'); + this.settings = { ...DEFAULT_SETTINGS }; + this.eventLines = 0; + this.lastSweepHour = ''; + } + + async init() { + await mkdir(this.tickDir, { recursive: true }); + await this.loadSettings(); + await this.countEvents(); + } + + async loadSettings() { + try { + const raw = await readFile(this.settingsPath, 'utf8'); + const parsed = JSON.parse(raw); + // 只接受已知键,避免旧版本遗留字段污染 + for (const k of Object.keys(DEFAULT_SETTINGS)) { + if (parsed[k] !== undefined) this.settings[k] = parsed[k]; + } + } catch { + // 首次运行或文件损坏,回落到默认值 + } + } + + async saveSettings(patch) { + Object.assign(this.settings, patch ?? {}); + const tmp = this.settingsPath + '.tmp'; + await writeFile(tmp, JSON.stringify(this.settings, null, 2), 'utf8'); + await rename(tmp, this.settingsPath); + return this.settings; + } + + tickPath(ts) { + return join(this.tickDir, hourKey(ts) + '.jsonl'); + } + + /** 追加一条采样点 */ + async appendTick(tick) { + await appendFile(this.tickPath(tick.t), JSON.stringify(tick) + '\n', 'utf8'); + const hk = hourKey(tick.t); + if (hk !== this.lastSweepHour) { + this.lastSweepHour = hk; + await this.sweep(); + } + } + + /** 读回 sinceMs 之后的采样点,只触碰相关分片 */ + async readTicks(sinceMs) { + const keys = new Set(); + const end = Date.now(); + // 向前覆盖到 since 所在小时,多读一小时避免边界缺失 + for (let t = sinceMs - HOUR; t <= end; t += HOUR) keys.add(hourKey(t)); + keys.add(hourKey(end)); + + const wanted = [...keys].sort(); + const out = []; + for (const k of wanted) { + const p = join(this.tickDir, k + '.jsonl'); + try { + await stat(p); + } catch { + continue; + } + const rl = createInterface({ input: createReadStream(p, 'utf8'), crlfDelay: Infinity }); + for await (const line of rl) { + if (!line) continue; + try { + const tick = JSON.parse(line); + if (tick.t >= sinceMs) out.push(tick); + } catch { + // 跳过损坏行,不让单行错误毁掉整段历史 + } + } + } + out.sort((a, b) => a.t - b.t); + return out; + } + + /** 删除超出保留窗口的分片 */ + async sweep() { + const keepMs = (this.settings.retentionHours ?? 48) * HOUR; + const cutoff = Date.now() - keepMs; + let files; + try { + files = await readdir(this.tickDir); + } catch { + return; + } + for (const f of files) { + if (!f.endsWith('.jsonl')) continue; + const m = /^(\d{8})-(\d{2})\.jsonl$/.exec(f); + if (!m) continue; + const y = +m[1].slice(0, 4), mo = +m[1].slice(4, 6), d = +m[1].slice(6, 8), h = +m[2]; + const start = new Date(y, mo - 1, d, h).getTime(); + // 整小时都早于截止时间才删 + if (start + HOUR < cutoff) { + await rm(join(this.tickDir, f), { force: true }); + } + } + } + + async countEvents() { + try { + const raw = await readFile(this.eventsPath, 'utf8'); + this.eventLines = raw.split('\n').filter(Boolean).length; + } catch { + this.eventLines = 0; + } + } + + /** + * 读事件。不传参时返回最近 EVENT_KEEP 条,给逐笔成交与 SSE 用; + * 传 since 时返回该时刻之后的全部,给主图事件带用 —— 长周期 K 线要覆盖几小时, + * 内存里那 500 条撑不住。 + */ + async readEvents(since = null, limit = EVENT_KEEP) { + let raw; + try { + raw = await readFile(this.eventsPath, 'utf8'); + } catch { + return []; + } + const parsed = raw.split('\n').filter(Boolean).map((l) => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + return since == null ? parsed.slice(-limit) : parsed.filter((ev) => ev.t >= since); + } + + async appendEvent(ev) { + await appendFile(this.eventsPath, JSON.stringify(ev) + '\n', 'utf8'); + this.eventLines += 1; + if (this.eventLines > EVENT_COMPACT_AT) await this.compactEvents(); + } + + async compactEvents() { + // 留一半:砍到 EVENT_KEEP 会把几小时的事件历史一次清空,事件带就断成一小截; + // 留一半既压住了文件大小,又不会刚截断就立刻回到阈值。 + const keep = await this.readEvents(null, Math.floor(EVENT_COMPACT_AT / 2)); + const tmp = this.eventsPath + '.tmp'; + await writeFile(tmp, keep.map((e) => JSON.stringify(e)).join('\n') + (keep.length ? '\n' : ''), 'utf8'); + await rename(tmp, this.eventsPath); + this.eventLines = keep.length; + } + + /** 磁盘占用统计,用于设置面板展示 */ + async usage() { + let bytes = 0, files = 0; + try { + for (const f of await readdir(this.tickDir)) { + if (!f.endsWith('.jsonl')) continue; + try { + const s = await stat(join(this.tickDir, f)); + bytes += s.size; + files += 1; + } catch { /* 文件可能刚被淘汰 */ } + } + } catch { /* 目录尚未创建 */ } + return { bytes, files, hours: files }; + } +} diff --git a/plugins/jzlikewei/kurumi-monitor/package.json b/plugins/jzlikewei/kurumi-monitor/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/jzlikewei/kurumi-monitor/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +}