Compare commits
9
Commits
main
...
58cf9787a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58cf9787a2 | ||
|
|
22fa9bd607 | ||
|
|
e71de44736 | ||
|
|
4a3a238e93 | ||
|
|
249ec4209e | ||
|
|
6111acd7e0 | ||
|
|
1c2aac3d8b | ||
|
|
97ac3ef555 | ||
|
|
e8ebd67509 |
No files matched your search
@@ -6,6 +6,7 @@ storage
|
||||
state.json
|
||||
state.json.corrupt
|
||||
.tmp-*.json
|
||||
/certs
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
+13
-4
@@ -1,12 +1,21 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.14-alpine
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_COMPILE_BYTECODE=0 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev linux-headers python3-dev \
|
||||
&& uv sync --locked --no-dev --no-install-project \
|
||||
&& apk del .build-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license
|
||||
for software and other kinds of works, specifically designed to
|
||||
ensure cooperation with the community in the case of network server
|
||||
software.
|
||||
|
||||
The licenses for most software and other practical works are
|
||||
designed to take away your freedom to share and change the works.
|
||||
By contrast, our General Public Licenses are intended to guarantee
|
||||
your freedom to share and change all versions of a program--to make
|
||||
sure it remains free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and
|
||||
charge for them if you wish), that you receive source code or can
|
||||
get it if you want it, that you can change the software or use
|
||||
pieces of it in new free programs, and that you know you can do
|
||||
these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your
|
||||
rights with two steps: (1) assert copyright on the software, and
|
||||
(2) offer you this License which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible feature
|
||||
that (1) displays an appropriate copyright notice, and (2) tells the
|
||||
user that there is no warranty for the work (except to the extent that
|
||||
warranties are provided), that licensees may convey the work under this
|
||||
License, and how to view a copy of this License. If the interface
|
||||
presents a list of user commands or options, such as a menu, a
|
||||
prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system (if
|
||||
any) on which the executable work runs, or a compiler used to produce
|
||||
the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in
|
||||
accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant mode
|
||||
of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include
|
||||
a requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission to
|
||||
receive a copy likewise does not require acceptance. However, nothing
|
||||
other than this License grants you permission to propagate or modify
|
||||
any covered work. These actions infringe copyright if you do not
|
||||
accept this License. Therefore, by modifying or propagating a covered
|
||||
work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement, or
|
||||
that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement
|
||||
or otherwise) that contradict the conditions of this License, they do
|
||||
not excuse you from the conditions of this License. If you cannot
|
||||
convey a covered work so as to satisfy simultaneously your obligations
|
||||
under this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree
|
||||
to terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Colony
|
||||
Copyright (C) 2026 Szymon Osypiuk
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source code. For example, if your program is a web application,
|
||||
its interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source code, and
|
||||
different solutions will be better for different programs; see section 13
|
||||
for the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Colony
|
||||
|
||||
Colony is a self-organizing cluster of nodes on a LAN. Nodes discover each
|
||||
other, gossip membership, and bid for **roles**: units of work such as
|
||||
storage replication, a password vault, or a web dashboard. That way the
|
||||
cluster keeps running the roles it needs even as nodes join, leave, or die.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Discovery & gossip**: each node scans the configured IP range for peers
|
||||
and joins the cluster via a gossip protocol (`Gossip`), building a shared
|
||||
view of membership and health.
|
||||
- **Role assignment**: `Foreman` proposes unclaimed roles to capable nodes.
|
||||
`RoleManager`/`RoleRegistry` decide who should run what based on priority,
|
||||
hardware preferences (disk, longevity, etc.) and policy (`role_policy` in
|
||||
`config.json`).
|
||||
- **Transport**: nodes talk over a TLS listener (`Listener`/`Whisperer`) and
|
||||
discover each other over UDP (`Discover`). A local control socket
|
||||
(`ControlSocket`) is how `colonyctl` talks to the node process on the same
|
||||
host.
|
||||
- **Roles shipped today** (see `role_settings.py`):
|
||||
- `Unit`: baseline role every node runs; reports stats/hardware/cluster info.
|
||||
- `Gossip`: membership protocol.
|
||||
- `Foreman`: assigns roles across the cluster.
|
||||
- `Gateway`: relays control commands to nodes that aren't directly reachable.
|
||||
- `VaultWarden`: replicated key/value secret storage.
|
||||
- `Replicator`: content-addressed replicated blob storage used by other roles.
|
||||
- `Explorer`: probes/tracks external hosts outside the discovery range.
|
||||
- `WebView` (on-demand): a small web dashboard (Flask) for the cluster.
|
||||
- `Chatter` (on-demand): peer-to-peer chat between nodes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.14
|
||||
- [uv](https://docs.astral.sh/uv/) for dependency management
|
||||
|
||||
## Running a node
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Configuration lives in `config.json` and can be overridden with
|
||||
`COLONY_<SECTION>__<KEY>` environment variables (see `docker-compose.yml` for
|
||||
every available key). At minimum, point `discovery.range_start`/`range_end`
|
||||
at the IP range your nodes live on.
|
||||
|
||||
## Controlling a node (`colonyctl`)
|
||||
|
||||
`colonyctl` talks to the local node over its control socket:
|
||||
|
||||
```bash
|
||||
./colonyctl.py status # cluster members and role assignment
|
||||
./colonyctl.py status --json # same, as JSON (also used for scripting)
|
||||
./colonyctl.py role add <Role> # start an on-demand role on this node
|
||||
./colonyctl.py webview # shortcut for `role add WebView`
|
||||
./colonyctl.py chat <node> [text] # view/send chat with another node
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t colony .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The container runs on the host network (nodes need to see each other's real
|
||||
IPs for discovery) and persists `/app/data`, `/app/storages`, and `/app/logs`
|
||||
as volumes. TLS cert/key paths are configured via `listener`/`whisperer` in
|
||||
`config.json`.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
+137
-259
@@ -2,290 +2,168 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from colony.core.network.requests import CommandRequest
|
||||
from colony.core.roles.unit.role import Unit
|
||||
from colony.settings import configuration, setting
|
||||
from colony.transport.whisperer import Whisperer
|
||||
from colony.core.storages.chat import ChatStorage
|
||||
from colony.settings import setting
|
||||
from colony.transport import control
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
DEFAULT_INTERVAL = 0.5
|
||||
|
||||
HOME = "\033[H"
|
||||
CLEAR_SCREEN = "\033[2J"
|
||||
CLEAR_LINE = "\033[K"
|
||||
CLEAR_BELOW = "\033[J"
|
||||
|
||||
def call(host, port, command, *args, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
|
||||
payload = CommandRequest.form(command, *args).collapse()
|
||||
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
|
||||
|
||||
if not isinstance(resp, dict) or resp.get("status") != "ok" or not isinstance(resp.get("result"), dict):
|
||||
reason = resp.get("error") if isinstance(resp, dict) else resp
|
||||
raise RuntimeError(f"the node answered with an error: {reason}")
|
||||
return resp["result"]
|
||||
CLEAR = "\033[H\033[2J"
|
||||
|
||||
|
||||
def fetch_cluster(host, port, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
|
||||
return call(host, port, Unit.COMMANDS.SHOW_CLUSTER, timeout=timeout, cert_file=cert_file, keyfile=keyfile)
|
||||
class CliError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def request_role(host, port, role, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
|
||||
return call(host, port, Unit.COMMANDS.REQUEST_ROLE, role, timeout=timeout, cert_file=cert_file, keyfile=keyfile)
|
||||
def call(args, action, **params):
|
||||
try:
|
||||
reply = control.request({"action": action, "args": params}, path=args.socket, timeout=args.timeout)
|
||||
except OSError as e:
|
||||
raise CliError(f"cannot reach the node at {args.socket}: {e}")
|
||||
if reply.get("status") != "ok":
|
||||
raise CliError(f"the node answered with an error: {reply.get('error')}")
|
||||
return reply["result"]
|
||||
|
||||
|
||||
def resolve_on_demand_role(payload, name):
|
||||
roles = payload["roles"]
|
||||
match = next((role for role in roles if role["name"].lower() == name.lower()), None)
|
||||
if match is None:
|
||||
raise LookupError(f"unknown role {name!r}. Roles: {', '.join(role['name'] for role in roles)}")
|
||||
if not match.get("on_demand"):
|
||||
on_demand = [role["name"] for role in roles if role.get("on_demand")]
|
||||
raise LookupError(
|
||||
f"{match['name']} is assigned automatically, only on-demand roles can be added by hand"
|
||||
+ (f" ({', '.join(on_demand)})" if on_demand else "")
|
||||
)
|
||||
return match["name"]
|
||||
def table(rows):
|
||||
widths = [max(len(str(row[i])) for row in rows) for i in range(len(rows[0]))]
|
||||
return "\n".join(" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in rows)
|
||||
|
||||
|
||||
def format_age(seconds):
|
||||
seconds = int(seconds)
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m{seconds % 60:02d}s"
|
||||
return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
|
||||
def usage(stats, name):
|
||||
used, total = stats.get(f"{name}_used_gb"), stats.get(f"{name}_total_gb")
|
||||
return f"{used:.1f}/{total:.1f} GB" if used is not None and total is not None else "-"
|
||||
|
||||
|
||||
def table(headers, rows):
|
||||
widths = [max(len(str(cell)) for cell in column) for column in zip(headers, *rows)]
|
||||
lines = [" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in [headers, *rows]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_size(gb):
|
||||
if gb >= 1000:
|
||||
return f"{gb / 1024:.1f} TB"
|
||||
if gb >= 100:
|
||||
return f"{gb:.0f} GB"
|
||||
return f"{gb:.1f} GB"
|
||||
|
||||
|
||||
def format_uptime(hours):
|
||||
if hours < 1:
|
||||
return f"{round(hours * 60)}m"
|
||||
if hours < 48:
|
||||
return f"{round(hours)}h"
|
||||
return f"{int(hours // 24)}d {round(hours % 24)}h"
|
||||
|
||||
|
||||
def describe_stats(stats):
|
||||
stats = stats or {}
|
||||
|
||||
def usage(used_key, total_key):
|
||||
if used_key in stats and total_key in stats:
|
||||
return f"{format_size(stats[used_key])} / {format_size(stats[total_key])}"
|
||||
return "-"
|
||||
|
||||
cpu = "-"
|
||||
if "cpu_percent" in stats:
|
||||
cpu = f"{stats['cpu_percent']:.0f}%"
|
||||
if stats.get("cpu_cores") and stats.get("cpu_threads"):
|
||||
cpu += f" · {stats['cpu_cores']}C/{stats['cpu_threads']}T"
|
||||
|
||||
gpu = usage("gpu_mem_used_gb", "gpu_mem_total_gb")
|
||||
if gpu != "-" and stats.get("gpu_count", 1) > 1:
|
||||
gpu = f"{stats['gpu_count']}× {gpu}"
|
||||
|
||||
uptime = format_uptime(stats["uptime_hours"]) if "uptime_hours" in stats else "-"
|
||||
return [cpu, usage("mem_used_gb", "mem_total_gb"), usage("disk_used_gb", "disk_total_gb"), gpu, uptime]
|
||||
|
||||
|
||||
def address(member):
|
||||
return f"{member['ip']}:{member['port']}"
|
||||
|
||||
|
||||
def role_states(payload):
|
||||
alive = [member for member in payload["members"] if member["alive"]]
|
||||
states = []
|
||||
for role in payload["roles"]:
|
||||
holders = sorted(address(member) for member in alive if role["name"] in member["roles"])
|
||||
required = role["required"]
|
||||
limit = role.get("max", required)
|
||||
if role.get("on_demand"):
|
||||
state = "held" if holders else "off"
|
||||
elif required is None:
|
||||
state = "general"
|
||||
elif len(holders) < required:
|
||||
state = "unmet"
|
||||
elif limit is not None and len(holders) > limit:
|
||||
state = "duplicate"
|
||||
else:
|
||||
state = "held"
|
||||
states.append({**role, "holders": holders, "state": state})
|
||||
return states
|
||||
|
||||
|
||||
def address_of(key):
|
||||
return f"{key[0]}:{key[1]}" if key else ""
|
||||
|
||||
|
||||
def render_status(payload):
|
||||
members = payload["members"]
|
||||
|
||||
hostnames = [member["hostname"] for member in members]
|
||||
labels = {
|
||||
address(member): member["hostname"] if hostnames.count(member["hostname"]) == 1 else address(member)
|
||||
for member in members
|
||||
}
|
||||
|
||||
alive = [member for member in members if member["alive"]]
|
||||
down = len(members) - len(alive)
|
||||
self_member = next(member for member in members if member["self"])
|
||||
title = f"Cluster as seen by {labels[address(self_member)]}: {len(alive)} node(s) alive"
|
||||
if down:
|
||||
title += f", {down} down"
|
||||
|
||||
show_declined = any(member["declined"] for member in members)
|
||||
show_route = any(member.get("via") for member in members)
|
||||
node_headers = ["NODE", "ADDRESS", "STATE", "SEEN", "ROLES"] + (["DECLINED"] if show_declined else []) + (["ROUTE"] if show_route else [])
|
||||
node_rows = []
|
||||
def render_status(cluster):
|
||||
members = cluster["members"]
|
||||
nodes = [["NODE", "ADDRESS", "STATE", "CPU", "RAM", "DISK", "ROLES"]]
|
||||
for member in members:
|
||||
label = labels[address(member)] + (" (this node)" if member["self"] else "")
|
||||
row = [
|
||||
label,
|
||||
address(member),
|
||||
"alive" if member["alive"] else "DOWN",
|
||||
"-" if member["self"] else format_age(member["seen_ago"]),
|
||||
stats = member.get("stats") or {}
|
||||
nodes.append([
|
||||
member["hostname"] + (" (this node)" if member["self"] else ""),
|
||||
f"{member['ip']}:{member['port']}",
|
||||
("alive" if member["alive"] else "DOWN") + (" via gateway" if member.get("via") else ""),
|
||||
f"{stats['cpu_percent']:.0f}%" if "cpu_percent" in stats else "-",
|
||||
usage(stats, "mem"),
|
||||
usage(stats, "disk"),
|
||||
" ".join(member["roles"]) or "-",
|
||||
])
|
||||
|
||||
roles = [["ROLE", "PRIORITY", "HOLDERS"]]
|
||||
for role in cluster["roles"]:
|
||||
holders = [member["hostname"] for member in members if member["alive"] and role["name"] in member["roles"]]
|
||||
empty = "off (on demand)" if role.get("on_demand") else "UNMET"
|
||||
roles.append([role["name"], role["priority"], ", ".join(holders) or empty])
|
||||
|
||||
return table(nodes) + "\n\n" + table(roles)
|
||||
|
||||
|
||||
def render_chat(messages, peer):
|
||||
if not messages:
|
||||
return f"No messages with {peer} yet."
|
||||
return "\n".join(f"{datetime.fromisoformat(m['at']).astimezone():%H:%M} {'you' if m['direction'] == 'out' else peer}: {m['text']}" for m in messages)
|
||||
|
||||
|
||||
def find_node(cluster, name):
|
||||
matches = [
|
||||
member for member in cluster["members"]
|
||||
if name.lower() in (member["hostname"].lower(), f"{member['ip']}:{member['port']}", f"{member['hostname']}@{member['ip']}".lower())
|
||||
]
|
||||
if show_declined:
|
||||
row.append(" ".join(member["declined"]) or "-")
|
||||
if show_route:
|
||||
via = member.get("via")
|
||||
if via:
|
||||
row.append(f"via {labels.get(address_of(via), address_of(via))}")
|
||||
else:
|
||||
row.append("direct" if member.get("direct") or member["self"] else "unknown")
|
||||
node_rows.append(row)
|
||||
|
||||
alive_addresses = {address(member) for member in alive}
|
||||
role_rows = []
|
||||
for role in role_states(payload):
|
||||
holders = role["holders"]
|
||||
if role["state"] == "general":
|
||||
state = "ok"
|
||||
else:
|
||||
state = {
|
||||
"held": "ok",
|
||||
"off": "off (on demand)",
|
||||
"unmet": f"UNMET ({len(holders)}/{role['required']})",
|
||||
"duplicate": f"DUPLICATE ({len(holders)}/{role['required']})",
|
||||
}[role["state"]]
|
||||
if role["state"] == "general" and set(holders) == alive_addresses:
|
||||
names = "all nodes"
|
||||
else:
|
||||
names = ", ".join(labels[holder] for holder in holders) or "-"
|
||||
role_rows.append([role["name"], role["priority"], state, names])
|
||||
|
||||
hardware_rows = [[labels[address(member)]] + describe_stats(member.get("stats")) for member in alive]
|
||||
|
||||
off = [role["name"] for role in role_states(payload) if role["state"] == "off"]
|
||||
sections = [
|
||||
title,
|
||||
table(node_headers, node_rows),
|
||||
table(["ROLE", "PRIORITY", "STATE", "HOLDERS"], role_rows),
|
||||
table(["NODE", "CPU", "RAM", "DISK", "GPU", "UP"], hardware_rows),
|
||||
]
|
||||
if off:
|
||||
sections.append(f"Not running (on demand): {', '.join(off)}. Start one with: colonyctl role add {off[0]}")
|
||||
return "\n\n".join(sections)
|
||||
if len(matches) != 1:
|
||||
raise CliError(f"{'no node' if not matches else 'more than one node'} matches {name!r}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(prog="colonyctl", description="Inspect and control a running colony node.")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
connection = argparse.ArgumentParser(add_help=False)
|
||||
connection.add_argument("--host", default="127.0.0.1", help="node to ask (default: this machine)")
|
||||
connection.add_argument("--port", type=int, default=None, help="node port (default: listener.port from config)")
|
||||
connection.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
|
||||
|
||||
status = commands.add_parser("status", parents=[connection], help="show members, roles and hardware (live in a terminal)")
|
||||
status.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="seconds between refreshes")
|
||||
status.add_argument("--raw", action="store_true", help="print once and exit instead of refreshing")
|
||||
status.add_argument("--json", action="store_true", help="print the raw data as JSON once and exit")
|
||||
|
||||
role = commands.add_parser("role", help="manage roles on a node").add_subparsers(dest="role_command", required=True)
|
||||
add = role.add_parser("add", parents=[connection], help="ask the node to run an on-demand role")
|
||||
add.add_argument("name", help="role name, e.g. WebView (case does not matter)")
|
||||
|
||||
commands.add_parser("webview", parents=[connection], help="ask the node to run the web view (same as: role add WebView)")
|
||||
return parser
|
||||
|
||||
|
||||
def draw(out, text):
|
||||
out.write(HOME + text.replace("\n", CLEAR_LINE + "\n") + CLEAR_LINE + CLEAR_BELOW)
|
||||
out.flush()
|
||||
|
||||
|
||||
def watch(host, port, args, out):
|
||||
out.write(CLEAR_SCREEN)
|
||||
def show(args, render):
|
||||
if args.json or args.raw or not sys.stdout.isatty():
|
||||
print(render())
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
text = render_status(fetch_cluster(host, port, timeout=args.timeout))
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
text = f"colonyctl: cannot get status from {host}:{port}: {e}\nretrying..."
|
||||
footer = f"Every {args.interval:g}s. Updated {time.strftime('%H:%M:%S')}. Ctrl-C to quit."
|
||||
draw(out, f"{text}\n\n{footer}")
|
||||
text = render()
|
||||
except CliError as e:
|
||||
text = f"colonyctl: {e}\nretrying..."
|
||||
print(CLEAR + text + "\n\nCtrl-C to quit.", flush=True)
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
out.write("\n")
|
||||
return 0
|
||||
print()
|
||||
|
||||
|
||||
def add_role(args, port, name, out):
|
||||
def status(args):
|
||||
def render():
|
||||
cluster = call(args, "status")
|
||||
return json.dumps(cluster, indent=2) if args.json else render_status(cluster)
|
||||
show(args, render)
|
||||
|
||||
|
||||
def add_role(args, name):
|
||||
cluster = call(args, "status")
|
||||
on_demand = {role["name"].lower(): role["name"] for role in cluster["roles"] if role.get("on_demand")}
|
||||
if name.lower() not in on_demand:
|
||||
raise CliError(f"{name!r} is not an on-demand role, pick one of: {', '.join(on_demand.values())}")
|
||||
role = on_demand[name.lower()]
|
||||
|
||||
reply = call(args, "add_role", role=role)
|
||||
if not reply["accepted"]:
|
||||
raise CliError(f"the node did not take {role}: {reply['reason']}")
|
||||
print(f"{role} is running ({reply['reason']})")
|
||||
if role == "WebView":
|
||||
me = next(member for member in cluster["members"] if member["self"])
|
||||
print(f"Web view: http://{me['ip']}:{setting('web.port')}")
|
||||
|
||||
|
||||
def chat(args):
|
||||
peer = find_node(call(args, "status"), args.node)
|
||||
peer_id = f"{peer['hostname']}@{peer['ip']}"
|
||||
|
||||
if args.text is not None:
|
||||
call(args, "send_chat", peer=peer_id, text=args.text)
|
||||
print(f"Sent to {peer['hostname']}")
|
||||
else:
|
||||
history = lambda: ChatStorage().messages(peer_id)
|
||||
show(args, lambda: json.dumps(history(), indent=2) if args.json else render_chat(history(), peer["hostname"]))
|
||||
|
||||
|
||||
def build_parser():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--socket", default=str(control.CONTROL_SOCKET), help="control socket of the local node")
|
||||
common.add_argument("--timeout", type=float, default=5)
|
||||
|
||||
live = argparse.ArgumentParser(add_help=False)
|
||||
live.add_argument("--interval", type=float, default=1, help="seconds between refreshes")
|
||||
live.add_argument("--raw", action="store_true", help="print once instead of refreshing")
|
||||
live.add_argument("--json", action="store_true", help="print the raw data as JSON once")
|
||||
|
||||
parser = argparse.ArgumentParser(prog="colonyctl", description="Inspect and control the local colony node.")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
commands.add_parser("status", parents=[common, live], help="show nodes and roles")
|
||||
|
||||
role = commands.add_parser("role", help="manage roles").add_subparsers(dest="role_command", required=True)
|
||||
role.add_parser("add", parents=[common], help="run an on-demand role on this node").add_argument("name")
|
||||
|
||||
commands.add_parser("webview", parents=[common], help="same as: role add WebView")
|
||||
|
||||
chat = commands.add_parser("chat", parents=[common, live], help="show the chat with a node, or send it a message")
|
||||
chat.add_argument("node", help="hostname, ip:port or hostname@ip")
|
||||
chat.add_argument("text", nargs="?", help="message to send")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
payload = fetch_cluster(args.host, port, timeout=args.timeout)
|
||||
role = resolve_on_demand_role(payload, name)
|
||||
reply = request_role(args.host, port, role, timeout=args.timeout)
|
||||
except LookupError as e:
|
||||
if args.command == "status":
|
||||
status(args)
|
||||
elif args.command == "role":
|
||||
add_role(args, args.name)
|
||||
elif args.command == "webview":
|
||||
add_role(args, "WebView")
|
||||
elif args.command == "chat":
|
||||
chat(args)
|
||||
except CliError as e:
|
||||
print(f"colonyctl: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
print(f"colonyctl: cannot reach {args.host}:{port}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
node = next(member for member in payload["members"] if member["self"])
|
||||
if not reply.get("accepted"):
|
||||
print(f"colonyctl: {node['hostname']} did not take {role}: {reply.get('reason')}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"{role} is running on {node['hostname']} ({reply.get('reason')})", file=out)
|
||||
if role == "WebView":
|
||||
print(f"Web view: http://{node['ip']}:{setting('web.port')}", file=out)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None, out=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
out = sys.stdout if out is None else out
|
||||
port = args.port if args.port is not None else configuration.listener.port
|
||||
|
||||
if args.command == "role":
|
||||
return add_role(args, port, args.name, out)
|
||||
if args.command == "webview":
|
||||
return add_role(args, port, "WebView", out)
|
||||
|
||||
if not args.raw and not args.json and out.isatty():
|
||||
return watch(args.host, port, args, out)
|
||||
|
||||
try:
|
||||
payload = fetch_cluster(args.host, port, timeout=args.timeout)
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
print(f"colonyctl: cannot get status from {args.host}:{port}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(payload, indent=2) if args.json else render_status(payload), file=out)
|
||||
return 0
|
||||
@@ -11,15 +11,9 @@ class RoleContext:
|
||||
return self._network.execute(command, *args, **kwargs)
|
||||
|
||||
def execute_on(self, target, command, *args, **kwargs):
|
||||
"""Run a command on one particular node, going through a gateway if it cannot be dialled.
|
||||
|
||||
`target` is a Node or a MemberRecord, anything with `ip` and `port`, for example an item of
|
||||
`nodes_with_role(...)`. `execute` picks the node for you; this one does not.
|
||||
"""
|
||||
return self._network.send_command(target.ip, target.port, command, *args, **kwargs)
|
||||
|
||||
def execute_to_unregistered_node(self, ip, port, command, *args, **kwargs):
|
||||
"""The same as `execute_on` for a caller that only has an address."""
|
||||
return self._network.send_command(ip, port, command, *args, **kwargs)
|
||||
|
||||
def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None):
|
||||
@@ -37,6 +31,9 @@ class RoleContext:
|
||||
def peers(self):
|
||||
return self._network.get_all_nodes()
|
||||
|
||||
def node_by_id(self, node_id):
|
||||
return self._network.get_node_by_id(node_id)
|
||||
|
||||
def nodes_with_role(self, role):
|
||||
return self._network.get_nodes_of_role(role if isinstance(role, str) else role.__name__)
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
class ControlActions:
|
||||
def __init__(self, network):
|
||||
self.network = network
|
||||
self.actions = {
|
||||
"status": self.status,
|
||||
"add_role": self.add_role,
|
||||
"send_chat": self.send_chat,
|
||||
}
|
||||
|
||||
def run(self, payload):
|
||||
action = payload.get("action") if isinstance(payload, dict) else None
|
||||
handler = self.actions.get(action)
|
||||
if handler is None:
|
||||
raise ValueError(f"Unknown action {action!r}")
|
||||
|
||||
args = payload.get("args", {})
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("Action args must be a dict")
|
||||
return handler(**args)
|
||||
|
||||
def _role(self, name):
|
||||
role = self.network.get_self().get_role_instances().get(name)
|
||||
if role is None:
|
||||
raise LookupError(f"{name} is not running on this node")
|
||||
return role
|
||||
|
||||
def status(self):
|
||||
return self._role("Unit").show_cluster()
|
||||
|
||||
def add_role(self, role):
|
||||
return self._role("Unit").request_role(role)
|
||||
|
||||
def send_chat(self, peer, text):
|
||||
self._role("Chatter").send(peer, text)
|
||||
@@ -20,7 +20,11 @@ def parse_command(payload):
|
||||
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
|
||||
raise ValueError("Command args must be a list and kwargs a dict")
|
||||
|
||||
return role_name, command_name, tuple(args), kwargs
|
||||
caller_id = command.get("from")
|
||||
if not isinstance(caller_id, str):
|
||||
caller_id = None
|
||||
|
||||
return role_name, command_name, tuple(args), kwargs, caller_id
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
@@ -31,7 +35,7 @@ class Dispatcher:
|
||||
self.role_registry = role_registry
|
||||
|
||||
def dispatch(self, payload):
|
||||
role_name, command_name, args, kwargs = parse_command(payload)
|
||||
role_name, command_name, args, kwargs, caller_id = parse_command(payload)
|
||||
self.logger.debug(f"Dispatching payload for command {role_name}.{command_name}")
|
||||
|
||||
role_entry = self.role_registry.get_role_class_by_name(role_name)
|
||||
@@ -44,6 +48,6 @@ class Dispatcher:
|
||||
node = self.network.get_self()
|
||||
|
||||
self.logger.info(f"Executing {role_name}.{command_name} on local node {node.id}")
|
||||
result = node.execute_local(command, *args, **kwargs)
|
||||
result = node.execute_local(command, *args, caller_id=caller_id, **kwargs)
|
||||
self.logger.debug(f"Passing the results from {role_name}.{command_name}")
|
||||
return result
|
||||
@@ -8,7 +8,7 @@ from colony.core.network.relay import GATEWAY_ROLE, RelayError, build_forward, r
|
||||
from colony.core.network.requests import CommandRequest
|
||||
from colony.core.logger.logging import get_logger
|
||||
from colony.settings import configuration, setting
|
||||
from colony.transport.whisperer import ConnectFailed, Whisperer
|
||||
from colony.transport.whisperer import Whisperer, ConnectFailed
|
||||
from colony.utils.network import get_local_ip, get_hostname
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ class Network:
|
||||
def get_all_nodes(self):
|
||||
return self.nodes
|
||||
|
||||
def get_node_by_id(self, node_id: str) -> Optional[Node]:
|
||||
return next((node for node in self.nodes if node.id == node_id), None)
|
||||
|
||||
def get_nodes_of_role(self, role_name: str) -> List[Node]:
|
||||
return [node for node in self.nodes if role_name in node.roles]
|
||||
|
||||
@@ -125,7 +128,7 @@ class Network:
|
||||
return None
|
||||
|
||||
def send_command(self, ip, port, command, *args, timeout=None, **kwargs):
|
||||
payload = CommandRequest.form(command, *args, **kwargs).collapse()
|
||||
payload = CommandRequest.form(command, *args, **kwargs).collapse(sender=self.self_node.id)
|
||||
key = (ip, port)
|
||||
now = self.membership.now()
|
||||
gateways = self._gateways(key, now)
|
||||
@@ -184,14 +187,6 @@ class Network:
|
||||
self._direct_ok[key] = self.membership.now()
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def execute_to_unregistered_node(ip, port, command: Enum, *args, timeout=None, **kwargs):
|
||||
request = CommandRequest.form(command, *args, **kwargs)
|
||||
|
||||
options = {} if timeout is None else {"timeout": timeout}
|
||||
return Whisperer(ip, port).send(request.collapse(), **options)
|
||||
|
||||
|
||||
def load_network_from_snapshot(self, file_path: str = "storages/default/colony_snapshot.json"):
|
||||
self.logger.info(f"Loading network snapshot from {file_path}")
|
||||
nodes = []
|
||||
|
||||
@@ -15,17 +15,24 @@ class HardwareStatistics:
|
||||
self.os_info = None
|
||||
self.boot_time = None
|
||||
|
||||
@staticmethod
|
||||
def _try(read, fallback=None):
|
||||
try:
|
||||
return read()
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def collect():
|
||||
self = HardwareStatistics()
|
||||
self.cpu = self._get_cpu_stats()
|
||||
self.mem = self._get_memory_stats()
|
||||
self.gpu = self._get_gpu_stats()
|
||||
self.disk = self._get_disk_stats()
|
||||
self.os_info = self._get_os_info()
|
||||
self.boot_time = datetime.datetime.fromtimestamp(
|
||||
psutil.boot_time()
|
||||
).isoformat()
|
||||
self.cpu = self._try(self._get_cpu_stats)
|
||||
self.mem = self._try(self._get_memory_stats)
|
||||
self.gpu = self._try(self._get_gpu_stats, [])
|
||||
self.disk = self._try(self._get_disk_stats, [])
|
||||
self.os_info = self._try(self._get_os_info)
|
||||
self.boot_time = self._try(
|
||||
lambda: datetime.datetime.fromtimestamp(psutil.boot_time()).isoformat()
|
||||
)
|
||||
return self
|
||||
|
||||
def to_dict(self):
|
||||
@@ -63,6 +70,7 @@ class HardwareStatistics:
|
||||
stats["gpu_mem_total_gb"] = round(sum(g["memory_total"] for g in self.gpu) / 1024, 2)
|
||||
stats["gpu_mem_used_gb"] = round(sum(g["memory_used"] for g in self.gpu) / 1024, 2)
|
||||
|
||||
if self.boot_time:
|
||||
stats["uptime_hours"] = HardwareScorer.longevity_score(self)
|
||||
return stats
|
||||
|
||||
@@ -87,7 +95,7 @@ class HardwareStatistics:
|
||||
return {
|
||||
"cores": psutil.cpu_count(logical=False),
|
||||
"threads": psutil.cpu_count(logical=True),
|
||||
"usage_percent": psutil.cpu_percent(interval=0.2)
|
||||
"usage_percent": self._try(lambda: psutil.cpu_percent(interval=0.2))
|
||||
}
|
||||
|
||||
def _get_memory_stats(self):
|
||||
@@ -100,10 +108,7 @@ class HardwareStatistics:
|
||||
}
|
||||
|
||||
def _get_gpu_stats(self):
|
||||
try:
|
||||
gpus = GPUtil.getGPUs()
|
||||
except Exception:
|
||||
return []
|
||||
if not gpus:
|
||||
return []
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import List
|
||||
|
||||
from colony.core.network.hardware import HardwareStatistics
|
||||
from colony.core.network.requests import CommandRequest
|
||||
from colony.transport.whisperer import Whisperer
|
||||
|
||||
|
||||
class Node:
|
||||
@@ -14,11 +13,6 @@ class Node:
|
||||
self.roles = roles
|
||||
self.port = port
|
||||
|
||||
def execute(self, command: Enum, *args, **kwargs):
|
||||
request = CommandRequest.form(command, *args, **kwargs)
|
||||
|
||||
return Whisperer(self.ip, self.port).send(request.collapse())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -42,7 +36,7 @@ class LocalNode(Node):
|
||||
def set_role_instances(self, roles):
|
||||
self.__roles = dict(roles)
|
||||
|
||||
def execute_local(self, command: Enum, *args, **kwargs):
|
||||
def execute_local(self, command: Enum, *args, caller_id=None, **kwargs):
|
||||
request = CommandRequest.form(command, *args, **kwargs)
|
||||
|
||||
role = self.__roles.get(request.role)
|
||||
@@ -52,5 +46,6 @@ class LocalNode(Node):
|
||||
return role.execute_local(
|
||||
request.id,
|
||||
*request.args,
|
||||
caller_id=caller_id,
|
||||
**request.kwargs
|
||||
)
|
||||
@@ -31,12 +31,13 @@ class CommandRequest:
|
||||
def form(cls, id: Enum, *args: Any, **kwargs: Any):
|
||||
return cls(id, args, kwargs)
|
||||
|
||||
def collapse(self):
|
||||
return {
|
||||
"command": {
|
||||
def collapse(self, sender=None):
|
||||
command = {
|
||||
"role": self.role,
|
||||
"name": self.name,
|
||||
"args": self.args,
|
||||
"kwargs": self.kwargs
|
||||
}
|
||||
}
|
||||
if sender is not None:
|
||||
command["from"] = sender
|
||||
return {"command": command}
|
||||
Whitespace-only changes.
@@ -0,0 +1,51 @@
|
||||
from enum import Enum
|
||||
|
||||
from colony.core.roles.role import Role, Command
|
||||
from colony.core.storages.chat import ChatStorage
|
||||
from colony.settings import setting
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
|
||||
class Chatter(Role):
|
||||
class COMMANDS(Enum):
|
||||
RECEIVE = 'Chatter.Receive'
|
||||
|
||||
def __init__(self, context, storage=None):
|
||||
commands = {
|
||||
self.COMMANDS.RECEIVE.value: Command(self.receive, with_caller=True),
|
||||
}
|
||||
|
||||
self.storage = storage or ChatStorage()
|
||||
self.max_messages = setting("chat.max_messages")
|
||||
|
||||
super().__init__(commands, context)
|
||||
|
||||
def send(self, peer_id, text):
|
||||
node = self.context.node_by_id(peer_id)
|
||||
if node is None or "Chatter" not in node.roles:
|
||||
raise ValueError(f"{peer_id} is not running Chatter")
|
||||
|
||||
response = self.context.execute_on(node, self.COMMANDS.RECEIVE, text)
|
||||
if response.get("status") != "ok":
|
||||
raise ValueError(f"{peer_id} refused the message")
|
||||
|
||||
self.storage.append(peer_id, "out", text, self.max_messages)
|
||||
|
||||
def receive(self, text, caller_id):
|
||||
if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH:
|
||||
raise ValueError("Invalid message")
|
||||
|
||||
self.storage.append(caller_id, "in", text, self.max_messages)
|
||||
return True
|
||||
|
||||
def history(self, peer_id, after=0):
|
||||
return [message for message in self.storage.messages(peer_id) if message["seq"] > after]
|
||||
|
||||
def inbox(self):
|
||||
peers = {}
|
||||
for peer_id in self.storage.peers():
|
||||
messages = self.storage.messages(peer_id)
|
||||
incoming = [message for message in messages if message["direction"] == "in"]
|
||||
peers[peer_id] = {"incoming": messages[-1]["received"] if messages else 0, "last": incoming[-1] if incoming else None}
|
||||
return peers
|
||||
@@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from enum import Enum
|
||||
|
||||
from colony.core.network.membership import MemberRecord
|
||||
from colony.core.storage import DEFAULT_STORAGE_FILES
|
||||
from colony.core.storages.default import DefaultStorage
|
||||
from colony.core.roles.gossip.role import Gossip
|
||||
from colony.core.roles.role import Role
|
||||
from colony.core.roles.unit.role import Unit
|
||||
@@ -106,10 +106,10 @@ class Explorer(Role):
|
||||
self.logger.info("Snapshot updated successfully")
|
||||
|
||||
def get_snapshot(self):
|
||||
return self.context.execute(VaultWarden.COMMANDS.OPEN, DEFAULT_STORAGE_FILES.COLONY_SNAPSHOT.value)
|
||||
return self.context.execute(VaultWarden.COMMANDS.OPEN, DefaultStorage.COLONY_SNAPSHOT)
|
||||
|
||||
def set_snapshot(self, snapshot):
|
||||
return self.context.execute(VaultWarden.COMMANDS.OVERWRITE, DEFAULT_STORAGE_FILES.COLONY_SNAPSHOT.value, snapshot)
|
||||
return self.context.execute(VaultWarden.COMMANDS.OVERWRITE, DefaultStorage.COLONY_SNAPSHOT, snapshot)
|
||||
|
||||
def _hosts_to_scan(self):
|
||||
configured = configuration.get("explorer.hosts")
|
||||
|
||||
@@ -25,6 +25,19 @@ class RoleManager:
|
||||
if wanted != current:
|
||||
self.state_manager.set(StateManager.Data.Roles, wanted)
|
||||
|
||||
def ensure_on_demand_roles(self, role_names):
|
||||
roles = self.role_registry.roles
|
||||
on_demand = []
|
||||
for role_name in role_names:
|
||||
if role_name in roles and roles[role_name].on_demand:
|
||||
on_demand.append(role_name)
|
||||
else:
|
||||
self.logger.warning(f"Ignoring {role_name} in roles.on_demand, it is not an on-demand role")
|
||||
current = self.state_manager.get(StateManager.Data.Roles)
|
||||
wanted = list(dict.fromkeys(current + on_demand))
|
||||
if wanted != current:
|
||||
self.state_manager.set(StateManager.Data.Roles, wanted)
|
||||
|
||||
def initialize_roles(self):
|
||||
self.logger.info("Initializing roles...")
|
||||
context = RoleContext(self.network, self)
|
||||
|
||||
@@ -27,7 +27,6 @@ class Replicator(Role):
|
||||
super().__init__(commands, context)
|
||||
|
||||
def start(self):
|
||||
# catch up
|
||||
replicator_checksums = dict()
|
||||
|
||||
for node in self.context.nodes_with_role(Replicator):
|
||||
|
||||
@@ -5,8 +5,9 @@ from colony.core.logger.logging import get_logger
|
||||
|
||||
|
||||
class Command:
|
||||
def __init__(self, callback):
|
||||
def __init__(self, callback, with_caller=False):
|
||||
self.callback = callback
|
||||
self.with_caller = with_caller
|
||||
|
||||
class Role:
|
||||
CONTINUOUS = None
|
||||
@@ -56,10 +57,13 @@ class Role:
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def execute_local(self, name, *args, **kwargs):
|
||||
def execute_local(self, name, *args, caller_id=None, **kwargs):
|
||||
command = self.commands.get(name)
|
||||
|
||||
if not command:
|
||||
raise ValueError(f"No command {name}")
|
||||
|
||||
if command.with_caller:
|
||||
kwargs["caller_id"] = caller_id
|
||||
|
||||
return command.callback(*args, **kwargs)
|
||||
@@ -1,3 +1,4 @@
|
||||
import hmac
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,9 +9,29 @@ STATIC_DIR = Path(__file__).parent / "static"
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def create_app(get_status):
|
||||
def create_app(get_status, get_chatter, credentials=None):
|
||||
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static")
|
||||
|
||||
def no_store(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
def authorized():
|
||||
auth = request.authorization
|
||||
if auth is None or auth.type != "basic":
|
||||
return False
|
||||
username, password = credentials
|
||||
return hmac.compare_digest((auth.username or "").encode(), username.encode()) & \
|
||||
hmac.compare_digest((auth.password or "").encode(), password.encode())
|
||||
|
||||
@app.before_request
|
||||
def require_login():
|
||||
if credentials is not None and not authorized():
|
||||
response = jsonify(error="login required")
|
||||
response.status_code = 401
|
||||
response.headers["WWW-Authenticate"] = 'Basic realm="Colony", charset="UTF-8"'
|
||||
return response
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return app.send_static_file("index.html")
|
||||
@@ -23,8 +44,45 @@ def create_app(get_status):
|
||||
app.logger.exception("Could not build the status")
|
||||
return jsonify(error="status unavailable"), 500
|
||||
|
||||
response = jsonify(payload)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
return no_store(jsonify(payload))
|
||||
|
||||
@app.get("/api/chat")
|
||||
def chat_history():
|
||||
chatter = get_chatter()
|
||||
if chatter is None:
|
||||
return jsonify(error="Chatter is not running on this node"), 409
|
||||
|
||||
peer = request.args.get("peer")
|
||||
after = request.args.get("after", "0")
|
||||
if not peer or not after.isdigit():
|
||||
return jsonify(error="peer and a numeric after are required"), 400
|
||||
|
||||
return no_store(jsonify(messages=chatter.history(peer, int(after))))
|
||||
|
||||
@app.post("/api/chat")
|
||||
def chat_send():
|
||||
chatter = get_chatter()
|
||||
if chatter is None:
|
||||
return jsonify(error="Chatter is not running on this node"), 409
|
||||
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict) or not isinstance(body.get("peer"), str) or not isinstance(body.get("text"), str):
|
||||
return jsonify(error="peer and text are required"), 400
|
||||
|
||||
try:
|
||||
chatter.send(body["peer"], body["text"])
|
||||
except ValueError as e:
|
||||
return jsonify(error=str(e)), 400
|
||||
except OSError:
|
||||
return jsonify(error=f"{body['peer']} cannot be reached"), 502
|
||||
return jsonify(sent=True)
|
||||
|
||||
@app.get("/api/chat/inbox")
|
||||
def chat_inbox():
|
||||
chatter = get_chatter()
|
||||
if chatter is None:
|
||||
return jsonify(error="Chatter is not running on this node"), 409
|
||||
|
||||
return no_store(jsonify(chatter.inbox()))
|
||||
|
||||
return app
|
||||
@@ -21,7 +21,8 @@ class WebView(Role):
|
||||
|
||||
def __init__(self, context):
|
||||
host, port = setting("web.host"), setting("web.port")
|
||||
self.app = create_app(self._status)
|
||||
username, password = setting("web.username"), setting("web.password")
|
||||
self.app = create_app(self._status, self._chatter, (username, password) if password else None)
|
||||
try:
|
||||
self._server = make_server(host, port, self.app, threaded=True)
|
||||
except SystemExit:
|
||||
@@ -39,6 +40,9 @@ class WebView(Role):
|
||||
raise RuntimeError("The Unit role is not available")
|
||||
return unit
|
||||
|
||||
def _chatter(self):
|
||||
return self.context.self().get_role_instances().get("Chatter")
|
||||
|
||||
def _status(self, compact=False):
|
||||
payload = self._unit().show_cluster()
|
||||
own_key = next((member["ip"], member["port"]) for member in payload["members"] if member["self"])
|
||||
@@ -140,6 +144,8 @@ class WebView(Role):
|
||||
threading.Thread(target=self._refresh_loop, daemon=True, name="WebViewTopology").start()
|
||||
|
||||
self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}")
|
||||
if not setting("web.password"):
|
||||
self.logger.warning("The web view has no password, set web.password to require one")
|
||||
|
||||
def stop(self):
|
||||
super().stop()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const STORAGE_KEY = "colony.map.v1";
|
||||
const PAD = 48;
|
||||
const FIT_MS = 260;
|
||||
const COMPACT_MAX_WIDTH = 600;
|
||||
|
||||
const stage = document.getElementById("stage");
|
||||
const svgRoot = document.getElementById("map");
|
||||
@@ -149,10 +150,29 @@
|
||||
updateControls();
|
||||
}
|
||||
|
||||
function regionView(region) {
|
||||
return C.viewFor({ x: region.x - 40, y: region.y - 40, w: region.w + 80, h: region.h + 80 }, size, 24);
|
||||
}
|
||||
|
||||
function fitRegion(net) {
|
||||
const region = map && map.regionAt.get(net);
|
||||
if (!region) return;
|
||||
animateTo(C.viewFor({ x: region.x - 40, y: region.y - 40, w: region.w + 80, h: region.h + 80 }, size, 24), true);
|
||||
animateTo(regionView(region), true);
|
||||
}
|
||||
|
||||
// On a phone, fitting the whole map shrinks it below the zoom where names and stats are drawn,
|
||||
// so the first view is the region of this node instead. COMPACT_MAX_WIDTH matches the phone
|
||||
// breakpoint in style.css. This only sets `view` and `autoFit`; the caller applies the view afterwards.
|
||||
let startedCompact = false;
|
||||
function startCompact() {
|
||||
if (startedCompact) return;
|
||||
startedCompact = true; // decided once, on the first map: later changes to the window or the topology must not move the view
|
||||
if (size.w > COMPACT_MAX_WIDTH) return;
|
||||
const self = model.nodes.find((node) => node.self);
|
||||
const region = self && map.regionAt.get(self.net);
|
||||
if (!region) return;
|
||||
autoFit = false;
|
||||
view = regionView(region);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
@@ -279,15 +299,21 @@
|
||||
const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) };
|
||||
[title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part));
|
||||
|
||||
const badge = svg("g", { class: "badge" });
|
||||
const badgeText = svg("text", { "text-anchor": "middle", y: 3.5 });
|
||||
badge.appendChild(svg("circle", { r: 9 }));
|
||||
badge.appendChild(badgeText);
|
||||
|
||||
/* Hardware: three bars with their numbers, and a footer. */
|
||||
const stats = svg("g", { class: "stats" });
|
||||
const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W));
|
||||
const footer = svg("text", { class: "st-foot" });
|
||||
stats.appendChild(footer);
|
||||
group.appendChild(stats);
|
||||
group.appendChild(badge);
|
||||
|
||||
layers.nodes.appendChild(group);
|
||||
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, gateway: node.gateway };
|
||||
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, badge: badge, badgeText: badgeText, gateway: node.gateway };
|
||||
}
|
||||
|
||||
function drawNodes() {
|
||||
@@ -344,11 +370,28 @@
|
||||
put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10);
|
||||
});
|
||||
prune(nodeRecords, present);
|
||||
drawUnread();
|
||||
}
|
||||
|
||||
function drawUnread() {
|
||||
let total = 0;
|
||||
nodeRecords.forEach((record, key) => {
|
||||
const info = model.byKey.get(key);
|
||||
const count = info ? unreadOf(info.id) : 0;
|
||||
total += count;
|
||||
const corner = record.node.r * 0.8;
|
||||
put(record.badge, "transform", "translate(" + corner + "," + -corner + ")");
|
||||
put(record.badge, "display", count ? "inline" : "none");
|
||||
putText(record.badgeText, count > 9 ? "9+" : String(count));
|
||||
});
|
||||
document.title = total ? "(" + total + ") Colony" : "Colony";
|
||||
drawBell(total);
|
||||
}
|
||||
|
||||
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
|
||||
function describe(record, info) {
|
||||
const text = C.describeNode(model, info, routes);
|
||||
const unread = unreadOf(info.id);
|
||||
const text = C.describeNode(model, info, routes) + (unread ? "\n" + C.plural(unread, "unread message") : "");
|
||||
putText(record.title, text);
|
||||
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
|
||||
}
|
||||
@@ -482,6 +525,7 @@
|
||||
drawNodes();
|
||||
drawSpokes();
|
||||
drawPorts();
|
||||
startCompact();
|
||||
if (autoFit) fitAll(false);
|
||||
else setView(view, false);
|
||||
drawTrace();
|
||||
@@ -507,6 +551,7 @@
|
||||
} else {
|
||||
drawStats();
|
||||
}
|
||||
updateChat();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- controls */
|
||||
@@ -576,6 +621,7 @@
|
||||
else announce("Selection cleared");
|
||||
drawRegions();
|
||||
drawTrace();
|
||||
updateChat();
|
||||
}
|
||||
|
||||
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
|
||||
@@ -827,6 +873,349 @@
|
||||
drawTrace();
|
||||
});
|
||||
|
||||
const [chatPanel, chatTitle, chatStatus, chatLog, chatEmpty, chatError, chatForm, chatInput, chatSend, chatClose, chatLive] = ["chat", "chat-title", "chat-status", "chat-log", "chat-empty", "chat-error", "chat-form", "chat-input", "chat-send", "chat-close", "chat-live"].map((id) => document.getElementById(id));
|
||||
let chat = { peer: null, after: 0, loaded: false, loading: false };
|
||||
|
||||
const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null);
|
||||
const chatTime = (at) => {
|
||||
const date = new Date(at);
|
||||
return isNaN(date) ? "" : date.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
||||
};
|
||||
|
||||
function updateChat() {
|
||||
const peer = chatTarget();
|
||||
chatPanel.hidden = !peer;
|
||||
stage.classList.toggle("chatting", Boolean(peer));
|
||||
if (!peer) {
|
||||
chat = { peer: null, after: 0, loaded: false, loading: false };
|
||||
return;
|
||||
}
|
||||
if (chat.peer !== peer.id) {
|
||||
chat = { peer: peer.id, hostname: peer.hostname, after: 0, loaded: false, loading: false };
|
||||
chatTitle.textContent = "Chat with " + peer.hostname;
|
||||
chatLog.replaceChildren();
|
||||
chatEmpty.hidden = true;
|
||||
chatError.hidden = true;
|
||||
chatInput.value = "";
|
||||
}
|
||||
const state = C.chatState(model, peer.key);
|
||||
const ignored = isIgnored(peer.id);
|
||||
const status = [ignored ? "You are ignoring " + peer.hostname + " in this browser: no notifications or unread counts for them." : "", state.text].filter(Boolean).join(" ");
|
||||
chatStatus.textContent = status;
|
||||
chatStatus.hidden = !status;
|
||||
chatForm.hidden = !state.send;
|
||||
chatIgnore.hidden = !inbox || !state.read;
|
||||
chatIgnore.textContent = ignored ? "Unignore" : "Ignore";
|
||||
chatIgnore.title = ignored ? "Get notified about " + peer.hostname + " again" : "Stop notifications and unread counts for " + peer.hostname;
|
||||
chatIgnore.setAttribute("aria-pressed", ignored ? "true" : "false");
|
||||
if (state.read) loadChat();
|
||||
}
|
||||
|
||||
function paintChatMessages(messages, fresh) {
|
||||
const atBottom = chatLog.scrollTop + chatLog.clientHeight >= chatLog.scrollHeight - 8;
|
||||
messages.forEach((message) => {
|
||||
const item = html("li", undefined, "msg " + (message.direction === "out" ? "out" : "in"));
|
||||
item.appendChild(html("span", message.direction === "out" ? "You" : chat.hostname, "msg-from sr-only"));
|
||||
item.appendChild(html("p", message.text, "msg-text"));
|
||||
const time = html("time", chatTime(message.at), "msg-time");
|
||||
time.dateTime = message.at;
|
||||
item.appendChild(time);
|
||||
chatLog.appendChild(item);
|
||||
});
|
||||
if (atBottom || !fresh) chatLog.scrollTop = chatLog.scrollHeight;
|
||||
const incoming = messages.filter((message) => message.direction === "in");
|
||||
if (fresh && incoming.length) chatLive.textContent = chat.hostname + " says: " + incoming[incoming.length - 1].text;
|
||||
}
|
||||
|
||||
async function loadChat() {
|
||||
const current = chat;
|
||||
if (current.loading || !current.peer) return;
|
||||
current.loading = true;
|
||||
try {
|
||||
const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.after, { cache: "no-store" });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
|
||||
if (chat !== current) return;
|
||||
paintChatMessages(body.messages, current.loaded);
|
||||
if (body.messages.length) current.after = body.messages[body.messages.length - 1].seq;
|
||||
current.loaded = true;
|
||||
if (!document.hidden) markSeen(current.peer);
|
||||
chatEmpty.hidden = chatLog.children.length > 0;
|
||||
if (chatError.dataset.from === "load") chatError.hidden = true;
|
||||
} catch (error) {
|
||||
if (chat === current) {
|
||||
chatError.textContent = "Cannot load the messages: " + error.message;
|
||||
chatError.dataset.from = "load";
|
||||
chatError.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
current.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const text = chatInput.value.trim();
|
||||
const current = chat;
|
||||
if (!text || !current.peer || chatSend.disabled) return;
|
||||
chatInput.disabled = chatSend.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ peer: current.peer, text: text }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
|
||||
if (chat === current) {
|
||||
chatInput.value = "";
|
||||
chatError.hidden = true;
|
||||
loadChat();
|
||||
}
|
||||
} catch (error) {
|
||||
if (chat === current) {
|
||||
chatError.textContent = "Not sent: " + error.message;
|
||||
chatError.dataset.from = "send";
|
||||
chatError.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
chatInput.disabled = chatSend.disabled = false;
|
||||
if (!chatPanel.hidden) chatInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ notifications */
|
||||
|
||||
const SEEN_KEY = "colony.chat.seen.v1";
|
||||
const IGNORED_KEY = "colony.chat.ignored.v1";
|
||||
const TOAST_MS = 8000;
|
||||
const TOAST_LIMIT = 3;
|
||||
const [notifyButton, toastBox, chatIgnore, bellWrap, bell, bellCount, inboxPanel, inboxList, inboxEmpty, inboxRead] = ["notify", "toasts", "chat-ignore", "bell-wrap", "bell", "bell-count", "inbox-panel", "inbox-list", "inbox-empty", "inbox-read"].map((id) => document.getElementById(id));
|
||||
let inbox = null;
|
||||
let inboxText = "";
|
||||
let inboxBusy = false;
|
||||
let inboxSignature = "";
|
||||
let seen = loadStored(SEEN_KEY);
|
||||
const ignoredPeers = new Set(Object.keys(loadStored(IGNORED_KEY) || {}));
|
||||
|
||||
function loadStored(key) {
|
||||
try {
|
||||
const value = JSON.parse(window.localStorage.getItem(key));
|
||||
return value && typeof value === "object" ? value : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveStored(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
/* private mode: this is forgotten with the page */
|
||||
}
|
||||
}
|
||||
|
||||
const saveSeen = () => saveStored(SEEN_KEY, seen);
|
||||
const isIgnored = (peerId) => ignoredPeers.has(peerId);
|
||||
const unreadOf = (peerId) => {
|
||||
const entry = inbox && !isIgnored(peerId) ? inbox[peerId] : null;
|
||||
return entry ? Math.max(0, entry.incoming - ((seen && seen[peerId]) || 0)) : 0;
|
||||
};
|
||||
const peerName = (peerId) => {
|
||||
const node = model && model.nodes.find((entry) => entry.id === peerId);
|
||||
return node ? node.hostname : peerId.split("@")[0];
|
||||
};
|
||||
|
||||
function markSeen(peerId) {
|
||||
const entry = inbox && inbox[peerId];
|
||||
if (!entry || !seen || seen[peerId] === entry.incoming) return;
|
||||
seen[peerId] = entry.incoming;
|
||||
saveSeen();
|
||||
if (model) drawUnread();
|
||||
}
|
||||
|
||||
function drawBell(total) {
|
||||
bellWrap.hidden = !inbox;
|
||||
if (!inbox) toggleInbox(false);
|
||||
bellCount.hidden = !total;
|
||||
bellCount.textContent = total > 99 ? "99+" : String(total);
|
||||
bell.classList.toggle("unread", total > 0);
|
||||
bell.setAttribute("aria-label", total ? "Messages, " + C.plural(total, "unread message") : "Messages");
|
||||
drawInbox();
|
||||
}
|
||||
|
||||
function drawInbox() {
|
||||
if (inboxPanel.hidden || !inbox) return;
|
||||
const conversations = Object.keys(inbox)
|
||||
.filter((peerId) => inbox[peerId].last && !isIgnored(peerId))
|
||||
.sort((a, b) => (inbox[b].last.at || "").localeCompare(inbox[a].last.at || ""));
|
||||
const signature = JSON.stringify(conversations.map((peerId) => [peerId, peerName(peerId), unreadOf(peerId), inbox[peerId].last]));
|
||||
if (signature === inboxSignature) return;
|
||||
inboxSignature = signature;
|
||||
inboxList.replaceChildren();
|
||||
conversations.forEach((peerId) => {
|
||||
const unread = unreadOf(peerId);
|
||||
const last = inbox[peerId].last;
|
||||
const row = html("button", undefined, "inbox-row" + (unread ? " unread" : ""));
|
||||
row.type = "button";
|
||||
row.appendChild(html("span", peerName(peerId), "inbox-name"));
|
||||
const time = html("time", chatTime(last.at), "inbox-time");
|
||||
time.dateTime = last.at;
|
||||
row.appendChild(time);
|
||||
row.appendChild(html("span", last.text, "inbox-text"));
|
||||
if (unread) row.appendChild(html("span", unread > 99 ? "99+" : String(unread), "inbox-count"));
|
||||
row.setAttribute("aria-label", peerName(peerId) + (unread ? ", " + C.plural(unread, "unread message") : "") + ": " + last.text);
|
||||
row.addEventListener("click", () => openChat(peerId));
|
||||
const item = html("li");
|
||||
item.appendChild(row);
|
||||
inboxList.appendChild(item);
|
||||
});
|
||||
inboxEmpty.hidden = conversations.length > 0;
|
||||
inboxRead.disabled = !conversations.some((peerId) => unreadOf(peerId));
|
||||
}
|
||||
|
||||
function toggleInbox(open) {
|
||||
if (open === !inboxPanel.hidden) return;
|
||||
inboxPanel.hidden = !open;
|
||||
inboxSignature = "";
|
||||
bell.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) {
|
||||
toastBox.replaceChildren();
|
||||
updateNotifyButton();
|
||||
drawInbox();
|
||||
}
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
Object.keys(inbox).forEach((peerId) => (seen[peerId] = inbox[peerId].incoming));
|
||||
saveSeen();
|
||||
drawUnread();
|
||||
drawStats();
|
||||
}
|
||||
|
||||
function openChat(peerId) {
|
||||
const node = model && model.nodes.find((entry) => entry.id === peerId);
|
||||
toggleInbox(false);
|
||||
if (!node) return;
|
||||
focusKey = node.key;
|
||||
if (!isPinned(node.key)) select({ kind: "node", id: node.key });
|
||||
chatInput.focus();
|
||||
}
|
||||
|
||||
function showToast(peerId, name, text) {
|
||||
const toast = html("button", undefined, "toast");
|
||||
toast.type = "button";
|
||||
toast.appendChild(html("strong", name));
|
||||
toast.appendChild(html("span", text));
|
||||
toast.addEventListener("click", () => {
|
||||
toast.remove();
|
||||
openChat(peerId);
|
||||
});
|
||||
toastBox.appendChild(toast);
|
||||
while (toastBox.children.length > TOAST_LIMIT) toastBox.firstChild.remove();
|
||||
setTimeout(() => toast.remove(), TOAST_MS);
|
||||
}
|
||||
|
||||
function notifyMessage(peerId, message, count) {
|
||||
const name = peerName(peerId);
|
||||
const text = count > 1 ? C.plural(count, "new message") + ", last: " + message.text : message.text;
|
||||
if (inboxPanel.hidden) showToast(peerId, name, text);
|
||||
if (!("Notification" in window) || Notification.permission !== "granted" || (!document.hidden && document.hasFocus())) return;
|
||||
const notification = new Notification("Message from " + name, { body: text, tag: "colony-chat-" + peerId });
|
||||
notification.addEventListener("click", () => {
|
||||
window.focus();
|
||||
openChat(peerId);
|
||||
notification.close();
|
||||
});
|
||||
}
|
||||
|
||||
function handleInbox(next) {
|
||||
const previous = inbox;
|
||||
inbox = next;
|
||||
if (!seen) {
|
||||
seen = {};
|
||||
Object.keys(next).forEach((peerId) => (seen[peerId] = next[peerId].incoming));
|
||||
saveSeen();
|
||||
}
|
||||
Object.keys(next).forEach((peerId) => {
|
||||
const entry = next[peerId];
|
||||
const before = previous ? (previous[peerId] || { incoming: 0 }).incoming : entry.incoming;
|
||||
if (entry.incoming <= before || isIgnored(peerId) || !entry.last) return;
|
||||
if (chat.peer === peerId && !document.hidden) return;
|
||||
notifyMessage(peerId, entry.last, entry.incoming - before);
|
||||
});
|
||||
if (model) {
|
||||
drawUnread();
|
||||
drawStats();
|
||||
updateChat();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollInbox() {
|
||||
if (inboxBusy) return;
|
||||
inboxBusy = true;
|
||||
try {
|
||||
const response = await fetch("/api/chat/inbox", { cache: "no-store" });
|
||||
if (response.status === 409 && inbox) {
|
||||
inboxText = "";
|
||||
handleInbox(null);
|
||||
}
|
||||
if (!response.ok) return;
|
||||
const text = await response.text();
|
||||
if (text === inboxText) return;
|
||||
inboxText = text;
|
||||
handleInbox(JSON.parse(text));
|
||||
} catch (error) {
|
||||
/* the status refresh already shows when the node cannot be reached */
|
||||
} finally {
|
||||
inboxBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateNotifyButton() {
|
||||
notifyButton.hidden = !("Notification" in window) || !window.isSecureContext || Notification.permission !== "default";
|
||||
}
|
||||
|
||||
bell.addEventListener("click", () => toggleInbox(inboxPanel.hidden));
|
||||
inboxRead.addEventListener("click", markAllRead);
|
||||
bellWrap.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape" || inboxPanel.hidden) return;
|
||||
event.preventDefault();
|
||||
toggleInbox(false);
|
||||
bell.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!inboxPanel.hidden && !bellWrap.contains(event.target)) toggleInbox(false);
|
||||
});
|
||||
|
||||
notifyButton.addEventListener("click", async () => {
|
||||
await Notification.requestPermission();
|
||||
updateNotifyButton();
|
||||
});
|
||||
|
||||
chatIgnore.addEventListener("click", () => {
|
||||
const peerId = chat.peer;
|
||||
if (!peerId) return;
|
||||
const ignored = !isIgnored(peerId);
|
||||
if (ignored) ignoredPeers.add(peerId);
|
||||
else ignoredPeers.delete(peerId);
|
||||
saveStored(IGNORED_KEY, Object.fromEntries(Array.from(ignoredPeers, (id) => [id, true])));
|
||||
if (!ignored) markSeen(peerId);
|
||||
announce((ignored ? "Ignoring " : "No longer ignoring ") + chat.hostname);
|
||||
drawUnread();
|
||||
drawStats();
|
||||
updateChat();
|
||||
});
|
||||
|
||||
chatClose.addEventListener("click", () => select(null));
|
||||
chatPanel.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
const key = selection && selection.id;
|
||||
select(null);
|
||||
if (key && model.byKey.has(key)) focusNode(key);
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------------- data */
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(stage);
|
||||
@@ -852,10 +1241,15 @@
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) refresh();
|
||||
if (document.hidden) return;
|
||||
refresh();
|
||||
if (chat.peer && chat.loaded) markSeen(chat.peer);
|
||||
});
|
||||
svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h);
|
||||
updateControls();
|
||||
updateNotifyButton();
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
pollInbox();
|
||||
setInterval(pollInbox, REFRESH_MS);
|
||||
})();
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Colony</title>
|
||||
<script>
|
||||
/* The Android app asks for ?theme=dark: a WebView reports the theme of its host app, not the phone's setting. */
|
||||
const theme = new URLSearchParams(location.search).get("theme");
|
||||
if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -11,6 +16,22 @@
|
||||
<div class="headline">
|
||||
<h1>Colony</h1>
|
||||
<p id="summary" class="summary">Loading...</p>
|
||||
<div id="bell-wrap" class="bell-wrap" hidden>
|
||||
<button id="bell" type="button" class="bell" aria-expanded="false" aria-controls="inbox-panel" aria-label="Messages" title="Messages">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 14V9a5 5 0 0 1 10 0v5l1.5 1.5h-13zM8.2 17.5a2 2 0 0 0 3.6 0"/></svg>
|
||||
<span id="bell-count" class="bell-count" hidden></span>
|
||||
</button>
|
||||
<div id="toasts" class="toasts" aria-live="polite"></div>
|
||||
<section id="inbox-panel" class="inbox-panel" aria-labelledby="inbox-title" hidden>
|
||||
<div class="inbox-head">
|
||||
<h2 id="inbox-title">Messages</h2>
|
||||
<button id="inbox-read" type="button">Mark all read</button>
|
||||
</div>
|
||||
<ol id="inbox-list" class="inbox-list"></ol>
|
||||
<p id="inbox-empty" class="inbox-empty muted" hidden>No messages yet.</p>
|
||||
<button id="notify" type="button" class="notify" hidden>Turn on desktop notifications</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts" class="alerts"></div>
|
||||
<p id="error" class="error" hidden></p>
|
||||
@@ -60,7 +81,25 @@
|
||||
</div>
|
||||
<button id="legend-open" type="button" class="legend-open" aria-label="Show the legend" title="Show the legend" hidden>?</button>
|
||||
<p id="updated" class="muted stamp"></p>
|
||||
|
||||
<aside id="chat" class="chat" aria-labelledby="chat-title" hidden>
|
||||
<div class="chat-head">
|
||||
<h2 id="chat-title"></h2>
|
||||
<button id="chat-ignore" type="button" class="chat-ignore" aria-pressed="false" hidden>Ignore</button>
|
||||
<button id="chat-close" type="button" aria-label="Close the chat" title="Close the chat (Escape)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
|
||||
</div>
|
||||
<p id="chat-status" class="chat-status" hidden></p>
|
||||
<ol id="chat-log" class="chat-log" role="log" aria-label="Messages" tabindex="0"></ol>
|
||||
<p id="chat-empty" class="chat-empty muted" hidden>No messages yet.</p>
|
||||
<p id="chat-error" class="error chat-error" role="alert" hidden></p>
|
||||
<form id="chat-form" class="chat-form">
|
||||
<label for="chat-input" class="sr-only">Message</label>
|
||||
<input id="chat-input" type="text" maxlength="4000" autocomplete="off" placeholder="Message">
|
||||
<button id="chat-send" type="submit">Send</button>
|
||||
</form>
|
||||
</aside>
|
||||
</main>
|
||||
<div id="chat-live" class="sr-only" aria-live="polite"></div>
|
||||
|
||||
<div id="live" class="sr-only" role="status" aria-live="polite"></div>
|
||||
<ul id="reader" class="sr-only" aria-label="Networks and their nodes"></ul>
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
index: index,
|
||||
key: address(member),
|
||||
net: networkOf(member.ip),
|
||||
id: member.hostname + "@" + member.ip,
|
||||
hostname: member.hostname,
|
||||
ip: member.ip,
|
||||
port: member.port,
|
||||
@@ -236,6 +237,18 @@
|
||||
return off.length ? "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0] : "";
|
||||
}
|
||||
|
||||
function chatState(model, key) {
|
||||
const peer = model.byKey.get(key);
|
||||
if (!peer || peer.self) return null;
|
||||
const own = model.byKey.get(model.selfKey);
|
||||
if (!own || !own.roles.includes("Chatter")) {
|
||||
return { read: false, send: false, text: "Chat needs Chatter on this node too. Start it with: colonyctl role add Chatter" };
|
||||
}
|
||||
if (!peer.roles.includes("Chatter")) return { read: true, send: false, text: peer.hostname + " is not running Chatter, so it cannot receive messages." };
|
||||
if (!peer.alive) return { read: true, send: false, text: peer.hostname + " is down." };
|
||||
return { read: true, send: true, text: "" };
|
||||
}
|
||||
|
||||
function summarize(model) {
|
||||
const alive = model.nodes.filter((node) => node.alive).length;
|
||||
const down = model.nodes.length - alive;
|
||||
@@ -367,7 +380,7 @@
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode };
|
||||
const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, chatState, summarize, describeNode };
|
||||
if (typeof module !== "undefined") module.exports = api;
|
||||
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
|
||||
})();
|
||||
@@ -18,7 +18,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bg: #12161c;
|
||||
--card: #1b2129;
|
||||
--text: #e6eaf0;
|
||||
@@ -38,6 +39,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #12161c;
|
||||
--card: #1b2129;
|
||||
--text: #e6eaf0;
|
||||
--muted: #93a0b3;
|
||||
--line: #313a47;
|
||||
--down: #6b7686;
|
||||
--track: #2a3340;
|
||||
--panel: rgba(27, 33, 41, 0.92);
|
||||
--warn: #ffb04a;
|
||||
--crit: #ff6b6b;
|
||||
--n0: #6c9cff;
|
||||
--n1: #3cc7c7;
|
||||
--n2: #ab8cff;
|
||||
--n3: #ff7cc8;
|
||||
--n4: #e0b04a;
|
||||
--n5: #8fd06a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
@@ -195,4 +216,90 @@ p { margin: 4px 0; }
|
||||
.swatch.flow { width: 20px; height: 0; border-radius: 0; border: 0; border-top: 3.5px dashed var(--text); vertical-align: 3px; }
|
||||
.swatch.none { background: transparent; border: 2px dashed var(--muted); }
|
||||
|
||||
.chat { position: absolute; top: 12px; right: 12px; bottom: 12px; width: min(340px, calc(100% - 24px)); display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--card); box-shadow: 0 6px 24px color-mix(in srgb, var(--text) 12%, transparent); }
|
||||
.chat-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
|
||||
.chat-head h2 { flex: 1; margin: 0; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-head button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text); cursor: pointer; }
|
||||
.chat-head button:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.chat-head svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
|
||||
.chat-status { margin: 0; padding: 8px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
||||
.chat-log { flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 12px; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
.chat-log:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
|
||||
.chat-empty { margin: 0; padding: 0 14px 12px; text-align: center; }
|
||||
.chat-error { margin: 0; padding: 6px 14px; font-size: 13px; }
|
||||
.msg { max-width: 85%; padding: 6px 10px; border-radius: 12px; overflow-wrap: anywhere; }
|
||||
.msg.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
||||
.msg.out { align-self: flex-end; background: color-mix(in srgb, var(--n0) 16%, var(--card)); border: 1px solid color-mix(in srgb, var(--n0) 40%, transparent); border-bottom-right-radius: 4px; }
|
||||
.msg-text { margin: 0; white-space: pre-wrap; }
|
||||
.msg-time { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.msg.out .msg-time { text-align: right; }
|
||||
.chat-form { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--line); }
|
||||
.chat-form input { flex: 1; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); font: inherit; }
|
||||
.chat-form input:focus-visible { outline: 2px solid var(--n0); outline-offset: 0; border-color: transparent; }
|
||||
.chat-form button { height: 36px; padding: 0 14px; border: 0; border-radius: 8px; background: var(--n0); color: var(--card); font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.chat-form button:disabled, .chat-form input:disabled { opacity: 0.6; cursor: default; }
|
||||
.chat-form button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
#stage.chatting .controls { right: 364px; }
|
||||
@media (pointer: coarse) { .chat-form input, .chat-form button, .chat-head button { min-height: 44px; } .chat-head button { min-width: 44px; } }
|
||||
|
||||
.chat-head .chat-ignore { min-width: 0; padding: 0 10px; border: 1px solid var(--line); font: inherit; font-size: 13px; color: var(--muted); }
|
||||
.chat-head .chat-ignore[aria-pressed="true"] { border-color: var(--crit); color: var(--crit); }
|
||||
.chat-head button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
|
||||
.badge { pointer-events: none; }
|
||||
.badge circle { fill: var(--crit); stroke: var(--card); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.badge text { fill: #fff; font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.bell-wrap { position: relative; z-index: 5; margin-left: auto; align-self: center; margin-top: 8px; }
|
||||
.bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); cursor: pointer; }
|
||||
.bell:hover, .bell[aria-expanded="true"] { border-color: var(--muted); }
|
||||
.bell:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
.bell svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bell.unread svg { stroke: var(--n0); }
|
||||
.bell-count { position: absolute; top: -6px; right: -6px; min-width: 19px; height: 19px; padding: 0 5px; border: 2px solid var(--bg); border-radius: 999px; background: var(--crit); color: #fff; font-size: 11px; font-weight: 700; line-height: 15px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.toasts, .inbox-panel { position: absolute; top: calc(100% + 8px); right: 0; width: min(340px, calc(100vw - 24px)); }
|
||||
.toasts { display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast { display: block; width: 100%; padding: 10px 14px; border: 1px solid var(--line); border-left: 4px solid var(--n0); border-radius: 10px; background: var(--card); color: var(--text); font: inherit; text-align: left; cursor: pointer; pointer-events: auto; box-shadow: 0 6px 24px color-mix(in srgb, var(--text) 14%, transparent); }
|
||||
.toast:hover { border-color: var(--muted); border-left-color: var(--n0); }
|
||||
.toast:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
.toast strong { display: block; font-size: 13px; }
|
||||
.toast span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 13px; }
|
||||
|
||||
.inbox-panel { display: flex; flex-direction: column; max-height: min(480px, 70dvh); border: 1px solid var(--line); border-radius: 10px; background: var(--card); box-shadow: 0 10px 32px color-mix(in srgb, var(--text) 16%, transparent); }
|
||||
.inbox-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
|
||||
.inbox-head h2 { flex: 1; margin: 0; font-size: 15px; }
|
||||
.inbox-head button, .notify { height: 30px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.inbox-head button:hover, .notify:hover { border-color: var(--muted); color: var(--text); }
|
||||
.inbox-head button:disabled { opacity: 0.5; cursor: default; }
|
||||
.inbox-head button:focus-visible, .notify:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
.inbox-list { flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 4px; list-style: none; }
|
||||
.inbox-row { display: grid; grid-template-columns: 1fr auto; gap: 1px 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
|
||||
.inbox-row:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.inbox-row:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
|
||||
.inbox-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.inbox-time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.inbox-text { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.inbox-count { justify-self: end; min-width: 19px; height: 19px; padding: 0 5px; border-radius: 999px; background: var(--n0); color: var(--card); font-size: 11px; font-weight: 700; line-height: 19px; text-align: center; }
|
||||
.inbox-row.unread .inbox-text { color: var(--text); }
|
||||
.inbox-empty { margin: 0; padding: 16px 14px; text-align: center; }
|
||||
.notify { margin: 0 10px 10px; color: var(--n0); border-color: color-mix(in srgb, var(--n0) 50%, transparent); }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.toast, .inbox-panel { animation: drop-in 0.18s ease-out; }
|
||||
}
|
||||
@keyframes drop-in { from { opacity: 0; transform: translateY(-6px); } }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* Phones: the desktop sizes are small on a 400px screen, so the page text and touch targets grow. */
|
||||
@media (max-width: 600px) {
|
||||
body { font-size: 16px; }
|
||||
header { padding: 0 12px 6px; }
|
||||
h1 { font-size: 24px; }
|
||||
.alert, .route, .muted, .legend { font-size: 14px; }
|
||||
.legend { max-width: calc(100% - 24px); }
|
||||
.legend, .legend-open { bottom: 64px; }
|
||||
.chat { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: min(70%, 520px); border-radius: 14px 14px 0 0; border-bottom: 0; }
|
||||
#stage.chatting .controls, #stage.chatting .legend, #stage.chatting .legend-open { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import datetime
|
||||
import os
|
||||
import threading
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from colony.core.storages.core import Storage, STORAGES_DIR
|
||||
|
||||
CHAT_DIR = os.path.join(STORAGES_DIR, "chat")
|
||||
|
||||
|
||||
class ChatStorage(Storage):
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self, storage_dir=None):
|
||||
super().__init__(storage_dir or CHAT_DIR)
|
||||
|
||||
@staticmethod
|
||||
def _file(peer_id):
|
||||
return quote(peer_id, safe="") + ".json"
|
||||
|
||||
def messages(self, peer_id):
|
||||
messages = self.open(self._file(peer_id)) or []
|
||||
received = 0
|
||||
for seq, message in enumerate(messages, 1):
|
||||
received = message.setdefault("received", received + (message["direction"] == "in"))
|
||||
message.setdefault("seq", seq)
|
||||
return messages
|
||||
|
||||
def append(self, peer_id, direction, text, keep=None):
|
||||
at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
with self._lock:
|
||||
messages = self.messages(peer_id)
|
||||
last = messages[-1] if messages else {"seq": 0, "received": 0}
|
||||
messages.append({
|
||||
"direction": direction,
|
||||
"text": text,
|
||||
"at": at,
|
||||
"seq": last["seq"] + 1,
|
||||
"received": last["received"] + (direction == "in"),
|
||||
})
|
||||
if keep:
|
||||
messages = messages[-keep:]
|
||||
self.save(self._file(peer_id), messages)
|
||||
|
||||
def peers(self):
|
||||
peers = []
|
||||
for filename in self.ls(""):
|
||||
peer_id = unquote(filename.removesuffix(".json"))
|
||||
peers.append(peer_id)
|
||||
return sorted(peers)
|
||||
+5
-44
@@ -6,52 +6,13 @@ configuration = Dynaconf(
|
||||
load_dotenv=False,
|
||||
)
|
||||
|
||||
DEFAULTS = {
|
||||
"discovery.range_start": "192.168.65.110",
|
||||
"discovery.range_end": "192.168.65.120",
|
||||
"discovery.discover_port": 28149,
|
||||
"discovery.probe_timeout": 1.0,
|
||||
"discovery.probe_workers": 64,
|
||||
"membership.gossip_interval": 5,
|
||||
"membership.member_timeout": 20,
|
||||
"membership.reprobe_interval": 15,
|
||||
"membership.fanout": 3,
|
||||
"membership.forget_after": 600,
|
||||
"membership.hello_timeout": 3,
|
||||
"membership.max_members": 1024,
|
||||
"membership.admission": "open",
|
||||
"foreman.interval": 5,
|
||||
"foreman.acquire_delay": 10,
|
||||
"foreman.proposal_stagger": 2,
|
||||
"foreman.proposal_cooldown": 30,
|
||||
"gateway.client_fallback": True,
|
||||
"gateway.connect_timeout": 2,
|
||||
"gateway.direct_retry": 30,
|
||||
"gateway.max_attempts": 4,
|
||||
"gateway.forward_timeout": 8,
|
||||
"gateway.max_concurrent": 16,
|
||||
"gateway.max_payload_bytes": 4 * 1024 * 1024,
|
||||
"gateway.allow_commands": [
|
||||
"Gossip.Hello",
|
||||
"Unit.ShowStats",
|
||||
"Unit.ShowRoles",
|
||||
"Unit.ShowHardware",
|
||||
"Unit.ShowCluster",
|
||||
"Unit.RequestRole",
|
||||
"Replicator.HashSum",
|
||||
"Replicator.Retrieve",
|
||||
"Replicator.Replicate",
|
||||
],
|
||||
"web.host": "0.0.0.0",
|
||||
"web.port": 8080,
|
||||
"role_policy.max_roles": None,
|
||||
"role_policy.deny": [],
|
||||
"role_policy.min_scores": {},
|
||||
"role_policy.decline_cooldown": 60,
|
||||
}
|
||||
MISSING = object()
|
||||
|
||||
|
||||
def setting(key, override=None):
|
||||
if override is not None:
|
||||
return override
|
||||
return configuration.get(key, DEFAULTS[key])
|
||||
value = configuration.get(key, MISSING)
|
||||
if value is MISSING:
|
||||
raise KeyError(f"Setting {key} is not configured")
|
||||
return value
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from colony.core.logger.logging import get_logger
|
||||
from colony.transport.framing import send_frame, recv_frame
|
||||
|
||||
CONTROL_SOCKET = Path(__file__).resolve().parents[2] / "data" / "colony.sock"
|
||||
|
||||
|
||||
def request(payload, path=CONTROL_SOCKET, timeout=10):
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.connect(str(path))
|
||||
send_frame(sock, json.dumps(payload).encode())
|
||||
return json.loads(recv_frame(sock).decode())
|
||||
|
||||
|
||||
class ControlQueue:
|
||||
def __init__(self, actions):
|
||||
self.logger = get_logger(self.__class__.__name__)
|
||||
self.actions = actions
|
||||
self.tasks = queue.Queue()
|
||||
|
||||
def submit(self, payload, timeout):
|
||||
reply = queue.Queue(maxsize=1)
|
||||
self.tasks.put((payload, reply))
|
||||
return reply.get(timeout=timeout)
|
||||
|
||||
def run_once(self):
|
||||
payload, reply = self.tasks.get()
|
||||
try:
|
||||
body = json.dumps({"status": "ok", "result": self.actions.run(payload)})
|
||||
except Exception as e:
|
||||
self.logger.exception("Control command failed")
|
||||
body = json.dumps({"status": "error", "error": f"{type(e).__name__}: {e}"})
|
||||
reply.put(body.encode())
|
||||
|
||||
def run_forever(self):
|
||||
while True:
|
||||
self.run_once()
|
||||
|
||||
|
||||
class ControlSocket:
|
||||
CLIENT_TIMEOUT = 30
|
||||
|
||||
def __init__(self, control_queue, path=CONTROL_SOCKET):
|
||||
self.logger = get_logger(self.__class__.__name__)
|
||||
self.control_queue = control_queue
|
||||
self.path = Path(path)
|
||||
self._sock = None
|
||||
|
||||
def _remove_stale_socket(self):
|
||||
if not self.path.exists():
|
||||
return
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
||||
try:
|
||||
probe.connect(str(self.path))
|
||||
except OSError:
|
||||
self.path.unlink()
|
||||
return
|
||||
raise OSError(f"another node is already running on {self.path}")
|
||||
|
||||
def bind(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._remove_stale_socket()
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(str(self.path))
|
||||
os.chmod(self.path, 0o600)
|
||||
sock.listen()
|
||||
except OSError:
|
||||
sock.close()
|
||||
raise
|
||||
self._sock = sock
|
||||
|
||||
def start(self):
|
||||
self.logger.info(f"Control socket on {self.path}")
|
||||
while True:
|
||||
conn, _ = self._sock.accept()
|
||||
threading.Thread(target=self.handle, args=(conn,), daemon=True, name="HandleControl").start()
|
||||
|
||||
def handle(self, conn):
|
||||
with conn:
|
||||
try:
|
||||
conn.settimeout(self.CLIENT_TIMEOUT)
|
||||
payload = json.loads(recv_frame(conn).decode())
|
||||
body = self.control_queue.submit(payload, self.CLIENT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
body = json.dumps({"status": "error", "error": "timed out waiting for the node"}).encode()
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Bad control request: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
send_frame(conn, body)
|
||||
except OSError as e:
|
||||
self.logger.warning(f"Could not answer control request: {e}")
|
||||
+13
-4
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"discovery": {
|
||||
"range_start": "192.168.65.100",
|
||||
"range_end": "192.168.65.100",
|
||||
"range_end": "192.168.65.120",
|
||||
"discover_port": 28149,
|
||||
"probe_timeout": 1.0,
|
||||
"probe_workers": 64
|
||||
@@ -26,7 +26,7 @@
|
||||
"forget_after": 600,
|
||||
"hello_timeout": 3,
|
||||
"max_members": 10,
|
||||
"admission": "closed"
|
||||
"admission": "open"
|
||||
},
|
||||
"foreman": {
|
||||
"interval": 5,
|
||||
@@ -34,9 +34,14 @@
|
||||
"proposal_stagger": 2,
|
||||
"proposal_cooldown": 30
|
||||
},
|
||||
"chat": {
|
||||
"max_messages": 1000
|
||||
},
|
||||
"web": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080
|
||||
"port": 8080,
|
||||
"username": "colony",
|
||||
"password": ""
|
||||
},
|
||||
"explorer": {
|
||||
"hosts": []
|
||||
@@ -58,9 +63,13 @@
|
||||
"Unit.RequestRole",
|
||||
"Replicator.HashSum",
|
||||
"Replicator.Retrieve",
|
||||
"Replicator.Replicate"
|
||||
"Replicator.Replicate",
|
||||
"Chatter.Receive"
|
||||
]
|
||||
},
|
||||
"roles": {
|
||||
"on_demand": []
|
||||
},
|
||||
"role_policy": {
|
||||
"max_roles": null,
|
||||
"deny": [],
|
||||
|
||||
@@ -4,10 +4,70 @@ services:
|
||||
container_name: colony
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
COLONY_WHISPERER__HOST: 0.0.0.0
|
||||
COLONY_WHISPERER__PORT: 65334
|
||||
COLONY_WHISPERER__CERT_FILE: certs/server.crt
|
||||
COLONY_WHISPERER__KEYFILE: certs/server.key
|
||||
|
||||
COLONY_LISTENER__HOST: 0.0.0.0
|
||||
COLONY_LISTENER__PORT: 65334
|
||||
COLONY_LISTENER__CERT_FILE: certs/server.crt
|
||||
COLONY_LISTENER__KEYFILE: certs/server.key
|
||||
|
||||
COLONY_DISCOVERY__RANGE_START: 192.168.65.100
|
||||
COLONY_DISCOVERY__RANGE_END: 192.168.65.120
|
||||
COLONY_DISCOVERY__DISCOVER_PORT: 28149
|
||||
COLONY_DISCOVERY__PROBE_TIMEOUT: 1
|
||||
COLONY_DISCOVERY__PROBE_WORKERS: 64
|
||||
|
||||
COLONY_MEMBERSHIP__GOSSIP_INTERVAL: 5
|
||||
COLONY_MEMBERSHIP__MEMBER_TIMEOUT: 20
|
||||
COLONY_MEMBERSHIP__REPROBE_INTERVAL: 15
|
||||
COLONY_MEMBERSHIP__FANOUT: 3
|
||||
COLONY_MEMBERSHIP__FORGET_AFTER: 600
|
||||
COLONY_MEMBERSHIP__HELLO_TIMEOUT: 3
|
||||
COLONY_MEMBERSHIP__MAX_MEMBERS: 10
|
||||
COLONY_MEMBERSHIP__ADMISSION: open
|
||||
|
||||
COLONY_FOREMAN__INTERVAL: 5
|
||||
COLONY_FOREMAN__ACQUIRE_DELAY: 10
|
||||
COLONY_FOREMAN__PROPOSAL_STAGGER: 2
|
||||
COLONY_FOREMAN__PROPOSAL_COOLDOWN: 30
|
||||
|
||||
COLONY_CHAT__MAX_MESSAGES: 1000
|
||||
|
||||
COLONY_WEB__HOST: 0.0.0.0
|
||||
COLONY_WEB__PORT: 8271
|
||||
COLONY_WEB__USERNAME: colony
|
||||
COLONY_WEB__PASSWORD: change-me
|
||||
|
||||
COLONY_EXPLORER__HOSTS: "[]"
|
||||
|
||||
COLONY_GATEWAY__CLIENT_FALLBACK: true
|
||||
COLONY_GATEWAY__CONNECT_TIMEOUT: 2
|
||||
COLONY_GATEWAY__DIRECT_RETRY: 30
|
||||
COLONY_GATEWAY__MAX_ATTEMPTS: 4
|
||||
COLONY_GATEWAY__FORWARD_TIMEOUT: 8
|
||||
COLONY_GATEWAY__MAX_CONCURRENT: 16
|
||||
COLONY_GATEWAY__MAX_PAYLOAD_BYTES: 4194304
|
||||
COLONY_GATEWAY__ALLOW_COMMANDS: >-
|
||||
["Gossip.Hello","Unit.ShowStats","Unit.ShowRoles","Unit.ShowHardware",
|
||||
"Unit.ShowCluster","Unit.RequestRole","Replicator.HashSum",
|
||||
"Replicator.Retrieve","Replicator.Replicate"]
|
||||
|
||||
COLONY_ROLES__ON_DEMAND: "[]"
|
||||
|
||||
COLONY_ROLE_POLICY__MAX_ROLES: null
|
||||
COLONY_ROLE_POLICY__DENY: "[]"
|
||||
COLONY_ROLE_POLICY__MIN_SCORES: "{}"
|
||||
COLONY_ROLE_POLICY__DECLINE_COOLDOWN: 60
|
||||
volumes:
|
||||
- colony-data:/app/data
|
||||
- colony-storages:/app/storages
|
||||
- colony-logs:/app/logs
|
||||
|
||||
volumes:
|
||||
colony-data:
|
||||
colony-storages:
|
||||
colony-logs:
|
||||
@@ -3,6 +3,7 @@ import threading
|
||||
|
||||
from colony.core.logger.logging import get_logger
|
||||
from colony.core.network.core import Network
|
||||
from colony.core.control import ControlActions
|
||||
from colony.core.dispatcher import Dispatcher
|
||||
from colony.core.roles.foreman.role import Foreman
|
||||
from colony.core.roles.gossip.role import Gossip, BOOTSTRAPPED
|
||||
@@ -10,6 +11,7 @@ from colony.core.roles.manager import RoleManager
|
||||
from colony.core.state_manager import StateManager
|
||||
from colony.role_registry import RoleRegistry
|
||||
from colony.settings import configuration, setting
|
||||
from colony.transport.control import ControlQueue, ControlSocket
|
||||
from colony.transport.discover import Discover
|
||||
from colony.transport.listener import Listener
|
||||
from colony.utils.network import hosts_in_range
|
||||
@@ -39,6 +41,7 @@ def main():
|
||||
)
|
||||
|
||||
role_manager.ensure_general_roles()
|
||||
role_manager.ensure_on_demand_roles(setting("roles.on_demand"))
|
||||
role_manager.initialize_roles()
|
||||
|
||||
listener = Listener(dispatcher=dispatcher)
|
||||
@@ -48,7 +51,20 @@ def main():
|
||||
logger.error(f"Cannot listen on {listener.host}:{listener.port}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
threads = []
|
||||
control = ControlQueue(ControlActions(network))
|
||||
control_socket = ControlSocket(control)
|
||||
try:
|
||||
control_socket.bind()
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot open the control socket {control_socket.path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
t = threading.Thread(
|
||||
target=control_socket.start,
|
||||
daemon=True,
|
||||
name="ControlSocket"
|
||||
)
|
||||
t.start()
|
||||
|
||||
t = threading.Thread(
|
||||
target=listener.start,
|
||||
@@ -56,7 +72,6 @@ def main():
|
||||
name="Listener"
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
t = threading.Thread(
|
||||
target=Discover.listen,
|
||||
@@ -65,7 +80,6 @@ def main():
|
||||
name="Discover"
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
roles = role_manager.get_role_instances()
|
||||
gossip: Gossip = roles["Gossip"]
|
||||
@@ -80,8 +94,7 @@ def main():
|
||||
|
||||
role_manager.start_all()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
control.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "colony"
|
||||
version = "0.1.0"
|
||||
description = "Colony"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"dependency-injector==4.48.3",
|
||||
"dynaconf==3.2.12",
|
||||
"Flask==3.1.3",
|
||||
"GPUtil==1.4.0",
|
||||
"psutil==7.2.1",
|
||||
"setuptools==80.9.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -1,6 +0,0 @@
|
||||
dependency-injector==4.48.3
|
||||
dynaconf==3.2.12
|
||||
Flask==3.1.3
|
||||
GPUtil==1.4.0
|
||||
psutil==7.2.1
|
||||
setuptools==80.9.0
|
||||
@@ -1,3 +1,4 @@
|
||||
from colony.core.roles.chatter.role import Chatter
|
||||
from colony.core.roles.explorer.role import Explorer
|
||||
from colony.core.roles.replicator.role import Replicator
|
||||
from colony.core.roles.unit.role import Unit
|
||||
@@ -59,5 +60,10 @@ available_roles = [
|
||||
}
|
||||
),
|
||||
minimal_percentage=51
|
||||
),
|
||||
RoleStats(
|
||||
cls=Chatter,
|
||||
priority=RolePriority.LOW,
|
||||
on_demand=True,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colony"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "dependency-injector" },
|
||||
{ name = "dynaconf" },
|
||||
{ name = "flask" },
|
||||
{ name = "gputil" },
|
||||
{ name = "psutil" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "dependency-injector", specifier = "==4.48.3" },
|
||||
{ name = "dynaconf", specifier = "==3.2.12" },
|
||||
{ name = "flask", specifier = "==3.1.3" },
|
||||
{ name = "gputil", specifier = "==1.4.0" },
|
||||
{ name = "psutil", specifier = "==7.2.1" },
|
||||
{ name = "setuptools", specifier = "==80.9.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dependency-injector"
|
||||
version = "4.48.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/14/4f7b2d24d3ad50ac399262636cdf4b66b9318bfd0e9de5c61669625dc77c/dependency_injector-4.48.3.tar.gz", hash = "sha256:106e43a6a9959b2c49926edc45bdf07fdc377bc5fa1fd59012741590df30b20c", size = 1129251, upload-time = "2025-12-04T18:43:55.751Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d1/bebb2921e1cfdeba992e57c62bcd871f4703fde852b1d64ceb0dd65540ee/dependency_injector-4.48.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:37d5b962d9ba67f6536557dec3c83136cefd7a747bbe8ba53b158e34e050bae5", size = 1746065, upload-time = "2025-12-04T18:43:06.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/da/068f23d12b55cda6e2d5e7783d1b602aa40f0889f35cf182ecfa1b12f7b8/dependency_injector-4.48.3-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:54aa2b8b8ff057555ab03ee98035eb41120d30f8ec220cf43aa3b117f6b9b1b1", size = 1829780, upload-time = "2025-12-04T18:43:08.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/7f/7d5f16a87d6cc4111be41c8593ae6b98ebcdf0dc797c6e08e303ce6155b7/dependency_injector-4.48.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a1f8d5b7dcf24773c27ddb9beab043753d8e251804f2398468944b93c767a35", size = 1741838, upload-time = "2025-12-04T18:43:10.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/1d/251c79858c9ebf53c6d8b51c9a653c794db0392070ab5a8f3f4a298a8b26/dependency_injector-4.48.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7ed11a944f5c84a5ad3f15bc9154f07affae2902967b4e47c74504d4595ed92f", size = 1726565, upload-time = "2025-12-04T18:43:11.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/92/023bd0b72baf170368323412a911fe31fd1c5cccbd4d399744bdec21cecd/dependency_injector-4.48.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00c7ad5b5b5f9b1616c363770eed8c629a42dd0155b398c4458c949b8c0b9ae0", size = 1820764, upload-time = "2025-12-04T18:43:13.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/0f6c65c6f67be9a7bcc18546ad08610b1cb294cef0da3d16502a1b336246/dependency_injector-4.48.3-cp310-abi3-win32.whl", hash = "sha256:2870a3b84ddb2e4b13731b7250b7714f0d4a07f6147a181b3ecbceed061b74f2", size = 1527160, upload-time = "2025-12-04T18:43:15.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/74/51e8d051d55ae3b88fb0d94356cc9998e070a8b0cf6c93ef8456b8d05c01/dependency_injector-4.48.3-cp310-abi3-win_amd64.whl", hash = "sha256:d0e6244b3674a48ee2c5b0af29bf20d2b0b5baef806dd1739af9aab9d20f08de", size = 1655409, upload-time = "2025-12-04T18:43:16.901Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dynaconf"
|
||||
version = "3.2.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/7a6f84b68268fe1d12e709faec7d293e0c37c9c03bacaf363de41e7e7568/dynaconf-3.2.12.tar.gz", hash = "sha256:29cea583b007d890e6031fa89c0ac489b631c73dbee83bcd5e6f97602c26354e", size = 313801, upload-time = "2025-10-10T19:54:06.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/68/51adede38ab2ee9ecfddb8b60a80a42b618a72f1018fcf60974e5d852831/dynaconf-3.2.12-py2.py3-none-any.whl", hash = "sha256:eb2a11865917dff8810c6098cd736b8f4d2f4e39ad914500e2dfbe064b82c499", size = 237788, upload-time = "2025-10-10T19:54:03.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gputil"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/0e/5c61eedde9f6c87713e89d794f01e378cfd9565847d4576fa627d758c554/GPUtil-1.4.0.tar.gz", hash = "sha256:099e52c65e512cdfa8c8763fca67f5a5c2afb63469602d5dcb4d296b3661efb9", size = 5545, upload-time = "2018-12-18T09:12:13.63Z" }
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||
]
|
||||
Reference in new issue
Block a user