chore(reorganization): part two

Initially tried to condense each part of the kvm software into individual groups. The code is so dependent on each other file it doesn't work without causing a crap ton of import cycles.
This commit is contained in:
Jordan Jones 2025-01-06 15:01:15 -08:00
parent 9eced85559
commit 1508701927
No known key found for this signature in database
GPG Key ID: 60DB17F3740DF721
132 changed files with 20151 additions and 20130 deletions

View File

@ -1,10 +1,10 @@
{
"name": "JetKVM",
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "21.1.0"
}
}
}
{
"name": "JetKVM",
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "21.1.0"
}
}
}

22
.editorconfig Normal file
View File

@ -0,0 +1,22 @@
; https://editorconfig.org/
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{Makefile,go.mod,go.sum,*.go,.gitmodules}]
indent_style = tab
indent_size = 4
[*.md]
indent_size = 4
eclint_indent_style = unset
[Dockerfile]
indent_size = 4

View File

@ -1,130 +1,130 @@
CODE_OF_CONDUCT.md
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@jetkvm.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
CODE_OF_CONDUCT.md
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@jetkvm.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

678
LICENSE
View File

@ -1,339 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
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
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the 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 a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE 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.
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
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
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
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the 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 a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE 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.
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
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@ -1,33 +1,33 @@
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.4
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
frontend:
cd ui && npm ci && npm run build:device
dev_release: build_dev
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
echo "Error: Version $(VERSION) already exists. Please update the VERSION variable."; \
exit 1; \
fi
make build_release
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.4
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
frontend:
cd ui && npm ci && npm run build:device
dev_release: build_dev
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
echo "Error: Version $(VERSION) already exists. Please update the VERSION variable."; \
exit 1; \
fi
make build_release
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256

View File

@ -1,46 +1,46 @@
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
### KVM
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
</div>
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
## Features
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
## Contributing
We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully.
## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
## I want to report an issue
If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/kvm/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue.
# Development
JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An intermediate level of Go & TypeScript knowledge is recommended for comfortable programming.
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
## Backend
The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web.
## Frontend
The frontend is written in React and TypeScript and is served by the KVM device. It has three build targets: `device`, `development` and `production`. Development is used for development of the cloud version on your local machine, device is used for building the frontend for the KVM device and production is used for building the frontend for the cloud.
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
### KVM
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
</div>
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
## Features
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
## Contributing
We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully.
## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
## I want to report an issue
If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/kvm/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue.
# Development
JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An intermediate level of Go & TypeScript knowledge is recommended for comfortable programming.
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
## Backend
The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web.
## Frontend
The frontend is written in React and TypeScript and is served by the KVM device. It has three build targets: `device`, `development` and `production`. Development is used for development of the cloud version on your local machine, device is used for building the frontend for the KVM device and production is used for building the frontend for the cloud.

View File

@ -10,24 +10,22 @@ import (
"time"
"github.com/jetkvm/kvm/internal/config"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/kvm"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/server"
"github.com/gwatts/rootcerts"
)
var appCtx context.Context
var ctx context.Context
func main() {
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
logging.Logger.Info("Starting JetKvm")
go hardware.RunWatchdog()
go network.ConfirmCurrentSystem()
go kvm.RunWatchdog(ctx)
go kvm.ConfirmCurrentSystem()
http.DefaultClient.Timeout = 1 * time.Minute
cfg := config.LoadConfig()
@ -38,13 +36,13 @@ func main() {
logging.Logger.Errorf("failed to load CA certs: %v", err)
}
go network.TimeSyncLoop()
go kvm.TimeSyncLoop()
hardware.StartNativeCtrlSocketServer()
hardware.StartNativeVideoSocketServer()
kvm.StartNativeCtrlSocketServer()
kvm.StartNativeVideoSocketServer()
go func() {
err = hardware.ExtractAndRunNativeBin()
err = kvm.ExtractAndRunNativeBin(ctx)
if err != nil {
logging.Logger.Errorf("failed to extract and run native bin: %v", err)
//TODO: prepare an error message screen buffer to show on kvm screen
@ -58,13 +56,13 @@ func main() {
if cfg.AutoUpdateEnabled == false {
return
}
if server.CurrentSession != nil {
if kvm.CurrentSession != nil {
logging.Logger.Debugf("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := cfg.IncludePreRelease
err = network.TryUpdate(context.Background(), hardware.GetDeviceID(), includePreRelease)
err = kvm.TryUpdate(context.Background(), kvm.GetDeviceID(), includePreRelease)
if err != nil {
logging.Logger.Errorf("failed to auto update: %v", err)
}
@ -72,8 +70,8 @@ func main() {
}
}()
//go RunFuseServer()
go server.RunWebServer()
go server.RunWebsocketClient()
go kvm.RunWebServer()
go kvm.RunWebsocketClient()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs

View File

@ -1,86 +1,86 @@
# Exit immediately if a command exits with a non-zero status
set -e
# Function to display help message
show_help() {
echo "Usage: $0 [options] -r <remote_ip>"
echo
echo "Required:"
echo " -r, --remote <remote_ip> Remote host IP address"
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --help Display this help message"
echo
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-r|--remote)
REMOTE_HOST="$2"
shift 2
;;
-u|--user)
REMOTE_USER="$2"
shift 2
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Verify required parameters
if [ -z "$REMOTE_HOST" ]; then
echo "Error: Remote IP is a required parameter"
show_help
fi
# Build the development version on the host
make frontend
make build_dev
# Change directory to the binary output directory
cd bin
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
set -e
# Set the library path to include the directory where librockit.so is located
export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
# Kill any existing instances of the application
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
./jetkvm_app_debug
EOF
echo "Deployment complete."
# Exit immediately if a command exits with a non-zero status
set -e
# Function to display help message
show_help() {
echo "Usage: $0 [options] -r <remote_ip>"
echo
echo "Required:"
echo " -r, --remote <remote_ip> Remote host IP address"
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --help Display this help message"
echo
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-r|--remote)
REMOTE_HOST="$2"
shift 2
;;
-u|--user)
REMOTE_USER="$2"
shift 2
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Verify required parameters
if [ -z "$REMOTE_HOST" ]; then
echo "Error: Remote IP is a required parameter"
show_help
fi
# Build the development version on the host
make frontend
make build_dev
# Change directory to the binary output directory
cd bin
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
set -e
# Set the library path to include the directory where librockit.so is located
export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
# Kill any existing instances of the application
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
./jetkvm_app_debug
EOF
echo "Deployment complete."

354
go.sum
View File

@ -1,177 +1,177 @@
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -1,4 +1,4 @@
package hardware
package kvm
import (
"context"
@ -17,15 +17,15 @@ type remoteImageBackend struct {
}
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
virtualMediaStateMutex.RLock()
logging.Logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
VirtualMediaStateMutex.RLock()
logging.Logger.Debugf("currentVirtualMediaState is %v", CurrentVirtualMediaState)
logging.Logger.Debugf("read size: %d, off: %d", len(p), off)
if currentVirtualMediaState == nil {
if CurrentVirtualMediaState == nil {
return 0, errors.New("image not mounted")
}
source := currentVirtualMediaState.Source
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
source := CurrentVirtualMediaState.Source
mountedImageSize := CurrentVirtualMediaState.Size
VirtualMediaStateMutex.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -36,14 +36,14 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
}
var data []byte
if source == WebRTC {
data, err = webRTCDiskReader.Read(ctx, off, readLen)
data, err = WebRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
} else if source == HTTP {
return httpRangeReader.ReadAt(p, off)
return HttpRangeReader.ReadAt(p, off)
} else {
return 0, errors.New("unknown image source")
}
@ -54,12 +54,12 @@ func (r remoteImageBackend) WriteAt(p []byte, off int64) (n int, err error) {
}
func (r remoteImageBackend) Size() (int64, error) {
virtualMediaStateMutex.Lock()
defer virtualMediaStateMutex.Unlock()
if currentVirtualMediaState == nil {
VirtualMediaStateMutex.Lock()
defer VirtualMediaStateMutex.Unlock()
if CurrentVirtualMediaState == nil {
return 0, errors.New("no virtual media state")
}
return currentVirtualMediaState.Size, nil
return CurrentVirtualMediaState.Size, nil
}
func (r remoteImageBackend) Sync() error {

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"bytes"
@ -11,7 +11,6 @@ import (
"github.com/coder/websocket/wsjson"
"github.com/jetkvm/kvm/internal/config"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/logging"
"github.com/coreos/go-oidc/v3/oidc"
@ -121,7 +120,7 @@ func runWebsocketClient() error {
wsURL.Scheme = "wss"
}
header := http.Header{}
header.Set("X-Device-ID", hardware.GetDeviceID())
header.Set("X-Device-ID", GetDeviceID())
header.Set("Authorization", "Bearer "+cfg.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
defer cancelDial()
@ -248,7 +247,7 @@ func RPCDeregisterDevice() error {
return fmt.Errorf("cloud token or URL is not set")
}
req, err := http.NewRequest(http.MethodDelete, cfg.CloudURL+"/devices/"+hardware.GetDeviceID(), nil)
req, err := http.NewRequest(http.MethodDelete, cfg.CloudURL+"/devices/"+GetDeviceID(), nil)
if err != nil {
return fmt.Errorf("failed to create deregister request: %w", err)
}

View File

@ -1,9 +1,10 @@
package hardware
package kvm
import (
"fmt"
"log"
"time"
)
var currentScreen = "ui_Boot_Screen"
@ -34,23 +35,23 @@ func switchToScreenIfDifferent(screenName string) {
}
func updateDisplay() {
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4)
if usbState == "configured" {
updateLabelIfChanged("ui_Home_Content_Ip", NetworkState.IPv4)
if UsbState == "configured" {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_DEFAULT"})
} else {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Disconnected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_USER_2"})
}
if lastVideoState.Ready {
if LastVideoState.Ready {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Connected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_DEFAULT"})
} else {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Disconnected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_USER_2"})
}
updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions))
if networkState.Up {
updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", ActionSessions))
if NetworkState.Up {
switchToScreenIfDifferent("ui_Home_Screen")
} else {
switchToScreenIfDifferent("ui_No_Network_Screen")
@ -73,7 +74,7 @@ func RequestDisplayUpdate() {
func updateStaticContents() {
//contents that never change
updateLabelIfChanged("ui_Home_Content_Mac", networkState.MAC)
updateLabelIfChanged("ui_Home_Content_Mac", NetworkState.MAC)
systemVersion, appVersion, err := GetLocalVersion()
if err == nil {
updateLabelIfChanged("ui_About_Content_Operating_System_Version_ContentLabel", systemVersion.String())
@ -85,7 +86,7 @@ func updateStaticContents() {
func init() {
go func() {
waitCtrlClientConnected()
WaitCtrlClientConnected()
fmt.Println("setting initial display contents")
time.Sleep(500 * time.Millisecond)
updateStaticContents()

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"context"
@ -58,10 +58,10 @@ type DiskReadRequest struct {
End uint64 `json:"end"`
}
var diskReadChan = make(chan []byte, 1)
var DiskReadChan = make(chan []byte, 1)
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
buf, err := WebRTCDiskReader.Read(ctx, off, int64(len(dest)))
if err != nil {
return nil, syscall.EIO
}

View File

@ -1,11 +1,16 @@
package hardware
package kvm
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/Masterminds/semver/v3"
"github.com/jetkvm/kvm/internal/logging"
)
func extractSerialNumber() (string, error) {
@ -42,7 +47,7 @@ func GetDeviceID() string {
deviceIDOnce.Do(func() {
serial, err := extractSerialNumber()
if err != nil {
logger.Warn("unknown serial number, the program likely not running on RV1106")
logging.Logger.Warn("unknown serial number, the program likely not running on RV1106")
deviceID = "unknown_device_id"
} else {
deviceID = serial
@ -51,10 +56,10 @@ func GetDeviceID() string {
return deviceID
}
func RunWatchdog() {
func RunWatchdog(ctx context.Context) {
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
if err != nil {
logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
logging.Logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
return
}
defer file.Close()
@ -65,15 +70,36 @@ func RunWatchdog() {
case <-ticker.C:
_, err = file.Write([]byte{0})
if err != nil {
logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
logging.Logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
}
case <-appCtx.Done():
case <-ctx.Done():
//disarm watchdog with magic value
_, err := file.Write([]byte("V"))
if err != nil {
logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
logging.Logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
}
return
}
}
}
var builtAppVersion = "0.1.0+dev"
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil {
return nil, nil, fmt.Errorf("invalid built-in app version: %w", err)
}
systemVersionBytes, err := os.ReadFile("/version")
if err != nil {
return nil, appVersion, fmt.Errorf("error reading system version: %w", err)
}
systemVersion, err = semver.NewVersion(strings.TrimSpace(string(systemVersionBytes)))
if err != nil {
return nil, appVersion, fmt.Errorf("invalid system version: %w", err)
}
return systemVersion, appVersion, nil
}

View File

@ -1,9 +1,8 @@
package jiggler
package kvm
import (
"time"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/logging"
)
@ -31,11 +30,11 @@ func runJiggler() {
if jigglerEnabled {
if time.Since(lastUserInput) > 20*time.Second {
//TODO: change to rel mouse
err := hardware.RPCAbsMouseReport(1, 1, 0)
err := RPCAbsMouseReport(1, 1, 0)
if err != nil {
logging.Logger.Warnf("Failed to jiggle mouse: %v", err)
}
err = hardware.RPCAbsMouseReport(0, 0, 0)
err = RPCAbsMouseReport(0, 0, 0)
if err != nil {
logging.Logger.Warnf("Failed to reset mouse position: %v", err)
}

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"context"
@ -12,10 +12,7 @@ import (
"reflect"
"github.com/jetkvm/kvm/internal/config"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/jiggler"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/wol"
"github.com/pion/webrtc/v4"
)
@ -134,7 +131,7 @@ func rpcPing() (string, error) {
}
func rpcGetDeviceID() (string, error) {
return hardware.GetDeviceID(), nil
return GetDeviceID(), nil
}
var streamFactor = 1.0
@ -145,7 +142,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
func rpcSetStreamQualityFactor(factor float64) error {
log.Printf("Setting stream quality factor to: %f", factor)
var _, err = hardware.CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil {
return err
}
@ -169,7 +166,7 @@ func rpcSetAutoUpdateState(enabled bool) (bool, error) {
}
func rpcGetEDID() (string, error) {
resp, err := hardware.CallCtrlAction("get_edid", nil)
resp, err := CallCtrlAction("get_edid", nil)
if err != nil {
return "", err
}
@ -187,7 +184,7 @@ func rpcSetEDID(edid string) error {
} else {
log.Printf("Setting EDID to: %s", edid)
}
_, err := hardware.CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
if err != nil {
return err
}
@ -208,10 +205,10 @@ func rpcSetDevChannelState(enabled bool) error {
return nil
}
func rpcGetUpdateStatus() (*network.UpdateStatus, error) {
func rpcGetUpdateStatus() (*UpdateStatus, error) {
cfg := config.LoadConfig()
includePreRelease := cfg.IncludePreRelease
updateStatus, err := network.GetUpdateStatus(context.Background(), hardware.GetDeviceID(), includePreRelease)
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
@ -223,7 +220,7 @@ func rpcTryUpdate() error {
cfg := config.LoadConfig()
includePreRelease := cfg.IncludePreRelease
go func() {
err := network.TryUpdate(context.Background(), hardware.GetDeviceID(), includePreRelease)
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logging.Logger.Warnf("failed to try update: %v", err)
}
@ -441,7 +438,7 @@ func rpcSetMassStorageMode(mode string) (string, error) {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
err := hardware.SetMassStorageMode(cdrom)
err := SetMassStorageMode(cdrom)
if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
}
@ -453,7 +450,7 @@ func rpcSetMassStorageMode(mode string) (string, error) {
}
func rpcGetMassStorageMode() (string, error) {
cdrom, err := hardware.GetMassStorageMode()
cdrom, err := GetMassStorageMode()
if err != nil {
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
}
@ -466,10 +463,10 @@ func rpcGetMassStorageMode() (string, error) {
}
func rpcIsUpdatePending() (bool, error) {
return network.IsUpdatePending(), nil
return IsUpdatePending(), nil
}
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", Udc)
func rpcGetUsbEmulationState() (bool, error) {
_, err := os.Stat(udcFilePath)
@ -484,9 +481,9 @@ func rpcGetUsbEmulationState() (bool, error) {
func rpcSetUsbEmulationState(enabled bool) error {
if enabled {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(Udc), 0644)
} else {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(Udc), 0644)
}
}
@ -523,15 +520,15 @@ var rpcHandlers = map[string]RPCHandler{
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: RPCDeregisterDevice},
"getCloudState": {Func: RPCGetCloudState},
"keyboardReport": {Func: hardware.RPCKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: hardware.RPCAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: hardware.RPCWheelReport, Params: []string{"wheelY"}},
"keyboardReport": {Func: RPCKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: RPCAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: RPCWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: hardware.RPCGetUSBState},
"unmountImage": {Func: hardware.RPCUnmountImage},
"rpcMountBuiltInImage": {Func: hardware.RPCMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: jiggler.RPCSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: jiggler.RPCGetJigglerState},
"getUSBState": {Func: RPCGetUSBState},
"unmountImage": {Func: RPCUnmountImage},
"rpcMountBuiltInImage": {Func: RPCMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: RPCSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: RPCGetJigglerState},
"sendWOLMagicPacket": {Func: wol.RPCSendWolMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
@ -552,15 +549,15 @@ var rpcHandlers = map[string]RPCHandler{
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"checkMountUrl": {Func: hardware.RPCCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: hardware.RPCGetVirtualMediaState},
"getStorageSpace": {Func: hardware.RPCGetStorageSpace},
"mountWithHTTP": {Func: hardware.RPCMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: hardware.RPCMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: hardware.RPCMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: hardware.RPCListStorageFiles},
"deleteStorageFile": {Func: hardware.RPCDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: hardware.RPCStartStorageFileUpload, Params: []string{"filename", "size"}},
"checkMountUrl": {Func: RPCCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: RPCGetVirtualMediaState},
"getStorageSpace": {Func: RPCGetStorageSpace},
"mountWithHTTP": {Func: RPCMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: RPCMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: RPCMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: RPCListStorageFiles},
"deleteStorageFile": {Func: RPCDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: RPCStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},

View File

@ -1,11 +1,11 @@
package hardware
package kvm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"kvm/resource"
"log"
"net"
"os"
@ -13,6 +13,9 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4/pkg/media"
)
@ -95,7 +98,7 @@ var nativeVideoSocketListener net.Listener
var ctrlClientConnected = make(chan struct{})
func waitCtrlClientConnected() {
func WaitCtrlClientConnected() {
<-ctrlClientConnected
}
@ -118,11 +121,11 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
conn, err := listener.Accept()
listener.Close()
if err != nil {
logger.Errorf("failed to accept sock: %v", err)
logging.Logger.Errorf("failed to accept sock: %v", err)
}
if isCtrl {
close(ctrlClientConnected)
logger.Debug("first native ctrl socket client connected")
logging.Logger.Debug("first native ctrl socket client connected")
}
handleClient(conn)
}()
@ -132,20 +135,20 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
logger.Debug("native app ctrl sock started")
logging.Logger.Debug("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
logger.Debug("native app video sock started")
logging.Logger.Debug("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
logger.Debug("native socket client connected")
logging.Logger.Debug("native socket client connected")
if ctrlSocketConn != nil {
logger.Debugf("closing existing native socket connection")
logging.Logger.Debugf("closing existing native socket connection")
ctrlSocketConn.Close()
}
@ -155,15 +158,15 @@ func handleCtrlClient(conn net.Conn) {
for {
n, err := conn.Read(readBuf)
if err != nil {
logger.Errorf("error reading from ctrl sock: %v", err)
logging.Logger.Errorf("error reading from ctrl sock: %v", err)
break
}
readMsg := string(readBuf[:n])
logger.Tracef("ctrl sock msg: %v", readMsg)
logging.Logger.Tracef("ctrl sock msg: %v", readMsg)
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
logger.Warnf("error parsing ctrl sock msg: %v", err)
logging.Logger.Warnf("error parsing ctrl sock msg: %v", err)
continue
}
if ctrlResp.Seq != 0 {
@ -178,7 +181,7 @@ func handleCtrlClient(conn net.Conn) {
}
}
logger.Debug("ctrl sock disconnected")
logging.Logger.Debug("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
@ -186,7 +189,7 @@ func handleVideoClient(conn net.Conn) {
log.Printf("Native video socket client connected: %v", conn.RemoteAddr())
inboundPacket := make([]byte, maxFrameSize)
inboundPacket := make([]byte, MaxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
@ -207,7 +210,7 @@ func handleVideoClient(conn net.Conn) {
}
}
func ExtractAndRunNativeBin() error {
func ExtractAndRunNativeBin(ctx context.Context) error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil {
return fmt.Errorf("failed to extract binary: %w", err)
@ -231,11 +234,11 @@ func ExtractAndRunNativeBin() error {
//TODO: add auto restart
go func() {
<-appCtx.Done()
logger.Infof("killing process PID: %d", cmd.Process.Pid)
<-ctx.Done()
logging.Logger.Infof("killing process PID: %d", cmd.Process.Pid)
err := cmd.Process.Kill()
if err != nil {
logger.Errorf("failed to kill process: %v", err)
logging.Logger.Errorf("failed to kill process: %v", err)
return
}
}()
@ -247,13 +250,13 @@ func ExtractAndRunNativeBin() error {
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
logging.Logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
logging.Logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
@ -269,13 +272,13 @@ func ensureBinaryUpdated(destPath string) error {
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
logging.Logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
logger.Info("writing jetkvm_native")
logging.Logger.Info("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
@ -292,7 +295,7 @@ func ensureBinaryUpdated(destPath string) error {
return err
}
}
logger.Info("jetkvm_native updated")
logging.Logger.Info("jetkvm_native updated")
}
return nil

View File

@ -1,20 +1,18 @@
package network
package kvm
import (
"fmt"
"net"
"time"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/pion/mdns/v2"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var networkState struct {
var NetworkState struct {
Up bool
IPv4 string
IPv6 string
@ -57,10 +55,10 @@ func checkNetworkState() {
}
}
if newState != networkState {
networkState = newState
if newState != NetworkState {
NetworkState = newState
fmt.Println("network state changed")
hardware.RequestDisplayUpdate()
RequestDisplayUpdate()
}
}
@ -105,7 +103,7 @@ func init() {
}
go func() {
waitCtrlClientConnected()
WaitCtrlClientConnected()
checkNetworkState()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

View File

@ -1,4 +1,4 @@
package network
package kvm
import (
"errors"

View File

@ -1,4 +1,4 @@
package network
package kvm
import (
"bytes"
@ -13,10 +13,10 @@ import (
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/jetkvm/kvm/internal/logging"
)
type UpdateMetadata struct {
@ -43,27 +43,6 @@ type UpdateStatus struct {
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
var builtAppVersion = "0.1.0+dev"
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil {
return nil, nil, fmt.Errorf("invalid built-in app version: %w", err)
}
systemVersionBytes, err := os.ReadFile("/version")
if err != nil {
return nil, appVersion, fmt.Errorf("error reading system version: %w", err)
}
systemVersion, err = semver.NewVersion(strings.TrimSpace(string(systemVersionBytes)))
if err != nil {
return nil, appVersion, fmt.Errorf("invalid system version: %w", err)
}
return systemVersion, appVersion, nil
}
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
@ -498,6 +477,6 @@ func IsUpdatePending() bool {
func ConfirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
if err != nil {
logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
logging.Logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
}
}

View File

@ -1,32 +1,34 @@
package server
package kvm
import (
"context"
"encoding/json"
"errors"
"github.com/jetkvm/kvm/internal/logging"
)
type RemoteImageReader interface {
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
}
type WebRTCDiskReader struct {
type WebRTCDiskReaderStruct struct {
}
var webRTCDiskReader WebRTCDiskReader
var WebRTCDiskReader WebRTCDiskReaderStruct
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
virtualMediaStateMutex.RLock()
if currentVirtualMediaState == nil {
virtualMediaStateMutex.RUnlock()
func (w *WebRTCDiskReaderStruct) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
VirtualMediaStateMutex.RLock()
if CurrentVirtualMediaState == nil {
VirtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted")
}
if currentVirtualMediaState.Source != WebRTC {
virtualMediaStateMutex.RUnlock()
if CurrentVirtualMediaState.Source != WebRTC {
VirtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted from webrtc")
}
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
mountedImageSize := CurrentVirtualMediaState.Size
VirtualMediaStateMutex.RUnlock()
end := offset + size
if end > mountedImageSize {
end = mountedImageSize
@ -44,7 +46,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
return nil, errors.New("not active session")
}
logger.Debugf("reading from webrtc %v", string(jsonBytes))
logging.Logger.Debugf("reading from webrtc %v", string(jsonBytes))
err = CurrentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
@ -52,7 +54,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
buf := make([]byte, 0)
for {
select {
case data := <-diskReadChan:
case data := <-DiskReadChan:
buf = data[16:]
case <-ctx.Done():
return nil, context.Canceled

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"encoding/json"

View File

@ -1,4 +1,4 @@
package hardware
package kvm
import (
"errors"
@ -41,7 +41,7 @@ func init() {
logging.UsbLogger.Error("no udc found, skipping USB stack init")
return
}
udc = udcs[0]
Udc = udcs[0]
_, err := os.Stat(kvmGadgetPath)
if err == nil {
logging.Logger.Info("usb gadget already exists, skipping usb gadget initialization")
@ -208,7 +208,7 @@ func writeGadgetConfig() error {
return err
}
err = os.WriteFile(path.Join(kvmGadgetPath, "UDC"), []byte(udc), 0644)
err = os.WriteFile(path.Join(kvmGadgetPath, "UDC"), []byte(Udc), 0644)
if err != nil {
return err
}
@ -217,11 +217,11 @@ func writeGadgetConfig() error {
}
func rebindUsb() error {
err := os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
err := os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(Udc), 0644)
if err != nil {
return err
}
err = os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
err = os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(Udc), 0644)
if err != nil {
return err
}
@ -255,7 +255,7 @@ func RPCKeyboardReport(modifier uint8, keys []uint8) error {
keyboardHidFile = nil
return err
}
kvm.ResetUserInputTime()
ResetUserInputTime()
return err
}
@ -269,7 +269,7 @@ func RPCAbsMouseReport(x, y int, buttons uint8) error {
return fmt.Errorf("failed to open hidg1: %w", err)
}
}
kvm.ResetUserInputTime()
ResetUserInputTime()
_, err := mouseHidFile.Write([]byte{
1, // Report ID 1
buttons, // Buttons
@ -308,7 +308,7 @@ func RPCWheelReport(wheelY int8) error {
// Reset the accumulator, keeping any remainder
accumulatedWheelY -= float64(scaledWheelY)
kvm.ResetUserInputTime()
ResetUserInputTime()
return err
}
@ -323,7 +323,7 @@ func abs(x float64) float64 {
return x
}
var usbState = "unknown"
var UsbState = "unknown"
func RPCGetUSBState() (state string) {
stateBytes, err := os.ReadFile("/sys/class/udc/ffb00000.usb/state")
@ -335,24 +335,24 @@ func RPCGetUSBState() (state string) {
func TriggerUSBStateUpdate() {
go func() {
if kvm.CurrentSession == nil {
if CurrentSession == nil {
log.Println("No active RPC session, skipping update state update")
return
}
WriteJSONRPCEvent("usbState", usbState, kvm.CurrentSession)
WriteJSONRPCEvent("usbState", UsbState, CurrentSession)
}()
}
var udc string
var Udc string
func init() {
go func() {
for {
newState := RPCGetUSBState()
if newState != usbState {
log.Printf("USB state changed from %s to %s", usbState, newState)
usbState = newState
requestDisplayUpdate()
if newState != UsbState {
log.Printf("USB state changed from %s to %s", UsbState, newState)
UsbState = newState
RequestDisplayUpdate()
TriggerUSBStateUpdate()
}
time.Sleep(500 * time.Millisecond)

View File

@ -1,11 +1,10 @@
package hardware
package kvm
import (
"encoding/json"
"errors"
"fmt"
"io"
"kvm/resource"
"log"
"net/http"
"os"
@ -16,6 +15,8 @@ import (
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/gin-gonic/gin"
"github.com/jetkvm/kvm/internal/logging"
@ -55,7 +56,7 @@ func SetMassStorageMode(cdrom bool) error {
func OnDiskMessage(msg webrtc.DataChannelMessage) {
fmt.Println("Disk Message, len:", len(msg.Data))
diskReadChan <- msg.Data
DiskReadChan <- msg.Data
}
func mountImage(imagePath string) error {
@ -153,18 +154,18 @@ type VirtualMediaState struct {
Size int64 `json:"size"`
}
var currentVirtualMediaState *VirtualMediaState
var virtualMediaStateMutex sync.RWMutex
var CurrentVirtualMediaState *VirtualMediaState
var VirtualMediaStateMutex sync.RWMutex
func RPCGetVirtualMediaState() (*VirtualMediaState, error) {
virtualMediaStateMutex.RLock()
defer virtualMediaStateMutex.RUnlock()
return currentVirtualMediaState, nil
VirtualMediaStateMutex.RLock()
defer VirtualMediaStateMutex.RUnlock()
return CurrentVirtualMediaState, nil
}
func RPCUnmountImage() error {
virtualMediaStateMutex.Lock()
defer virtualMediaStateMutex.Unlock()
VirtualMediaStateMutex.Lock()
defer VirtualMediaStateMutex.Unlock()
err := setMassStorageImage("\n")
if err != nil {
fmt.Println("Remove Mass Storage Image Error", err)
@ -175,32 +176,32 @@ func RPCUnmountImage() error {
nbdDevice.Close()
nbdDevice = nil
}
currentVirtualMediaState = nil
CurrentVirtualMediaState = nil
return nil
}
var httpRangeReader *httpreadat.RangeReader
var HttpRangeReader *httpreadat.RangeReader
func RPCMountWithHTTP(url string, mode VirtualMediaMode) error {
virtualMediaStateMutex.Lock()
if currentVirtualMediaState != nil {
virtualMediaStateMutex.Unlock()
VirtualMediaStateMutex.Lock()
if CurrentVirtualMediaState != nil {
VirtualMediaStateMutex.Unlock()
return fmt.Errorf("another virtual media is already mounted")
}
httpRangeReader = httpreadat.New(url)
n, err := httpRangeReader.Size()
HttpRangeReader = httpreadat.New(url)
n, err := HttpRangeReader.Size()
if err != nil {
virtualMediaStateMutex.Unlock()
VirtualMediaStateMutex.Unlock()
return fmt.Errorf("failed to use http url: %w", err)
}
logging.Logger.Infof("using remote url %s with size %d", url, n)
currentVirtualMediaState = &VirtualMediaState{
CurrentVirtualMediaState = &VirtualMediaState{
Source: HTTP,
Mode: mode,
URL: url,
Size: n,
}
virtualMediaStateMutex.Unlock()
VirtualMediaStateMutex.Unlock()
logging.Logger.Debug("Starting nbd device")
nbdDevice = NewNBDDevice()
@ -221,19 +222,19 @@ func RPCMountWithHTTP(url string, mode VirtualMediaMode) error {
}
func RPCMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) error {
virtualMediaStateMutex.Lock()
if currentVirtualMediaState != nil {
virtualMediaStateMutex.Unlock()
VirtualMediaStateMutex.Lock()
if CurrentVirtualMediaState != nil {
VirtualMediaStateMutex.Unlock()
return fmt.Errorf("another virtual media is already mounted")
}
currentVirtualMediaState = &VirtualMediaState{
CurrentVirtualMediaState = &VirtualMediaState{
Source: WebRTC,
Mode: mode,
Filename: filename,
Size: size,
}
virtualMediaStateMutex.Unlock()
logging.Logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
VirtualMediaStateMutex.Unlock()
logging.Logger.Debugf("currentVirtualMediaState is %v", CurrentVirtualMediaState)
logging.Logger.Debug("Starting nbd device")
nbdDevice = NewNBDDevice()
err := nbdDevice.Start()
@ -258,9 +259,9 @@ func RPCMountWithStorage(filename string, mode VirtualMediaMode) error {
return err
}
virtualMediaStateMutex.Lock()
defer virtualMediaStateMutex.Unlock()
if currentVirtualMediaState != nil {
VirtualMediaStateMutex.Lock()
defer VirtualMediaStateMutex.Unlock()
if CurrentVirtualMediaState != nil {
return fmt.Errorf("another virtual media is already mounted")
}
@ -274,7 +275,7 @@ func RPCMountWithStorage(filename string, mode VirtualMediaMode) error {
if err != nil {
return fmt.Errorf("failed to set mass storage image: %w", err)
}
currentVirtualMediaState = &VirtualMediaState{
CurrentVirtualMediaState = &VirtualMediaState{
Source: Storage,
Mode: mode,
Filename: filename,

View File

@ -1,14 +1,12 @@
package server
package kvm
import (
"encoding/json"
"log"
"github.com/jetkvm/kvm/internal/hardware"
)
// max frame size for 1080p video, specified in mpp venc setting
const maxFrameSize = 1920 * 1080 / 2
const MaxFrameSize = 1920 * 1080 / 2
func writeCtrlAction(action string) error {
actionMessage := map[string]string{
@ -18,7 +16,7 @@ func writeCtrlAction(action string) error {
if err != nil {
return err
}
err = hardware.WriteCtrlMessage(jsonMessage)
err = WriteCtrlMessage(jsonMessage)
return err
}
@ -30,25 +28,25 @@ type VideoInputState struct {
FramePerSecond float64 `json:"fps"`
}
var lastVideoState VideoInputState
var LastVideoState VideoInputState
func TriggerVideoStateUpdate() {
go func() {
WriteJSONRPCEvent("videoInputState", lastVideoState, CurrentSession)
WriteJSONRPCEvent("videoInputState", LastVideoState, CurrentSession)
}()
}
func HandleVideoStateMessage(event hardware.CtrlResponse) {
func HandleVideoStateMessage(event CtrlResponse) {
videoState := VideoInputState{}
err := json.Unmarshal(event.Data, &videoState)
if err != nil {
log.Println("Error parsing video state json:", err)
return
}
lastVideoState = videoState
LastVideoState = videoState
TriggerVideoStateUpdate()
hardware.RequestDisplayUpdate()
RequestDisplayUpdate()
}
func rpcGetVideoState() (VideoInputState, error) {
return lastVideoState, nil
return LastVideoState, nil
}

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"embed"
@ -11,13 +11,11 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/config"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/server"
"golang.org/x/crypto/bcrypt"
)
//go:embed all:static
var staticFiles embed.FS
//go:embed static
var StaticFiles embed.FS
type WebRTCSessionRequest struct {
Sd string `json:"sd"`
@ -56,7 +54,7 @@ func setupRouter() *gin.Engine {
gin.DisableConsoleColor()
r := gin.Default()
staticFS, _ := fs.Sub(staticFiles, "static")
staticFS, _ := fs.Sub(StaticFiles, "static")
// Add a custom middleware to set cache headers for images
// This is crucial for optimizing the initial welcome screen load time
@ -86,14 +84,14 @@ func setupRouter() *gin.Engine {
protected.Use(protectedMiddleware())
{
protected.POST("/webrtc/session", handleWebRTCSession)
protected.POST("/cloud/register", server.HandleCloudRegister)
protected.POST("/cloud/register", HandleCloudRegister)
protected.GET("/device", handleDevice)
protected.POST("/auth/logout", handleLogout)
protected.POST("/auth/password-local", handleCreatePassword)
protected.PUT("/auth/password-local", handleUpdatePassword)
protected.DELETE("/auth/local-password", handleDeletePassword)
protected.POST("/storage/upload", hardware.HandleUploadHttp)
protected.POST("/storage/upload", HandleUploadHttp)
}
// Catch-all route for SPA
@ -119,7 +117,7 @@ func handleWebRTCSession(c *gin.Context) {
return
}
session, err := server.NewSession()
session, err := NewSession()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
@ -220,7 +218,7 @@ func handleDevice(c *gin.Context) {
response := LocalDevice{
AuthMode: &cfg.LocalAuthMode,
DeviceID: hardware.GetDeviceID(),
DeviceID: GetDeviceID(),
}
c.JSON(http.StatusOK, response)

View File

@ -1,4 +1,4 @@
package server
package kvm
import (
"encoding/base64"
@ -6,9 +6,7 @@ import (
"fmt"
"strings"
"github.com/jetkvm/kvm/internal/hardware"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/server"
"github.com/pion/webrtc/v4"
)
@ -79,19 +77,19 @@ func NewSession() (*Session, error) {
case "rpc":
session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) {
go server.OnRPCMessage(msg, session)
go OnRPCMessage(msg, session)
})
server.TriggerOTAStateUpdate()
server.TriggerVideoStateUpdate()
hardware.TriggerUSBStateUpdate()
TriggerOTAStateUpdate()
TriggerVideoStateUpdate()
TriggerUSBStateUpdate()
case "disk":
session.DiskChannel = d
d.OnMessage(hardware.OnDiskMessage)
d.OnMessage(OnDiskMessage)
case "terminal":
server.HandleTerminalChannel(d)
HandleTerminalChannel(d)
default:
if strings.HasPrefix(d.Label(), hardware.UploadIdPrefix) {
go hardware.HandleUploadChannel(d)
if strings.HasPrefix(d.Label(), UploadIdPrefix) {
go HandleUploadChannel(d)
}
}
})
@ -124,9 +122,9 @@ func NewSession() (*Session, error) {
if connectionState == webrtc.ICEConnectionStateConnected {
if !isConnected {
isConnected = true
actionSessions++
ActionSessions++
onActiveSessionsChanged()
if actionSessions == 1 {
if ActionSessions == 1 {
onFirstSessionConnected()
}
}
@ -140,14 +138,14 @@ func NewSession() (*Session, error) {
CurrentSession = nil
}
if session.shouldUmountVirtualMedia {
err := hardware.RPCUnmountImage()
err := RPCUnmountImage()
logging.Logger.Debugf("unmount image failed on connection close %v", err)
}
if isConnected {
isConnected = false
actionSessions--
ActionSessions--
onActiveSessionsChanged()
if actionSessions == 0 {
if ActionSessions == 0 {
onLastSessionDisconnected()
}
}
@ -156,10 +154,10 @@ func NewSession() (*Session, error) {
return session, nil
}
var actionSessions = 0
var ActionSessions = 0
func onActiveSessionsChanged() {
hardware.RequestDisplayUpdate()
RequestDisplayUpdate()
}
func onFirstSessionConnected() {

View File

@ -6,3 +6,5 @@ import "github.com/pion/logging"
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var Logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var UsbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
// Ideally you would implement some kind of logging system here with our own custom logging functions

View File

@ -1,46 +1,46 @@
#!/bin/bash
# Check if a commit message was provided
if [ -z "$1" ]; then
echo "Usage: $0 \"Your commit message here\""
exit 1
fi
COMMIT_MESSAGE="$1"
# Ensure you're on the main branch
git checkout main
# Add 'public' remote if it doesn't exist
if ! git remote | grep -q '^public$'; then
git remote add public https://github.com/jetkvm/kvm.git
fi
# Fetch the latest from the public repository
git fetch public || true
# Create a temporary branch for the release
git checkout -b release-temp
# If public/main exists, reset to it; else, use the root commit
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft $(git rev-list --max-parents=0 HEAD)
fi
# Merge changes from main
git merge --squash main
# Commit all changes as a single release commit
git commit -m "$COMMIT_MESSAGE"
# Force push the squashed commit to the public repository
git push --force public release-temp:main
# Switch back to main and delete the temporary branch
git checkout main
git branch -D release-temp
# Remove the public remote
git remote remove public
#!/bin/bash
# Check if a commit message was provided
if [ -z "$1" ]; then
echo "Usage: $0 \"Your commit message here\""
exit 1
fi
COMMIT_MESSAGE="$1"
# Ensure you're on the main branch
git checkout main
# Add 'public' remote if it doesn't exist
if ! git remote | grep -q '^public$'; then
git remote add public https://github.com/jetkvm/kvm.git
fi
# Fetch the latest from the public repository
git fetch public || true
# Create a temporary branch for the release
git checkout -b release-temp
# If public/main exists, reset to it; else, use the root commit
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft $(git rev-list --max-parents=0 HEAD)
fi
# Merge changes from main
git merge --squash main
# Commit all changes as a single release commit
git commit -m "$COMMIT_MESSAGE"
# Force push the squashed commit to the public repository
git push --force public release-temp:main
# Switch back to main and delete the temporary branch
git checkout main
git branch -D release-temp
# Remove the public remote
git remote remove public

View File

@ -1,8 +1,8 @@
package resource
import (
"embed"
)
//go:embed jetkvm_native jetkvm_native.sha256 netboot.xyz-multiarch.iso
var ResourceFS embed.FS
package resource
import (
"embed"
)
//go:embed jetkvm_native jetkvm_native.sha256 netboot.xyz-multiarch.iso
var ResourceFS embed.FS

View File

@ -1,4 +1,4 @@
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000

View File

@ -1,4 +1,4 @@
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

View File

@ -1,4 +1,4 @@
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

View File

@ -1,24 +1,24 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
},
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
},
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};

48
ui/.gitignore vendored
View File

@ -1,24 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,15 +1,15 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"useTabs": false,
"arrowParens": "avoid",
"singleQuote": false,
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"useTabs": false,
"arrowParens": "avoid",
"singleQuote": false,
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90
}

View File

@ -1,57 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app -->
<link
rel="preload"
href="/fonts/CircularXXWeb-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Book.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
<script>
// Initial theme setup
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
// Listen for system theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
</script>
</head>
<body
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased dark:bg-slate-900 md:text-base"
>
<div id="root" class="w-full h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app -->
<link
rel="preload"
href="/fonts/CircularXXWeb-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Book.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
<script>
// Initial theme setup
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
// Listen for system theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
</script>
</head>
<body
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased dark:bg-slate-900 md:text-base"
>
<div id="root" class="w-full h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12604
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,66 +1,66 @@
{
"name": "kvm-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "21.1.0"
},
"scripts": {
"dev": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@headlessui/react": "^2.1.10",
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"cva": "^1.0.0-beta.1",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.28",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"usehooks-ts": "^3.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}
{
"name": "kvm-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "21.1.0"
},
"scripts": {
"dev": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@headlessui/react": "^2.1.10",
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"cva": "^1.0.0-beta.1",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.28",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"usehooks-ts": "^3.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,111 +1,111 @@
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Thin.woff2") format("woff2");
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ThinItalic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-LightItalic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Book.woff2") format("woff2");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BookItalic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Medium.woff2") format("woff2");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-MediumItalic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Black.woff2") format("woff2");
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BlackItalic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlack.woff2") format("woff2");
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlackItalic.woff2") format("woff2");
font-weight: 900;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Thin.woff2") format("woff2");
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ThinItalic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-LightItalic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Book.woff2") format("woff2");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BookItalic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Medium.woff2") format("woff2");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-MediumItalic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Black.woff2") format("woff2");
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BlackItalic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlack.woff2") format("woff2");
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlackItalic.woff2") format("woff2");
font-weight: 900;
font-style: italic;
}

View File

@ -1,2 +1,2 @@
User-agent: *
Disallow: /
User-agent: *
Disallow: /

View File

@ -1,22 +1,22 @@
function api(url: string, options: RequestInit): Promise<Response> {
const baseOptions: RequestInit = {
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
...options,
};
return fetch(url, baseOptions);
}
export default Object.assign(api, {
GET: (url: string, options?: RequestInit) => api(url, { method: "GET", ...options }),
POST: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "POST", body: JSON.stringify(body), ...options }),
PUT: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "PUT", body: JSON.stringify(body), ...options }),
DELETE: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "DELETE", body: JSON.stringify(body), ...options }),
});
function api(url: string, options: RequestInit): Promise<Response> {
const baseOptions: RequestInit = {
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
...options,
};
return fetch(url, baseOptions);
}
export default Object.assign(api, {
GET: (url: string, options?: RequestInit) => api(url, { method: "GET", ...options }),
POST: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "POST", body: JSON.stringify(body), ...options }),
PUT: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "PUT", body: JSON.stringify(body), ...options }),
DELETE: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "DELETE", body: JSON.stringify(body), ...options }),
});

View File

@ -1,8 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 519 B

View File

@ -1,15 +1,15 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 7C2 5.89543 2.89543 5 4 5H20C21.1046 5 22 5.89543 22 7V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V7Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 7H4V19H20V7ZM4 5C2.89543 5 2 5.89543 2 7V19C2 20.1046 2.89543 21 4 21H20C21.1046 21 22 20.1046 22 19V7C22 5.89543 21.1046 5 20 5H4Z"
fill="currentColor" />
<path d="M12 3H24V15H12V3Z" fill="transparent" />
<path
d="M14 6C14 5.44772 14.4477 5 15 5H21C21.5523 5 22 5.44772 22 6V12C22 12.5523 21.5523 13 21 13H15C14.4477 13 14 12.5523 14 12V6Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 7V11H20V7H16ZM15 5C14.4477 5 14 5.44772 14 6V12C14 12.5523 14.4477 13 15 13H21C21.5523 13 22 12.5523 22 12V6C22 5.44772 21.5523 5 21 5H15Z"
fill="currentColor" />
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 7C2 5.89543 2.89543 5 4 5H20C21.1046 5 22 5.89543 22 7V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V7Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 7H4V19H20V7ZM4 5C2.89543 5 2 5.89543 2 7V19C2 20.1046 2.89543 21 4 21H20C21.1046 21 22 20.1046 22 19V7C22 5.89543 21.1046 5 20 5H4Z"
fill="currentColor" />
<path d="M12 3H24V15H12V3Z" fill="transparent" />
<path
d="M14 6C14 5.44772 14.4477 5 15 5H21C21.5523 5 22 5.44772 22 6V12C22 12.5523 21.5523 13 21 13H15C14.4477 13 14 12.5523 14 12V6Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 7V11H20V7H16ZM15 5C14.4477 5 14 5.44772 14 6V12C14 12.5523 14.4477 13 15 13H21C21.5523 13 22 12.5523 22 12V6C22 5.44772 21.5523 5 21 5H15Z"
fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 991 B

After

Width:  |  Height:  |  Size: 1006 B

View File

@ -1,12 +1,12 @@
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z" fill="#1D4ED8"/>
</svg>
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z" fill="#1D4ED8"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -1,30 +1,30 @@
<svg viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z"
fill="white" />
</svg>
<svg viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z"
fill="white" />
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -1,11 +1,11 @@
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_210)">
<path d="M11.5 1V7.60833H17.3333C17.3333 4.20833 14.7917 1.40833 11.5 1ZM4 12.6083C4 16.2917 6.98333 19.275 10.6667 19.275C14.35 19.275 17.3333 16.2917 17.3333 12.6083V9.275H4V12.6083ZM9.83333 1C6.54167 1.40833 4 4.20833 4 7.60833H9.83333V1Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_210">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_210)">
<path d="M11.5 1V7.60833H17.3333C17.3333 4.20833 14.7917 1.40833 11.5 1ZM4 12.6083C4 16.2917 6.98333 19.275 10.6667 19.275C14.35 19.275 17.3333 16.2917 17.3333 12.6083V9.275H4V12.6083ZM9.83333 1C6.54167 1.40833 4 4.20833 4 7.60833H9.83333V1Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_210">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

After

Width:  |  Height:  |  Size: 586 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,11 +1,11 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_200)">
<path d="M7.49967 9.36667V6.25C7.49967 5.69747 7.71917 5.16756 8.10987 4.77686C8.50057 4.38616 9.03047 4.16667 9.58301 4.16667C10.1355 4.16667 10.6654 4.38616 11.0561 4.77686C11.4468 5.16756 11.6663 5.69747 11.6663 6.25V9.36667C12.6747 8.69167 13.333 7.55 13.333 6.25C13.333 4.175 11.658 2.5 9.58301 2.5C7.50801 2.5 5.83301 4.175 5.83301 6.25C5.83301 7.55 6.49134 8.69167 7.49967 9.36667ZM15.6997 13.225L11.9163 11.3417C11.7747 11.2833 11.6247 11.25 11.4663 11.25H10.833V6.25C10.833 5.55833 10.2747 5 9.58301 5C8.89134 5 8.33301 5.55833 8.33301 6.25V15.2C5.33301 14.5667 5.38301 14.575 5.27467 14.575C5.01634 14.575 4.78301 14.6833 4.61634 14.85L3.95801 15.5167L8.07467 19.6333C8.29967 19.8583 8.61634 20 8.95801 20H14.6163C15.2413 20 15.7247 19.5417 15.8163 18.9333L16.4413 14.5417C16.4497 14.4833 16.458 14.425 16.458 14.375C16.458 13.8583 16.1413 13.4083 15.6997 13.225Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_200">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_200)">
<path d="M7.49967 9.36667V6.25C7.49967 5.69747 7.71917 5.16756 8.10987 4.77686C8.50057 4.38616 9.03047 4.16667 9.58301 4.16667C10.1355 4.16667 10.6654 4.38616 11.0561 4.77686C11.4468 5.16756 11.6663 5.69747 11.6663 6.25V9.36667C12.6747 8.69167 13.333 7.55 13.333 6.25C13.333 4.175 11.658 2.5 9.58301 2.5C7.50801 2.5 5.83301 4.175 5.83301 6.25C5.83301 7.55 6.49134 8.69167 7.49967 9.36667ZM15.6997 13.225L11.9163 11.3417C11.7747 11.2833 11.6247 11.25 11.4663 11.25H10.833V6.25C10.833 5.55833 10.2747 5 9.58301 5C8.89134 5 8.33301 5.55833 8.33301 6.25V15.2C5.33301 14.5667 5.38301 14.575 5.27467 14.575C5.01634 14.575 4.78301 14.6833 4.61634 14.85L3.95801 15.5167L8.07467 19.6333C8.29967 19.8583 8.61634 20 8.95801 20H14.6163C15.2413 20 15.7247 19.5417 15.8163 18.9333L16.4413 14.5417C16.4497 14.4833 16.458 14.425 16.458 14.375C16.458 13.8583 16.1413 13.4083 15.6997 13.225Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_200">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,258 +1,258 @@
import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
const checkIfStateChanged = useCallback(
(open: boolean) => {
if (open !== isOpen.current) {
isOpen.current = open;
if (!open) {
setTimeout(() => {
setDisableFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video");
}, 0);
}
}
},
[setDisableFocusTrap],
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
<Button
size="XS"
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
/>
)}
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Paste text"
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<PasteModal />
</div>
);
}}
</PopoverPanel>
</Popover>
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Virtual Media"
LeadingIcon={({ className }) => {
return (
<>
<LuHardDrive className={className} />
<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState,
})}
/>
</>
);
}}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<MountPopopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Wake on Lan"
onClick={() => {
setDisableFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
<path d="M6 8v1" />
<path d="M10 8v1" />
<path d="M14 8v1" />
<path d="M18 8v1" />
</svg>
)}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
style={{
transitionProperty: "opacity",
}}
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<WakeOnLanModal />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<div className="block lg:hidden">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
<div className="hidden md:block">
<Button
size="XS"
theme="light"
text="Connection Stats"
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
strokeWidth={4}
/>
)}
onClick={() => {
toggleSidebarView("connection-stats");
}}
/>
</div>
<div className="hidden xs:block ">
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>
</div>
</div>
</div>
</Container>
);
}
import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
const checkIfStateChanged = useCallback(
(open: boolean) => {
if (open !== isOpen.current) {
isOpen.current = open;
if (!open) {
setTimeout(() => {
setDisableFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video");
}, 0);
}
}
},
[setDisableFocusTrap],
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
<Button
size="XS"
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
/>
)}
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Paste text"
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<PasteModal />
</div>
);
}}
</PopoverPanel>
</Popover>
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Virtual Media"
LeadingIcon={({ className }) => {
return (
<>
<LuHardDrive className={className} />
<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState,
})}
/>
</>
);
}}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<MountPopopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Wake on Lan"
onClick={() => {
setDisableFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
<path d="M6 8v1" />
<path d="M10 8v1" />
<path d="M14 8v1" />
<path d="M18 8v1" />
</svg>
)}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
style={{
transitionProperty: "opacity",
}}
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<WakeOnLanModal />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<div className="block lg:hidden">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
<div className="hidden md:block">
<Button
size="XS"
theme="light"
text="Connection Stats"
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
strokeWidth={4}
/>
)}
onClick={() => {
toggleSidebarView("connection-stats");
}}
/>
</div>
<div className="hidden xs:block ">
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>
</div>
</div>
</div>
</Container>
);
}

View File

@ -1,99 +1,99 @@
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
type AuthLayoutProps = {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
};
export default function AuthLayout({
title,
description,
action,
cta,
ctaHref,
showCounter,
}: AuthLayoutProps) {
const [sq] = useSearchParams();
const location = useLocation();
const returnTo = sq.get("returnTo") || location.state?.returnTo;
const deviceId = sq.get("deviceId") || location.state?.deviceId;
const navigation = useNavigation();
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar
logoHref="/"
actionElement={
<div>
<LinkButton to={ctaHref} text={cta} theme="light" size="MD" />
</div>
}
/>
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
{showCounter ? (
<div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} />
</div>
) : null}
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
{title}
</h1>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />
) : null}
{returnTo ? (
<input type="hidden" name="returnTo" value={returnTo} />
) : null}
<Button
size="LG"
theme="light"
fullWidth
text={`${action}`}
LeadingIcon={GoogleIcon}
textAlign="center"
type="submit"
loading={
(navigation.state === "submitting" ||
navigation.state === "loading") &&
navigation.formMethod?.toLowerCase() === "post" &&
navigation.formAction?.includes("auth/google")
}
/>
</form>
</div>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
type AuthLayoutProps = {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
};
export default function AuthLayout({
title,
description,
action,
cta,
ctaHref,
showCounter,
}: AuthLayoutProps) {
const [sq] = useSearchParams();
const location = useLocation();
const returnTo = sq.get("returnTo") || location.state?.returnTo;
const deviceId = sq.get("deviceId") || location.state?.deviceId;
const navigation = useNavigation();
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar
logoHref="/"
actionElement={
<div>
<LinkButton to={ctaHref} text={cta} theme="light" size="MD" />
</div>
}
/>
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
{showCounter ? (
<div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} />
</div>
) : null}
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
{title}
</h1>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />
) : null}
{returnTo ? (
<input type="hidden" name="returnTo" value={returnTo} />
) : null}
<Button
size="LG"
theme="light"
fullWidth
text={`${action}`}
LeadingIcon={GoogleIcon}
textAlign="center"
type="submit"
loading={
(navigation.state === "submitting" ||
navigation.state === "loading") &&
navigation.formMethod?.toLowerCase() === "post" &&
navigation.formAction?.includes("auth/google")
}
/>
</form>
</div>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}

View File

@ -1,34 +1,34 @@
import { useRef, useState, useEffect } from "react";
import AnimateHeight, { Height } from "react-animate-height";
const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
const [height, setHeight] = useState<Height>("auto");
const contentDiv = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = contentDiv.current as HTMLDivElement;
const resizeObserver = new ResizeObserver(() => {
setHeight(element.clientHeight);
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return (
<AnimateHeight
{...props}
height={height}
duration={300}
contentClassName="auto-content pointer-events-none"
contentRef={contentDiv}
disableDisplayNone
>
{children}
</AnimateHeight>
);
};
export default AutoHeight;
import { useRef, useState, useEffect } from "react";
import AnimateHeight, { Height } from "react-animate-height";
const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
const [height, setHeight] = useState<Height>("auto");
const contentDiv = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = contentDiv.current as HTMLDivElement;
const resizeObserver = new ResizeObserver(() => {
setHeight(element.clientHeight);
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return (
<AnimateHeight
{...props}
height={height}
duration={300}
contentClassName="auto-content pointer-events-none"
contentRef={contentDiv}
disableDisplayNone
>
{children}
</AnimateHeight>
);
};
export default AutoHeight;

View File

@ -1,245 +1,245 @@
import React from "react";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
SM: "h-[36px] px-3 text-[13px]",
MD: "h-[40px] px-3.5 text-sm",
LG: "h-[48px] px-4 text-base",
XL: "h-[56px] px-5 text-base",
};
const themes = {
primary: cx(
// Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
// Hover states
"group-hover:bg-blue-800",
// Active states
"group-active:bg-blue-900",
),
danger: cx(
// Base styles
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states
"group-active:bg-red-800 dark:group-active:bg-red-800",
// Focus states
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
),
light: cx(
// Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
// Disabled states
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
),
lightDanger: cx(
// Base styles
"bg-white text-black border-red-400/60 shadow-sm",
// Hover states
"group-hover:bg-red-50/80",
// Active states
"group-active:bg-red-100/60",
// Focus states
"group-focus:ring-red-700",
),
blank: cx(
// Base styles
"bg-white/0 text-black border-transparent dark:text-white",
// Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states
"group-active:bg-slate-100/80",
),
};
const btnVariants = cva({
base: cx(
// Base styles
"border rounded select-none",
// Size classes
"justify-center items-center shrink-0",
// Transition classes
"outline-none transition-all duration-200",
// Text classes
"font-display text-center font-medium leading-tight",
// States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none",
),
variants: {
size: sizes,
theme: themes,
},
});
const iconVariants = cva({
variants: {
size: {
XS: "h-3.5",
SM: "h-3.5",
MD: "h-5",
LG: "h-6",
XL: "h-6",
},
theme: {
primary: "text-white",
danger: "text-white ",
light: "text-black dark:text-white",
lightDanger: "text-black dark:text-white",
blank: "text-black dark:text-white",
},
},
});
type ButtonContentPropsType = {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
fullWidth?: boolean;
className?: string;
textAlign?: "left" | "center" | "right";
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
};
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
props;
// Based on the size prop, we'll use the corresponding variant classnames
const iconClassName = iconVariants(props);
const btnClassName = btnVariants(props);
return (
<div className={cx(className, fullWidth ? "flex" : "inline-flex", btnClassName)}>
<div
className={cx(
"flex w-full min-w-0 items-center gap-x-1.5 text-center",
textAlign === "left" ? "!text-left" : "",
textAlign === "center" ? "!text-center" : "",
textAlign === "right" ? "!text-right" : "",
)}
>
{loading ? (
<div>
<LoadingSpinner className={cx(iconClassName, "animate-spin")} />
</div>
) : (
LeadingIcon && (
<LeadingIcon className={cx(iconClassName, "shrink-0 justify-start")} />
)
)}
{text && typeof text === "string" ? (
<span className="relative w-full truncate">{text}</span>
) : (
text
)}
{TrailingIcon && (
<TrailingIcon className={cx(iconClassName, "shrink-0 justify-end")} />
)}
</div>
</div>
);
}
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-none",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<button
ref={ref}
formNoValidate={formNoValidate}
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
name={props.name}
value={props.value}
>
<ButtonContent
{...props}
loading={
loading ??
(type === "submit" &&
(loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
/>
</button>
);
},
);
Button.displayName = "Button";
type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx(
"group outline-none",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
if (to.toString().startsWith("http")) {
return (
<ExtLink href={to.toString()} className={classes}>
<ButtonContent {...props} />
</ExtLink>
);
} else {
return (
<Link to={to} className={classes}>
<ButtonContent {...props} />
</Link>
);
}
};
type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx(
"group outline-none block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
return (
<div>
<label htmlFor={htmlFor} className={classes}>
<ButtonContent {...props} />
</label>
</div>
);
};
import React from "react";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
SM: "h-[36px] px-3 text-[13px]",
MD: "h-[40px] px-3.5 text-sm",
LG: "h-[48px] px-4 text-base",
XL: "h-[56px] px-5 text-base",
};
const themes = {
primary: cx(
// Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
// Hover states
"group-hover:bg-blue-800",
// Active states
"group-active:bg-blue-900",
),
danger: cx(
// Base styles
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states
"group-active:bg-red-800 dark:group-active:bg-red-800",
// Focus states
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
),
light: cx(
// Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
// Disabled states
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
),
lightDanger: cx(
// Base styles
"bg-white text-black border-red-400/60 shadow-sm",
// Hover states
"group-hover:bg-red-50/80",
// Active states
"group-active:bg-red-100/60",
// Focus states
"group-focus:ring-red-700",
),
blank: cx(
// Base styles
"bg-white/0 text-black border-transparent dark:text-white",
// Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states
"group-active:bg-slate-100/80",
),
};
const btnVariants = cva({
base: cx(
// Base styles
"border rounded select-none",
// Size classes
"justify-center items-center shrink-0",
// Transition classes
"outline-none transition-all duration-200",
// Text classes
"font-display text-center font-medium leading-tight",
// States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none",
),
variants: {
size: sizes,
theme: themes,
},
});
const iconVariants = cva({
variants: {
size: {
XS: "h-3.5",
SM: "h-3.5",
MD: "h-5",
LG: "h-6",
XL: "h-6",
},
theme: {
primary: "text-white",
danger: "text-white ",
light: "text-black dark:text-white",
lightDanger: "text-black dark:text-white",
blank: "text-black dark:text-white",
},
},
});
type ButtonContentPropsType = {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
fullWidth?: boolean;
className?: string;
textAlign?: "left" | "center" | "right";
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
};
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
props;
// Based on the size prop, we'll use the corresponding variant classnames
const iconClassName = iconVariants(props);
const btnClassName = btnVariants(props);
return (
<div className={cx(className, fullWidth ? "flex" : "inline-flex", btnClassName)}>
<div
className={cx(
"flex w-full min-w-0 items-center gap-x-1.5 text-center",
textAlign === "left" ? "!text-left" : "",
textAlign === "center" ? "!text-center" : "",
textAlign === "right" ? "!text-right" : "",
)}
>
{loading ? (
<div>
<LoadingSpinner className={cx(iconClassName, "animate-spin")} />
</div>
) : (
LeadingIcon && (
<LeadingIcon className={cx(iconClassName, "shrink-0 justify-start")} />
)
)}
{text && typeof text === "string" ? (
<span className="relative w-full truncate">{text}</span>
) : (
text
)}
{TrailingIcon && (
<TrailingIcon className={cx(iconClassName, "shrink-0 justify-end")} />
)}
</div>
</div>
);
}
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-none",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<button
ref={ref}
formNoValidate={formNoValidate}
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
name={props.name}
value={props.value}
>
<ButtonContent
{...props}
loading={
loading ??
(type === "submit" &&
(loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
/>
</button>
);
},
);
Button.displayName = "Button";
type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx(
"group outline-none",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
if (to.toString().startsWith("http")) {
return (
<ExtLink href={to.toString()} className={classes}>
<ButtonContent {...props} />
</ExtLink>
);
} else {
return (
<Link to={to} className={classes}>
<ButtonContent {...props} />
</Link>
);
}
};
type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx(
"group outline-none block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
return (
<div>
<label htmlFor={htmlFor} className={classes}>
<ButtonContent {...props} />
</label>
</div>
);
};

View File

@ -1,38 +1,38 @@
import React from "react";
import { cx } from "@/cva.config";
type CardPropsType = {
children: React.ReactNode;
className?: string;
};
export const GridCard = ({
children,
cardClassName,
}: {
children: React.ReactNode;
cardClassName?: string;
}) => {
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div>
</div>
</Card>
);
};
export default function Card({ children, className }: CardPropsType) {
return (
<div
className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
className,
)}
>
{children}
</div>
);
}
import React from "react";
import { cx } from "@/cva.config";
type CardPropsType = {
children: React.ReactNode;
className?: string;
};
export const GridCard = ({
children,
cardClassName,
}: {
children: React.ReactNode;
cardClassName?: string;
}) => {
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div>
</div>
</Card>
);
};
export default function Card({ children, className }: CardPropsType) {
return (
<div
className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
className,
)}
>
{children}
</div>
);
}

View File

@ -1,19 +1,19 @@
import React from "react";
type Props = {
headline: string;
description?: string | React.ReactNode;
Button?: React.ReactNode;
};
export const CardHeader = ({ headline, description, Button }: Props) => {
return (
<div className="flex items-center justify-between pb-0 gap-x-4">
<div className="space-y-1 grow">
<h3 className="text-lg font-bold leading-none text-black dark:text-white">{headline}</h3>
{description && <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>}
</div>
{Button && <div>{Button}</div>}
</div>
);
};
import React from "react";
type Props = {
headline: string;
description?: string | React.ReactNode;
Button?: React.ReactNode;
};
export const CardHeader = ({ headline, description, Button }: Props) => {
return (
<div className="flex items-center justify-between pb-0 gap-x-4">
<div className="space-y-1 grow">
<h3 className="text-lg font-bold leading-none text-black dark:text-white">{headline}</h3>
{description && <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>}
</div>
{Button && <div>{Button}</div>}
</div>
);
};

View File

@ -1,77 +1,77 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
const sizes = {
SM: "w-4 h-4",
MD: "w-5 h-5",
};
const checkboxVariants = cva({
base: cx(
"block rounded",
// Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
// Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
// Active
"active:bg-slate-200 dark:active:bg-slate-700",
// Focus
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
// Disabled
"disabled:pointer-events-none disabled:opacity-30",
),
variants: { size: sizes },
});
type CheckBoxProps = {
size?: keyof typeof sizes;
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
});
Checkbox.displayName = "Checkbox";
type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
CheckBoxProps & {
fullWidth?: boolean;
disabled?: boolean;
};
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<label
className={clsx(
"flex shrink-0 items-center justify-between gap-x-2",
fullWidth ? "flex" : "inline-flex",
readOnly ? "pointer-events-none opacity-50" : "",
)}
>
<Checkbox ref={ref as never} {...props} />
<div className="max-w-md">
<FieldLabel label={label} id={id} description={description} as="span" />
</div>
</label>
);
},
);
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export default Checkbox;
export { CheckboxWithLabel, Checkbox };
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
const sizes = {
SM: "w-4 h-4",
MD: "w-5 h-5",
};
const checkboxVariants = cva({
base: cx(
"block rounded",
// Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
// Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
// Active
"active:bg-slate-200 dark:active:bg-slate-700",
// Focus
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
// Disabled
"disabled:pointer-events-none disabled:opacity-30",
),
variants: { size: sizes },
});
type CheckBoxProps = {
size?: keyof typeof sizes;
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
});
Checkbox.displayName = "Checkbox";
type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
CheckBoxProps & {
fullWidth?: boolean;
disabled?: boolean;
};
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<label
className={clsx(
"flex shrink-0 items-center justify-between gap-x-2",
fullWidth ? "flex" : "inline-flex",
readOnly ? "pointer-events-none opacity-50" : "",
)}
>
<Checkbox ref={ref as never} {...props} />
<div className="max-w-md">
<FieldLabel label={label} id={id} description={description} as="span" />
</div>
</label>
);
},
);
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export default Checkbox;
export { CheckboxWithLabel, Checkbox };

View File

@ -1,20 +1,20 @@
import React, { ReactNode } from "react";
import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("mx-auto h-full w-full px-8 ", className)}>{children}</div>;
}
function Article({ children }: { children: React.ReactNode }) {
return (
<Container>
<div className="grid w-full grid-cols-12">
<div className="col-span-12 xl:col-span-11 xl:col-start-2">{children}</div>
</div>
</Container>
);
}
export default Object.assign(Container, {
Article,
});
import React, { ReactNode } from "react";
import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("mx-auto h-full w-full px-8 ", className)}>{children}</div>;
}
function Article({ children }: { children: React.ReactNode }) {
return (
<Container>
<div className="grid w-full grid-cols-12">
<div className="col-span-12 xl:col-span-11 xl:col-start-2">{children}</div>
</div>
</Container>
);
}
export default Object.assign(Container, {
Article,
});

View File

@ -1,32 +1,32 @@
import Card from "@components/Card";
export type CustomTooltipProps = {
payload: { payload: { date: number; stat: number }; unit: string }[];
};
export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) {
const toolTipData = payload[0];
const { date, stat } = toolTipData.payload;
return (
<Card>
<div className="p-2 text-black dark:text-white">
<div className="font-semibold">
{new Date(date * 1000).toLocaleTimeString()}
</div>
<div className="space-y-1">
<div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" />
<span >
{stat} {toolTipData?.unit}
</span>
</div>
</div>
</div>
</Card>
);
}
return null;
}
import Card from "@components/Card";
export type CustomTooltipProps = {
payload: { payload: { date: number; stat: number }; unit: string }[];
};
export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) {
const toolTipData = payload[0];
const { date, stat } = toolTipData.payload;
return (
<Card>
<div className="p-2 text-black dark:text-white">
<div className="font-semibold">
{new Date(date * 1000).toLocaleTimeString()}
</div>
<div className="space-y-1">
<div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" />
<span >
{stat} {toolTipData?.unit}
</span>
</div>
</div>
</div>
</Card>
);
}
return null;
}

View File

@ -1,39 +1,39 @@
import { GridCard } from "@/components/Card";
import React from "react";
import { cx } from "../cva.config";
type Props = {
IconElm?: React.FC<any>;
headline: string;
description?: string | React.ReactNode;
BtnElm?: React.ReactNode;
className?: string;
};
export default function EmptyCard({
IconElm,
headline,
description,
BtnElm,
className,
}: Props) {
return (
<GridCard>
<div
className={cx(
"flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center",
className,
)}
>
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
</div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
{BtnElm}
</div>
</GridCard>
);
}
import { GridCard } from "@/components/Card";
import React from "react";
import { cx } from "../cva.config";
type Props = {
IconElm?: React.FC<any>;
headline: string;
description?: string | React.ReactNode;
BtnElm?: React.ReactNode;
className?: string;
};
export default function EmptyCard({
IconElm,
headline,
description,
BtnElm,
className,
}: Props) {
return (
<GridCard>
<div
className={cx(
"flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center",
className,
)}
>
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
</div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
{BtnElm}
</div>
</GridCard>
);
}

View File

@ -1,28 +1,28 @@
import React from "react";
import { cx } from "@/cva.config";
export default function ExtLink({
className,
href,
id,
target,
children,
}: {
className?: string;
href: string;
id?: string;
target?: string;
children: React.ReactNode;
}) {
return (
<a
className={cx(className)}
target={target ?? "_blank"}
id={id}
rel="noopener noreferrer"
href={href}
>
{children}
</a>
);
}
import React from "react";
import { cx } from "@/cva.config";
export default function ExtLink({
className,
href,
id,
target,
children,
}: {
className?: string;
href: string;
id?: string;
target?: string;
children: React.ReactNode;
}) {
return (
<a
className={cx(className)}
target={target ?? "_blank"}
id={id}
rel="noopener noreferrer"
href={href}
>
{children}
</a>
);
}

View File

@ -1,51 +1,51 @@
import React from "react";
import { cx } from "@/cva.config";
type Props = {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
export default function FieldLabel({
label,
id,
as = "label",
description,
disabled,
}: Props) {
if (as === "label") {
return (
<label
htmlFor={id}
className={cx(
"flex select-none flex-col text-left font-display text-[13px] font-semibold leading-snug text-black dark:text-white",
disabled && "opacity-50",
)}
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</label>
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</div>
);
} else {
return <></>;
}
}
import React from "react";
import { cx } from "@/cva.config";
type Props = {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
export default function FieldLabel({
label,
id,
as = "label",
description,
disabled,
}: Props) {
if (as === "label") {
return (
<label
htmlFor={id}
className={cx(
"flex select-none flex-col text-left font-display text-[13px] font-semibold leading-snug text-black dark:text-white",
disabled && "opacity-50",
)}
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</label>
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</div>
);
} else {
return <></>;
}
}

View File

@ -1,30 +1,30 @@
import React from "react";
import clsx from "clsx";
import { FetcherWithComponents, useNavigation } from "react-router-dom";
export default function Fieldset({
children,
fetcher,
className,
disabled,
}: {
children: React.ReactNode;
fetcher?: FetcherWithComponents<any>;
className?: string;
disabled?: boolean;
}) {
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<fieldset
className={clsx(className)}
disabled={
disabled ??
((loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
>
{children}
</fieldset>
);
}
import React from "react";
import clsx from "clsx";
import { FetcherWithComponents, useNavigation } from "react-router-dom";
export default function Fieldset({
children,
fetcher,
className,
disabled,
}: {
children: React.ReactNode;
fetcher?: FetcherWithComponents<any>;
className?: string;
disabled?: boolean;
}) {
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<fieldset
className={clsx(className)}
disabled={
disabled ??
((loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
>
{children}
</fieldset>
);
}

View File

@ -1,41 +1,41 @@
export default function GridBackground() {
return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
<svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg
x="50%"
y={-1}
className="overflow-visible fill-blue-100 dark:fill-blue-900/30"
>
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
</div>
);
}
export default function GridBackground() {
return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
<svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg
x="50%"
y={-1}
className="overflow-visible fill-blue-100 dark:fill-blue-900/30"
>
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
</div>
);
}

View File

@ -1,176 +1,176 @@
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
isLoggedIn: boolean;
primaryLinks?: { title: string; to: string }[];
userEmail?: string;
showConnectionStatus?: boolean;
picture?: string;
kvmName?: string;
}
export default function DashboardNavbar({
primaryLinks = [],
isLoggedIn,
showConnectionStatus,
userEmail,
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
<Container>
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
</div>
<div className="flex gap-x-2">
{primaryLinks.map(({ title, to }, i) => {
return (
<LinkButton
key={i + title}
theme="blank"
size="SM"
text={title}
to={to}
LeadingIcon={LuMonitorSmartphone}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
{showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex">
<div className="w-[159px]">
<PeerConnectionStatusCard
state={peerConnectionState}
title={kvmName}
/>
</div>
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
)}
{isLoggedIn ? (
<>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton as={Fragment}>
<Button
theme="blank"
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) => (
picture && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
)}
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div>
</div>
</Card>
</Menu.Items>
</Transition>
</Menu>
</>
) : null}
</div>
</div>
</div>
</Container>
</div>
);
}
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
isLoggedIn: boolean;
primaryLinks?: { title: string; to: string }[];
userEmail?: string;
showConnectionStatus?: boolean;
picture?: string;
kvmName?: string;
}
export default function DashboardNavbar({
primaryLinks = [],
isLoggedIn,
showConnectionStatus,
userEmail,
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
<Container>
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
</div>
<div className="flex gap-x-2">
{primaryLinks.map(({ title, to }, i) => {
return (
<LinkButton
key={i + title}
theme="blank"
size="SM"
text={title}
to={to}
LeadingIcon={LuMonitorSmartphone}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
{showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex">
<div className="w-[159px]">
<PeerConnectionStatusCard
state={peerConnectionState}
title={kvmName}
/>
</div>
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
)}
{isLoggedIn ? (
<>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton as={Fragment}>
<Button
theme="blank"
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) => (
picture && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
)}
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div>
</div>
</Card>
</Menu.Items>
</Transition>
</Menu>
</>
) : null}
</div>
</div>
</div>
</Container>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@ -1,142 +1,142 @@
import { cx } from "@/cva.config";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const isNumLockActive = useHidStore(state => state.isNumLockActive);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
</div>
) : null}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
</h2>
</div>
</div>
</div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare
</div>
)}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Caps Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isNumLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Num Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
</div>
</div>
</div>
);
}
import { cx } from "@/cva.config";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const isNumLockActive = useHidStore(state => state.isNumLockActive);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
</div>
) : null}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
</h2>
</div>
</div>
</div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare
</div>
)}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Caps Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isNumLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Num Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
</div>
</div>
</div>
);
}

View File

@ -1,99 +1,99 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
const sizes = {
XS: "h-[26px] px-3 text-xs",
SM: "h-[36px] px-3 text-[14px]",
MD: "h-[40px] px-4 text-sm",
LG: "h-[48px] py-4 px-5 text-base",
};
const inputVariants = cva({
variants: { size: sizes },
});
type InputFieldProps = {
size?: keyof typeof sizes;
TrailingElm?: React.ReactNode;
LeadingElm?: React.ReactNode;
error?: string | null;
} & Omit<JSX.IntrinsicElements["input"], "size">;
type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode;
description?: string | null;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
{ LeadingElm, TrailingElm, className, size = "MD", error, ...props },
ref,
) {
const sizeClasses = inputVariants({ size });
return (
<>
<Card
className={clsx(
// General styling
"relative flex w-full overflow-hidden",
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// Focus Within
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
// Disabled Within
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",
)}
>
{LeadingElm && (
<div className={clsx("pointer-events-none border-r border-r-slate-300 dark:border-r-slate-600")}>
{LeadingElm}
</div>
)}
<input
ref={ref}
className={clsx(
sizeClasses,
TrailingElm ? "pr-2" : "",
className,
"block flex-1 border-0 bg-transparent leading-none placeholder:text-sm placeholder:text-slate-300 dark:placeholder:text-slate-500 focus:ring-0 text-black dark:text-white",
)}
{...props}
/>
{TrailingElm && (
<div className="flex items-center pr-3 pointer-events-none">{TrailingElm}</div>
)}
</Card>
{error && <FieldError error={error} />}
</>
);
});
InputField.displayName = "InputField";
const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProps>(
function InputFieldWithLabel(
{ label, description, id, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<div className="w-full space-y-1">
{(label || description) && (
<FieldLabel label={label} id={id} description={description} />
)}
<InputField ref={ref as any} id={id} {...props} />
</div>
);
},
);
InputFieldWithLabel.displayName = "InputFieldWithLabel";
export default InputField;
export { InputFieldWithLabel };
export function FieldError({ error }: { error: string | React.ReactNode }) {
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
}
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
const sizes = {
XS: "h-[26px] px-3 text-xs",
SM: "h-[36px] px-3 text-[14px]",
MD: "h-[40px] px-4 text-sm",
LG: "h-[48px] py-4 px-5 text-base",
};
const inputVariants = cva({
variants: { size: sizes },
});
type InputFieldProps = {
size?: keyof typeof sizes;
TrailingElm?: React.ReactNode;
LeadingElm?: React.ReactNode;
error?: string | null;
} & Omit<JSX.IntrinsicElements["input"], "size">;
type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode;
description?: string | null;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
{ LeadingElm, TrailingElm, className, size = "MD", error, ...props },
ref,
) {
const sizeClasses = inputVariants({ size });
return (
<>
<Card
className={clsx(
// General styling
"relative flex w-full overflow-hidden",
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// Focus Within
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
// Disabled Within
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",
)}
>
{LeadingElm && (
<div className={clsx("pointer-events-none border-r border-r-slate-300 dark:border-r-slate-600")}>
{LeadingElm}
</div>
)}
<input
ref={ref}
className={clsx(
sizeClasses,
TrailingElm ? "pr-2" : "",
className,
"block flex-1 border-0 bg-transparent leading-none placeholder:text-sm placeholder:text-slate-300 dark:placeholder:text-slate-500 focus:ring-0 text-black dark:text-white",
)}
{...props}
/>
{TrailingElm && (
<div className="flex items-center pr-3 pointer-events-none">{TrailingElm}</div>
)}
</Card>
{error && <FieldError error={error} />}
</>
);
});
InputField.displayName = "InputField";
const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProps>(
function InputFieldWithLabel(
{ label, description, id, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<div className="w-full space-y-1">
{(label || description) && (
<FieldLabel label={label} id={id} description={description} />
)}
<InputField ref={ref as any} id={id} {...props} />
</div>
);
},
);
InputFieldWithLabel.displayName = "InputFieldWithLabel";
export default InputField;
export { InputFieldWithLabel };
export function FieldError({ error }: { error: string | React.ReactNode }) {
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
}

View File

@ -1,153 +1,153 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units
const units: Intl.RelativeTimeFormatUnit[] = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
// Grab the ideal cutoff unit
const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds));
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
// is one day in seconds, so we can divide our seconds by this to get the # of days
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
// Intl.RelativeTimeFormat do its magic
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
}
export default function KvmCard({
title,
id,
online,
lastSeen,
}: {
title: string;
id: string;
online: boolean;
lastSeen: Date | null;
}) {
return (
<Card>
<div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente">
<div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white">
{title}
</div>
{online ? (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
<div className="text-sm text-black dark:text-white">Online</div>
</div>
) : (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
<div className="text-sm text-black dark:text-white">
{lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</>
) : (
<>Never seen online</>
)}
</div>
</div>
)}
</div>
</div>
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" />
<div className="flex justify-between">
<div>
{online ? (
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
/>
) : (
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
textAlign="center"
/>
)}
</div>
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
as={Button}
theme="light"
TrailingIcon={LuEllipsisVertical}
size="MD"
></MenuButton>
</div>
<MenuItems
transition
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`}
>
Rename
</Link>
</div>
</div>
</div>
</MenuItem>
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`}
>
Deregister from cloud
</Link>
</div>
</div>
</div>
</MenuItem>
</div>
</Card>
</MenuItems>
</Menu>
</div>
</div>
</Card>
);
}
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units
const units: Intl.RelativeTimeFormatUnit[] = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
// Grab the ideal cutoff unit
const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds));
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
// is one day in seconds, so we can divide our seconds by this to get the # of days
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
// Intl.RelativeTimeFormat do its magic
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
}
export default function KvmCard({
title,
id,
online,
lastSeen,
}: {
title: string;
id: string;
online: boolean;
lastSeen: Date | null;
}) {
return (
<Card>
<div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente">
<div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white">
{title}
</div>
{online ? (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
<div className="text-sm text-black dark:text-white">Online</div>
</div>
) : (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
<div className="text-sm text-black dark:text-white">
{lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</>
) : (
<>Never seen online</>
)}
</div>
</div>
)}
</div>
</div>
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" />
<div className="flex justify-between">
<div>
{online ? (
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
/>
) : (
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
textAlign="center"
/>
)}
</div>
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
as={Button}
theme="light"
TrailingIcon={LuEllipsisVertical}
size="MD"
></MenuButton>
</div>
<MenuItems
transition
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`}
>
Rename
</Link>
</div>
</div>
</div>
</MenuItem>
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`}
>
Deregister from cloud
</Link>
</div>
</div>
</div>
</MenuItem>
</div>
</Card>
</MenuItems>
</Menu>
</div>
</div>
</Card>
);
}

View File

@ -1,30 +1,30 @@
import clsx from "clsx";
export default function LoadingSpinner({
className,
}: {
className: string | undefined;
}) {
return (
<svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
// className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
import clsx from "clsx";
export default function LoadingSpinner({
className,
}: {
className: string | undefined;
}) {
return (
<svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
// className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -1,356 +1,356 @@
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
setError("Please enter a password");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
setError("An error occurred while setting the password");
}
};
const handleUpdatePassword = async (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
return;
}
if (newPassword === "") {
setError("Please enter a new password");
return;
}
try {
const res = await api.PUT("/auth/password-local", {
oldPassword,
newPassword,
});
if (res.ok) {
setModalView("updateSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
}
} catch (error) {
setError("An error occurred while changing the password");
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
return;
}
try {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
}
} catch (error) {
setError("An error occurred while disabling the password");
}
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)}
/>
)}
</div>
</GridCard>
);
}
function CreatePasswordModal({
onSetPassword,
onCancel,
error,
}: {
onSetPassword: (password: string, confirmPassword: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
</div>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a strong password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Secure Device"
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function DeletePasswordModal({
onDeletePassword,
onCancel,
error,
}: {
onDeletePassword: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="danger"
text="Disable Protection"
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function UpdatePasswordModal({
onUpdatePassword,
onCancel,
error,
}: {
onUpdatePassword: (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => void;
onCancel: () => void;
error: string | null;
}) {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a new strong password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your new password"
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update Password"
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function SuccessModal({
headline,
description,
onClose,
}: {
headline: string;
description: string;
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
);
}
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
setError("Please enter a password");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
setError("An error occurred while setting the password");
}
};
const handleUpdatePassword = async (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
return;
}
if (newPassword === "") {
setError("Please enter a new password");
return;
}
try {
const res = await api.PUT("/auth/password-local", {
oldPassword,
newPassword,
});
if (res.ok) {
setModalView("updateSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
}
} catch (error) {
setError("An error occurred while changing the password");
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
return;
}
try {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
}
} catch (error) {
setError("An error occurred while disabling the password");
}
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)}
/>
)}
</div>
</GridCard>
);
}
function CreatePasswordModal({
onSetPassword,
onCancel,
error,
}: {
onSetPassword: (password: string, confirmPassword: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
</div>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a strong password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Secure Device"
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function DeletePasswordModal({
onDeletePassword,
onCancel,
error,
}: {
onDeletePassword: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="danger"
text="Disable Protection"
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function UpdatePasswordModal({
onUpdatePassword,
onCancel,
error,
}: {
onUpdatePassword: (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => void;
onCancel: () => void;
error: string | null;
}) {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a new strong password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your new password"
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update Password"
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function SuccessModal({
headline,
description,
onClose,
}: {
headline: string;
description: string;
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
);
}

View File

@ -1,45 +1,45 @@
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
children,
className,
open,
onClose,
}: {
children: React.ReactNode;
className?: string;
open: boolean;
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
className,
)}
>
<div className="inline-block w-full text-left pointer-events-auto">
<div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
}
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
children,
className,
open,
onClose,
}: {
children: React.ReactNode;
className?: string;
open: boolean;
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
className,
)}
>
<div className="inline-block w-full text-left pointer-events-auto">
<div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,18 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {
return (
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Not found"
description="The page you were looking for does not exist."
/>
</div>
</div>
</div>
);
}
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {
return (
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Not found"
description="The page you were looking for does not exist."
/>
</div>
</div>
</div>
);
}

View File

@ -1,52 +1,52 @@
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Another Active Session Detected
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Only one active session is supported at a time. Would you like to take over
this session?
</p>
<div className="flex items-center justify-start space-x-4">
<Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div>
</div>
</div>
</div>
</GridCard>
);
}
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Another Active Session Detected
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Only one active session is supported at a time. Would you like to take over
this session?
</p>
<div className="flex items-center justify-start space-x-4">
<Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div>
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -1,66 +1,66 @@
import StatusCard from "@components/StatusCards";
const PeerConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Connection error",
closing: "Closing",
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
};
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = {
[key in PeerConnections]: {
statusIndicatorClassName: string;
};
};
export default function PeerConnectionStatusCard({
state,
title,
}: {
state?: PeerConnections;
title?: string;
}) {
if (!state) return null;
const StatusCardProps: StatusProps = {
connected: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connecting: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
disconnected: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
error: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closing: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
failed: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
["new"]: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
};
const props = StatusCardProps[state];
if (!props) return;
return (
<StatusCard
title={title || "JetKVM Device"}
status={PeerConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>
);
}
import StatusCard from "@components/StatusCards";
const PeerConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Connection error",
closing: "Closing",
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
};
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = {
[key in PeerConnections]: {
statusIndicatorClassName: string;
};
};
export default function PeerConnectionStatusCard({
state,
title,
}: {
state?: PeerConnections;
title?: string;
}) {
if (!state) return null;
const StatusCardProps: StatusProps = {
connected: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connecting: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
disconnected: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
error: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closing: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
failed: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
["new"]: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
};
const props = StatusCardProps[state];
if (!props) return;
return (
<StatusCard
title={title || "JetKVM Device"}
status={PeerConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>
);
}

View File

@ -1,16 +1,16 @@
import { ReactNode } from "react";
export function SectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div>
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
);
}
import { ReactNode } from "react";
export function SectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div>
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
);
}

View File

@ -1,103 +1,103 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"
> & {
defaultSelection?: string;
className?: string;
options: {
label: string;
value: string;
disabled?: boolean;
}[];
size?: keyof typeof sizes;
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
const selectMenuVariants = cva({
variants: { size: sizes },
});
export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuProps>(
function SelectMenuBasic(
{
fullWidth,
options,
className,
direction = "vertical",
label,
size = "MD",
name,
disabled,
value,
id,
onChange,
},
ref,
) {
const classes = selectMenuVariants({ size });
return (
<div
className={clsx(
direction === "vertical" ? "space-y-1" : "flex items-center gap-x-2",
fullWidth ? "w-full" : "w-auto",
className,
"text-sm",
)}
>
{label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<select
ref={ref}
name={name}
disabled={disabled}
className={clsx(
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}
onChange={onChange}
>
{options.map(x => (
<option key={x.value} value={x.value} disabled={x.disabled}>
{x.label}
</option>
))}
</select>
</Card>
</div>
);
},
);
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"
> & {
defaultSelection?: string;
className?: string;
options: {
label: string;
value: string;
disabled?: boolean;
}[];
size?: keyof typeof sizes;
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
const selectMenuVariants = cva({
variants: { size: sizes },
});
export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuProps>(
function SelectMenuBasic(
{
fullWidth,
options,
className,
direction = "vertical",
label,
size = "MD",
name,
disabled,
value,
id,
onChange,
},
ref,
) {
const classes = selectMenuVariants({ size });
return (
<div
className={clsx(
direction === "vertical" ? "space-y-1" : "flex items-center gap-x-2",
fullWidth ? "w-full" : "w-auto",
className,
"text-sm",
)}
>
{label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<select
ref={ref}
name={name}
disabled={disabled}
className={clsx(
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}
onChange={onChange}
>
{options.map(x => (
<option key={x.value} value={x.value} disabled={x.disabled}>
{x.label}
</option>
))}
</select>
</Card>
</div>
);
},
);

View File

@ -1,52 +1,52 @@
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
export default function SidebarHeader({
title,
setSidebarView,
}: {
title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void;
}) {
return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}>
<h2 className="text-sm text-black truncate dark:text-white">{title}</h2>
</div>
<Button
size="XS"
theme="blank"
text="Hide"
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 2H4C2.89543 2 2 2.89543 2 4V18C2 19.1046 2.89543 20 4 20H18C19.1046 20 20 19.1046 20 18V4C20 2.89543 19.1046 2 18 2ZM4 0C1.79086 0 0 1.79086 0 4V18C0 20.2091 1.79086 22 4 22H18C20.2091 22 22 20.2091 22 18V4C22 1.79086 20.2091 0 18 0H4Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 21L6 1L8 1L8 21H6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.7803 7.21983C15.9208 7.36045 15.9997 7.55108 15.9997 7.74983C15.9997 7.94858 15.9208 8.1392 15.7803 8.27983L13.0603 10.9998L15.7803 13.7198C15.854 13.7885 15.9131 13.8713 15.9541 13.9633C15.9951 14.0553 16.0171 14.1546 16.0189 14.2553C16.0207 14.356 16.0022 14.456 15.9644 14.5494C15.9267 14.6428 15.8706 14.7276 15.7994 14.7989C15.7281 14.8701 15.6433 14.9262 15.5499 14.9639C15.4565 15.0017 15.3565 15.0202 15.2558 15.0184C15.1551 15.0166 15.0558 14.9946 14.9638 14.9536C14.8718 14.9126 14.789 14.8535 14.7203 14.7798L11.4703 11.5298C11.3299 11.3892 11.251 11.1986 11.251 10.9998C11.251 10.8011 11.3299 10.6105 11.4703 10.4698L14.7203 7.21983C14.8609 7.07938 15.0516 7.00049 15.2503 7.00049C15.4491 7.00049 15.6397 7.07938 15.7803 7.21983Z"
fill="currentColor"
/>
</svg>
)}
onClick={() => setSidebarView(null)}
/>
</div>
);
}
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
export default function SidebarHeader({
title,
setSidebarView,
}: {
title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void;
}) {
return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}>
<h2 className="text-sm text-black truncate dark:text-white">{title}</h2>
</div>
<Button
size="XS"
theme="blank"
text="Hide"
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 2H4C2.89543 2 2 2.89543 2 4V18C2 19.1046 2.89543 20 4 20H18C19.1046 20 20 19.1046 20 18V4C20 2.89543 19.1046 2 18 2ZM4 0C1.79086 0 0 1.79086 0 4V18C0 20.2091 1.79086 22 4 22H18C20.2091 22 22 20.2091 22 18V4C22 1.79086 20.2091 0 18 0H4Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 21L6 1L8 1L8 21H6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.7803 7.21983C15.9208 7.36045 15.9997 7.55108 15.9997 7.74983C15.9997 7.94858 15.9208 8.1392 15.7803 8.27983L13.0603 10.9998L15.7803 13.7198C15.854 13.7885 15.9131 13.8713 15.9541 13.9633C15.9951 14.0553 16.0171 14.1546 16.0189 14.2553C16.0207 14.356 16.0022 14.456 15.9644 14.5494C15.9267 14.6428 15.8706 14.7276 15.7994 14.7989C15.7281 14.8701 15.6433 14.9262 15.5499 14.9639C15.4565 15.0017 15.3565 15.0202 15.2558 15.0184C15.1551 15.0166 15.0558 14.9946 14.9638 14.9536C14.8718 14.9126 14.789 14.8535 14.7203 14.7798L11.4703 11.5298C11.3299 11.3892 11.251 11.1986 11.251 10.9998C11.251 10.8011 11.3299 10.6105 11.4703 10.4698L14.7203 7.21983C14.8609 7.07938 15.0516 7.00049 15.2503 7.00049C15.4491 7.00049 15.6397 7.07938 15.7803 7.21983Z"
fill="currentColor"
/>
</svg>
)}
onClick={() => setSidebarView(null)}
/>
</div>
);
}

View File

@ -1,25 +1,25 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode };
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return (
<div>
<Container>
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
<div className="flex items-center justify-between">
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
</Link>
<div>{actionElement}</div>
</div>
</div>
</Container>
</div>
);
}
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode };
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return (
<div>
<Container>
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
<div className="flex items-center justify-between">
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
</Link>
<div>{actionElement}</div>
</div>
</div>
</Container>
</div>
);
}

View File

@ -1,99 +1,99 @@
import {
Brush,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({
data,
domain,
unit,
referenceValue,
}: {
data: { date: number; stat: number | null | undefined }[];
domain?: [string | number, string | number];
unit?: string;
referenceValue?: number;
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 24, right: 8, left: 0, bottom: 0 }}>
<Brush startIndex={data.length - 60 * 2} height={1} className="hidden" />
<CartesianGrid
strokeDasharray={0}
vertical={false}
strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)"
/>
{referenceValue && (
<ReferenceLine
y={referenceValue}
strokeDasharray="3 3"
strokeWidth={1}
stroke="rgba(30, 41, 59, 0.3)"
/>
)}
<XAxis
dataKey="date"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickFormatter={date => {
return new Date(date * 1000).toLocaleString("en-US", {
hourCycle: "h23",
hour: "numeric",
minute: "2-digit",
});
}}
ticks={data
.filter(d => {
return d.date % 60 === 0;
})
.map(x => x.date)}
/>
<YAxis
dataKey="stat"
axisLine={false}
orientation="right"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
padding={{ top: 0, bottom: 0 }}
tickLine={false}
unit={unit}
domain={domain || ["auto", "auto"]}
/>
<Tooltip
cursor={false}
content={({ payload }) => {
return <CustomTooltip payload={payload as CustomTooltipProps["payload"]} />;
}}
/>
<Line
type="monotone"
isAnimationActive={false}
dataKey="stat"
stroke="rgb(29 78 216)"
strokeLinecap="round"
strokeWidth={2}
unit={unit}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
import {
Brush,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({
data,
domain,
unit,
referenceValue,
}: {
data: { date: number; stat: number | null | undefined }[];
domain?: [string | number, string | number];
unit?: string;
referenceValue?: number;
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 24, right: 8, left: 0, bottom: 0 }}>
<Brush startIndex={data.length - 60 * 2} height={1} className="hidden" />
<CartesianGrid
strokeDasharray={0}
vertical={false}
strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)"
/>
{referenceValue && (
<ReferenceLine
y={referenceValue}
strokeDasharray="3 3"
strokeWidth={1}
stroke="rgba(30, 41, 59, 0.3)"
/>
)}
<XAxis
dataKey="date"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickFormatter={date => {
return new Date(date * 1000).toLocaleString("en-US", {
hourCycle: "h23",
hour: "numeric",
minute: "2-digit",
});
}}
ticks={data
.filter(d => {
return d.date % 60 === 0;
})
.map(x => x.date)}
/>
<YAxis
dataKey="stat"
axisLine={false}
orientation="right"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
padding={{ top: 0, bottom: 0 }}
tickLine={false}
unit={unit}
domain={domain || ["auto", "auto"]}
/>
<Tooltip
cursor={false}
content={({ payload }) => {
return <CustomTooltip payload={payload as CustomTooltipProps["payload"]} />;
}}
/>
<Line
type="monotone"
isAnimationActive={false}
dataKey="stat"
stroke="rgb(29 78 216)"
strokeLinecap="round"
strokeWidth={2}
unit={unit}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@ -1,45 +1,45 @@
import React from "react";
import { cx } from "@/cva.config";
interface Props {
title: string;
status: string;
icon?: React.FC<{ className: string | undefined }>;
iconClassName?: string;
statusIndicatorClassName?: string;
}
export default function StatusCard({
title,
status,
icon: Icon,
iconClassName,
statusIndicatorClassName,
}: Props) {
return (
<div className="flex items-center gap-x-3 rounded-md border bg-white dark:border-slate-600 dark:bg-slate-800 dark:text-white border-slate-800/20 px-2 py-1.5">
{Icon ? (
<span>
<Icon className={cx(iconClassName, "shrink-0")} />
</span>
) : null}
<div className="space-y-1">
<div className="text-xs font-semibold leading-none transition text-ellipsis">
{title}
</div>
<div className="text-xs leading-none">
<div className="flex items-center gap-x-1">
<div
className={cx(
"h-2 w-2 rounded-full border transition",
statusIndicatorClassName,
)}
/>
<span className={cx("transition")}>{status}</span>
</div>
</div>
</div>
</div>
);
}
import React from "react";
import { cx } from "@/cva.config";
interface Props {
title: string;
status: string;
icon?: React.FC<{ className: string | undefined }>;
iconClassName?: string;
statusIndicatorClassName?: string;
}
export default function StatusCard({
title,
status,
icon: Icon,
iconClassName,
statusIndicatorClassName,
}: Props) {
return (
<div className="flex items-center gap-x-3 rounded-md border bg-white dark:border-slate-600 dark:bg-slate-800 dark:text-white border-slate-800/20 px-2 py-1.5">
{Icon ? (
<span>
<Icon className={cx(iconClassName, "shrink-0")} />
</span>
) : null}
<div className="space-y-1">
<div className="text-xs font-semibold leading-none transition text-ellipsis">
{title}
</div>
<div className="text-xs leading-none">
<div className="flex items-center gap-x-1">
<div
className={cx(
"h-2 w-2 rounded-full border transition",
statusIndicatorClassName,
)}
/>
<span className={cx("transition")}>{status}</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,73 +1,73 @@
import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
type Props = {
nSteps: number;
currStepIdx: number;
size?: keyof typeof sizes;
};
const sizes = {
SM: "text-xs leading-[12px]",
MD: "text-sm leading-[14px]",
};
const variants = cva({
variants: {
size: sizes,
},
});
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
const textStyle = variants({ size });
return (
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
{[...Array(nSteps).keys()].map(i => {
if (i < currStepIdx) {
return (
<div
className={cx(
"flex items-center justify-center rounded-full border border-blue-800 bg-blue-700 text-slate-600 dark:border-blue-300",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
<CheckIcon className="h-3.5 w-3.5 text-white" />
</div>
);
}
if (i === currStepIdx) {
return (
<div
className={cx(
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300",
textStyle,
)}
key={`${i}-${currStepIdx}`}
>
Step {i + 1}
</div>
);
}
if (i > currStepIdx) {
return (
<Card
className={cx(
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
{i + 1}
</Card>
);
}
return null;
})}
</Card>
);
}
import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
type Props = {
nSteps: number;
currStepIdx: number;
size?: keyof typeof sizes;
};
const sizes = {
SM: "text-xs leading-[12px]",
MD: "text-sm leading-[14px]",
};
const variants = cva({
variants: {
size: sizes,
},
});
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
const textStyle = variants({ size });
return (
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
{[...Array(nSteps).keys()].map(i => {
if (i < currStepIdx) {
return (
<div
className={cx(
"flex items-center justify-center rounded-full border border-blue-800 bg-blue-700 text-slate-600 dark:border-blue-300",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
<CheckIcon className="h-3.5 w-3.5 text-white" />
</div>
);
}
if (i === currStepIdx) {
return (
<div
className={cx(
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300",
textStyle,
)}
key={`${i}-${currStepIdx}`}
>
Step {i + 1}
</div>
);
}
if (i > currStepIdx) {
return (
<Card
className={cx(
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
{i + 1}
</Card>
);
}
return null;
})}
</Card>
);
}

View File

@ -1,51 +1,51 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
</div>
</div>
</div>
</Transition>
</div>
);
}
export default TerminalWrapper;
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
</div>
</div>
</div>
</Transition>
</div>
);
}
export default TerminalWrapper;

View File

@ -1,56 +1,56 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import { cx } from "@/cva.config";
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {
error?: string | null;
};
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(props, ref) {
return (
<Card
className={cx(
"relative w-full",
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
)}
>
<textarea
ref={ref}
{...props}
id="asd"
className={clsx(
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
props.className,
)}
/>
</Card>
);
},
);
type TextAreaWithLabelProps = {
label: string | React.ReactNode;
id?: string;
description?: string;
error?: string | null;
} & React.ComponentProps<typeof TextArea>;
export const TextAreaWithLabel = React.forwardRef<
HTMLTextAreaElement,
TextAreaWithLabelProps
>(function TextAreaWithLabel({ label, error, id, description, ...props }, ref) {
return (
<div className="space-y-1">
<FieldLabel label={label} id={id} description={description} />
<TextArea ref={ref} {...props} />
{error && <FieldError error={error} />}
</div>
);
});
export default TextArea;
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import { cx } from "@/cva.config";
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {
error?: string | null;
};
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(props, ref) {
return (
<Card
className={cx(
"relative w-full",
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
)}
>
<textarea
ref={ref}
{...props}
id="asd"
className={clsx(
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
props.className,
)}
/>
</Card>
);
},
);
type TextAreaWithLabelProps = {
label: string | React.ReactNode;
id?: string;
description?: string;
error?: string | null;
} & React.ComponentProps<typeof TextArea>;
export const TextAreaWithLabel = React.forwardRef<
HTMLTextAreaElement,
TextAreaWithLabelProps
>(function TextAreaWithLabel({ label, error, id, description, ...props }, ref) {
return (
<div className="space-y-1">
<FieldLabel label={label} id={id} description={description} />
<TextArea ref={ref} {...props} />
{error && <FieldError error={error} />}
</div>
);
});
export default TextArea;

View File

@ -1,97 +1,97 @@
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = {
[key in USBStates]: {
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
};
};
const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
};
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupport USB state: ", state);
return;
}
// If the peer connection is not connected, show the USB cable as disconnected
if (peerConnectionState !== "connected") {
const {
icon: Icon,
iconClassName,
statusIndicatorClassName,
} = StatusCardProps["not attached"];
return (
<StatusCard
title="USB"
status="Disconnected"
icon={Icon}
iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName}
/>
);
}
return (
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
);
}
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = {
[key in USBStates]: {
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
};
};
const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
};
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupport USB state: ", state);
return;
}
// If the peer connection is not connected, show the USB cable as disconnected
if (peerConnectionState !== "connected") {
const {
icon: Icon,
iconClassName,
statusIndicatorClassName,
} = StatusCardProps["not attached"];
return (
<StatusCard
title="USB"
status="Disconnected"
icon={Icon}
iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName}
/>
);
}
return (
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,49 @@
import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis">
Update in Progress
</div>
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
</span>
</div>
</div>
</div>
</div>
<Button
size="SM"
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/>
</div>
</GridCard>
</div>
);
}
import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis">
Update in Progress
</div>
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
</span>
</div>
</div>
</div>
</div>
<Button
size="SM"
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/>
</div>
</GridCard>
</div>
);
}

View File

@ -1,193 +1,193 @@
import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
interface OverlayContentProps {
children: React.ReactNode;
}
function OverlayContent({ children }: OverlayContentProps) {
return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
{children}
</div>
</GridCard>
);
}
interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
return (
<Transition
show={show}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
}
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return (
<Transition
show={show}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface HDMIErrorOverlayProps {
show: boolean;
hdmiState: string;
}
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
return (
<>
<Transition
show={show && isNoSignal}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-all duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
<Transition
show={show && isOtherError}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300 "
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</>
);
}
import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
interface OverlayContentProps {
children: React.ReactNode;
}
function OverlayContent({ children }: OverlayContentProps) {
return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
{children}
</div>
</GridCard>
);
}
interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
return (
<Transition
show={show}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
}
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return (
<Transition
show={show}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface HDMIErrorOverlayProps {
show: boolean;
hdmiState: string;
}
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
return (
<>
<Transition
show={show && isNoSignal}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-all duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
<Transition
show={show && isOtherError}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300 "
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</>
);
}

View File

@ -1,459 +1,459 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
setIsDragging(true);
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const rect = keyboardRef.current.getBoundingClientRect();
setPosition({
x: clientX - rect.left,
y: clientY - rect.top,
});
}, []);
const onDrag = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (isDragging) {
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const newX = clientX - position.x;
const newY = clientY - position.y;
const rect = keyboardRef.current.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setNewPosition({
x: Math.min(maxX, Math.max(0, newX)),
y: Math.min(maxY, Math.max(0, newY)),
});
}
},
[isDragging, position.x, position.y],
);
const endDrag = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
handle.addEventListener("mousedown", startDrag);
}
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", onDrag);
document.addEventListener("touchmove", onDrag);
return () => {
if (handle) {
handle.removeEventListener("touchstart", startDrag);
handle.removeEventListener("mousedown", startDrag);
}
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
const onKeyDown = useCallback(
(key: string) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
// Handle caps lock state change
if (isKeyCaps) {
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
</div>
);
}
export default KeyboardWrapper;
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
setIsDragging(true);
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const rect = keyboardRef.current.getBoundingClientRect();
setPosition({
x: clientX - rect.left,
y: clientY - rect.top,
});
}, []);
const onDrag = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (isDragging) {
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const newX = clientX - position.x;
const newY = clientY - position.y;
const rect = keyboardRef.current.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setNewPosition({
x: Math.min(maxX, Math.max(0, newX)),
y: Math.min(maxY, Math.max(0, newY)),
});
}
},
[isDragging, position.x, position.y],
);
const endDrag = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
handle.addEventListener("mousedown", startDrag);
}
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", onDrag);
document.addEventListener("touchmove", onDrag);
return () => {
if (handle) {
handle.removeEventListener("touchstart", startDrag);
handle.removeEventListener("mousedown", startDrag);
}
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
const onKeyDown = useCallback(
(key: string) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
// Handle caps lock state change
if (isKeyCaps) {
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
</div>
);
}
export default KeyboardWrapper;

View File

@ -1,461 +1,461 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const [send] = useJsonRpc();
// Video-related
useResizeObserver({
ref: videoElm,
onResize: ({ width, height }) => {
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
});
const updateVideoSizeStore = useCallback(
(videoElm: HTMLVideoElement) => {
setVideoClientSize(videoElm.clientWidth, videoElm.clientHeight);
setVideoSize(videoElm.videoWidth, videoElm.videoHeight);
},
[setVideoClientSize, setVideoSize],
);
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// Mouse-related
const sendMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition],
);
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
// Clamp mouse position within the video boundaries
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
// Send mouse movement
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Round to the nearest integer
const roundedScroll = Math.round(clampedScroll);
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50);
},
[blockWheelEvent, send],
);
const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0);
}, [sendMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
.filter(
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
);
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10);
}
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
// Effect hooks
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
);
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
</fieldset>
</div>
<div className="h-full overflow-hidden">
<div className="relative h-full">
<div
className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]",
"[background-size:20px_20px]",
)}
/>
<div className="flex flex-col h-full">
<div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full">
<video
ref={videoElm}
autoPlay={true}
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={true}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
</div>
</div>
</div>
</div>
<VirtualKeyboard />
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<InfoBar />
</div>
</div>
);
}
import { useCallback, useEffect, useRef, useState } from "react";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const [send] = useJsonRpc();
// Video-related
useResizeObserver({
ref: videoElm,
onResize: ({ width, height }) => {
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
});
const updateVideoSizeStore = useCallback(
(videoElm: HTMLVideoElement) => {
setVideoClientSize(videoElm.clientWidth, videoElm.clientHeight);
setVideoSize(videoElm.videoWidth, videoElm.videoHeight);
},
[setVideoClientSize, setVideoSize],
);
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// Mouse-related
const sendMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition],
);
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
// Clamp mouse position within the video boundaries
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
// Send mouse movement
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Round to the nearest integer
const roundedScroll = Math.round(clampedScroll);
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50);
},
[blockWheelEvent, send],
);
const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0);
}, [sendMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
.filter(
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
);
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10);
}
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
// Effect hooks
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
);
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
</fieldset>
</div>
<div className="h-full overflow-hidden">
<div className="relative h-full">
<div
className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]",
"[background-size:20px_20px]",
)}
/>
<div className="flex flex-col h-full">
<div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full">
<video
ref={videoElm}
autoPlay={true}
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={true}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
</div>
</div>
</div>
</div>
<VirtualKeyboard />
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<InfoBar />
</div>
</div>
);
}

View File

@ -1,201 +1,201 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}

View File

@ -1,306 +1,306 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
});
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {
syncRemoteVirtualMediaState();
}
});
};
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
</p>
</div>
</div>
);
}
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
default:
return null;
}
};
const close = useClose();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
}}
LeadingIcon={LuPlus}
/>
</div>
)}
</div>
</GridCard>
);
});
MountPopopover.displayName = "MountSidebarRoute";
export default MountPopopover;
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
});
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {
syncRemoteVirtualMediaState();
}
});
};
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
</p>
</div>
</div>
);
}
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
default:
return null;
}
};
const close = useClose();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
}}
LeadingIcon={LuPlus}
/>
</div>
)}
</div>
</GridCard>
);
});
MountPopopover.displayName = "MountSidebarRoute";
export default MountPopopover;

View File

@ -1,164 +1,164 @@
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
};
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const { key, shift } = chars[char] ?? {};
if (!key) continue;
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => {
if (TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, []);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirmPaste();
} else if (e.key === "Escape") {
e.preventDefault();
onCancelPasteMode();
}
}}
onChange={e => {
const value = e.target.value;
const invalidChars = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !chars[char]),
),
];
setInvalidChars(invalidChars);
}}
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Cancel"
onClick={() => {
onCancelPasteMode();
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Confirm Paste"
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
</div>
</div>
</GridCard>
);
}
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
};
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const { key, shift } = chars[char] ?? {};
if (!key) continue;
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => {
if (TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, []);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirmPaste();
} else if (e.key === "Escape") {
e.preventDefault();
onCancelPasteMode();
}
}}
onChange={e => {
const value = e.target.value;
const invalidChars = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !chars[char]),
),
];
setInvalidChars(invalidChars);
}}
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Cancel"
onClick={() => {
onCancelPasteMode();
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Confirm Paste"
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
</div>
</div>
</GridCard>
);
}

View File

@ -1,104 +1,104 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
setShowAddForm: (show: boolean) => void;
errorMessage: string | null;
setErrorMessage: (errorMessage: string | null) => void;
}
export default function AddDeviceForm({
setShowAddForm,
onAddDevice,
errorMessage,
setErrorMessage,
}: AddDeviceFormProps) {
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
}}
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
setErrorMessage(null);
}}
maxLength={30}
/>
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
error={errorMessage}
onChange={e => {
setIsMacAddressValid(e.target.validity.valid);
setErrorMessage(null);
}}
minLength={17}
maxLength={17}
onKeyDown={e => {
if (isMacAddressValid || isDeviceNameValid) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
} else if (e.key === "Escape") {
e.preventDefault();
setShowAddForm(false);
}
}
}}
/>
</div>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Back"
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
}}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
setShowAddForm: (show: boolean) => void;
errorMessage: string | null;
setErrorMessage: (errorMessage: string | null) => void;
}
export default function AddDeviceForm({
setShowAddForm,
onAddDevice,
errorMessage,
setErrorMessage,
}: AddDeviceFormProps) {
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
}}
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
setErrorMessage(null);
}}
maxLength={30}
/>
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
error={errorMessage}
onChange={e => {
setIsMacAddressValid(e.target.validity.valid);
setErrorMessage(null);
}}
minLength={17}
maxLength={17}
onKeyDown={e => {
if (isMacAddressValid || isDeviceNameValid) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
} else if (e.key === "Escape") {
e.preventDefault();
setShowAddForm(false);
}
}
}}
/>
</div>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Back"
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
}}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@ -1,85 +1,85 @@
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
export interface StoredDevice {
name: string;
macAddress: string;
}
interface DeviceListProps {
storedDevices: StoredDevice[];
errorMessage: string | null;
onSendMagicPacket: (macAddress: string) => void;
onDeleteDevice: (index: number) => void;
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}
export default function DeviceList({
storedDevices,
errorMessage,
onSendMagicPacket,
onDeleteDevice,
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()}
</p>
</div>
{errorMessage && <FieldError error={errorMessage} />}
<div className="flex items-center space-x-2">
<Button
size="XS"
theme="light"
text="Wake"
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
<Button
size="XS"
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
/>
</div>
</div>
))}
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
export interface StoredDevice {
name: string;
macAddress: string;
}
interface DeviceListProps {
storedDevices: StoredDevice[];
errorMessage: string | null;
onSendMagicPacket: (macAddress: string) => void;
onDeleteDevice: (index: number) => void;
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}
export default function DeviceList({
storedDevices,
errorMessage,
onSendMagicPacket,
onDeleteDevice,
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()}
</p>
</div>
{errorMessage && <FieldError error={errorMessage} />}
<div className="flex items-center space-x-2">
<Button
size="XS"
theme="light"
text="Wake"
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
<Button
size="XS"
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
/>
</div>
</div>
))}
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@ -1,54 +1,54 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
setShowAddForm,
}: {
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
</p>
</div>
</div>
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
setShowAddForm,
}: {
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
</p>
</div>
</div>
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@ -1,137 +1,137 @@
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => {
close();
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
} else {
setErrorMessage("Failed to send Magic Packet");
}
} else {
notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false);
close();
}
});
},
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
console.error("Failed to load Wake-on-LAN devices:", resp.error);
}
});
}, [send, setStoredDevices]);
// Load stored devices from the backend
useEffect(() => {
syncStoredDevices();
}, [syncStoredDevices]);
const onDeleteDevice = useCallback(
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
syncStoredDevices();
}
});
},
[storedDevices, send, syncStoredDevices],
);
const onAddDevice = useCallback(
(name: string, macAddress: string) => {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
} else {
setShowAddForm(false);
syncStoredDevices();
}
});
},
[send, storedDevices, syncStoredDevices],
);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>
{showAddForm ? (
<AddDeviceForm
setShowAddForm={setShowAddForm}
errorMessage={addDeviceErrorMessage}
setErrorMessage={setAddDeviceErrorMessage}
onAddDevice={onAddDevice}
/>
) : storedDevices.length === 0 ? (
<EmptyStateCard
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
) : (
<DeviceList
storedDevices={storedDevices}
errorMessage={errorMessage}
onSendMagicPacket={onSendMagicPacket}
onDeleteDevice={onDeleteDevice}
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
)}
</div>
</div>
</div>
</GridCard>
);
}
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => {
close();
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
} else {
setErrorMessage("Failed to send Magic Packet");
}
} else {
notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false);
close();
}
});
},
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
console.error("Failed to load Wake-on-LAN devices:", resp.error);
}
});
}, [send, setStoredDevices]);
// Load stored devices from the backend
useEffect(() => {
syncStoredDevices();
}, [syncStoredDevices]);
const onDeleteDevice = useCallback(
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
syncStoredDevices();
}
});
},
[storedDevices, send, syncStoredDevices],
);
const onAddDevice = useCallback(
(name: string, macAddress: string) => {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
} else {
setShowAddForm(false);
syncStoredDevices();
}
});
},
[send, storedDevices, syncStoredDevices],
);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>
{showAddForm ? (
<AddDeviceForm
setShowAddForm={setShowAddForm}
errorMessage={addDeviceErrorMessage}
setErrorMessage={setAddDeviceErrorMessage}
onAddDevice={onAddDevice}
/>
) : storedDevices.length === 0 ? (
<EmptyStateCard
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
) : (
<DeviceList
storedDevices={storedDevices}
errorMessage={errorMessage}
onSendMagicPacket={onSendMagicPacket}
onDeleteDevice={onDeleteDevice}
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -1,258 +1,258 @@
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts";
function createChartArray<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): { date: number; stat: T[K] | null }[] {
const stat = Array.from(stream).map(([key, stats]) => {
return { date: key, stat: stats[metric] };
});
// Sort the dates to ensure they are in chronological order
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
// Determine the earliest statistic date
const earliestStat = sortedStat[0];
// Current time in seconds since the Unix epoch
const now = Math.floor(Date.now() / 1000);
// Determine the starting point for the chart data
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
// Generate the chart array for the range between 'firstChartDate' and 'now'
return Array.from({ length: now - firstChartDate }, (_, i) => {
const currentDate = firstChartDate + i;
return {
date: currentDate,
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
};
});
}
export default function ConnectionStatsSidebar () {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): boolean {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
const appendDiskDataChannelStats = useRTCStore(
state => state.appendDiskDataChannelStats,
);
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
const appendRemoteCandidateStats = useRTCStore(
state => state.appendRemoteCandidateStats,
);
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
useInterval(function collectWebRTCStats() {
(async () => {
if (!mediaStream) return;
const videoTrack = mediaStream.getVideoTracks()[0];
if (!videoTrack) return;
const stats = await peerConnection?.getStats();
let successfulLocalCandidateId: string | null = null;
let successfulRemoteCandidateId: string | null = null;
stats?.forEach(report => {
if (report.type === "inbound-rtp") {
appendInboundRtpStats(report);
} else if (report.type === "candidate-pair" && report.nominated) {
if (report.state === "succeeded") {
successfulLocalCandidateId = report.localCandidateId;
successfulRemoteCandidateId = report.remoteCandidateId;
}
appendIceCandidatePair(report);
} else if (report.type === "local-candidate") {
// We only want to append the local candidate stats that were used in nominated candidate pair
if (successfulLocalCandidateId === report.id) {
appendLocalCandidateStats(report);
}
} else if (report.type === "remote-candidate") {
if (successfulRemoteCandidateId === report.id) {
appendRemoteCandidateStats(report);
}
} else if (report.type === "data-channel" && report.label === "disk") {
appendDiskDataChannelStats(report);
}
});
})();
}, 500);
return (
<div className="grid h-full shadow-sm grid-rows-headerBody">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
<div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
*/}
{sidebarView === "connection-stats" && (
<div className="space-y-4">
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Packets Lost
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of data packets lost during transmission.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
<StatChart
data={createChartArray(inboundRtpStats, "packetsLost")}
domain={[0, 100]}
unit=" packets"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Round-Trip Time
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Time taken for data to travel from source to destination and back
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
<StatChart
data={createChartArray(
candidatePairStats,
"currentRoundTripTime",
).map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 600]}
unit=" ms"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Jitter
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Variation in packet delay, affecting video smoothness.{" "}
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "jitter").map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 300]}
unit=" ms"
/>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Frames per second
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of video frames displayed per second.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
x => {
return {
date: x.date,
stat: x.stat ? x.stat : null,
};
},
)}
domain={[0, 80]}
unit=" fps"
/>
)}
</div>
</GridCard>
</div>
</div>
)}
</div>
</div>
</div>
);
}
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts";
function createChartArray<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): { date: number; stat: T[K] | null }[] {
const stat = Array.from(stream).map(([key, stats]) => {
return { date: key, stat: stats[metric] };
});
// Sort the dates to ensure they are in chronological order
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
// Determine the earliest statistic date
const earliestStat = sortedStat[0];
// Current time in seconds since the Unix epoch
const now = Math.floor(Date.now() / 1000);
// Determine the starting point for the chart data
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
// Generate the chart array for the range between 'firstChartDate' and 'now'
return Array.from({ length: now - firstChartDate }, (_, i) => {
const currentDate = firstChartDate + i;
return {
date: currentDate,
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
};
});
}
export default function ConnectionStatsSidebar () {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): boolean {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
const appendDiskDataChannelStats = useRTCStore(
state => state.appendDiskDataChannelStats,
);
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
const appendRemoteCandidateStats = useRTCStore(
state => state.appendRemoteCandidateStats,
);
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
useInterval(function collectWebRTCStats() {
(async () => {
if (!mediaStream) return;
const videoTrack = mediaStream.getVideoTracks()[0];
if (!videoTrack) return;
const stats = await peerConnection?.getStats();
let successfulLocalCandidateId: string | null = null;
let successfulRemoteCandidateId: string | null = null;
stats?.forEach(report => {
if (report.type === "inbound-rtp") {
appendInboundRtpStats(report);
} else if (report.type === "candidate-pair" && report.nominated) {
if (report.state === "succeeded") {
successfulLocalCandidateId = report.localCandidateId;
successfulRemoteCandidateId = report.remoteCandidateId;
}
appendIceCandidatePair(report);
} else if (report.type === "local-candidate") {
// We only want to append the local candidate stats that were used in nominated candidate pair
if (successfulLocalCandidateId === report.id) {
appendLocalCandidateStats(report);
}
} else if (report.type === "remote-candidate") {
if (successfulRemoteCandidateId === report.id) {
appendRemoteCandidateStats(report);
}
} else if (report.type === "data-channel" && report.label === "disk") {
appendDiskDataChannelStats(report);
}
});
})();
}, 500);
return (
<div className="grid h-full shadow-sm grid-rows-headerBody">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
<div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
*/}
{sidebarView === "connection-stats" && (
<div className="space-y-4">
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Packets Lost
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of data packets lost during transmission.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
<StatChart
data={createChartArray(inboundRtpStats, "packetsLost")}
domain={[0, 100]}
unit=" packets"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Round-Trip Time
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Time taken for data to travel from source to destination and back
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
<StatChart
data={createChartArray(
candidatePairStats,
"currentRoundTripTime",
).map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 600]}
unit=" ms"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Jitter
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Variation in packet delay, affecting video smoothness.{" "}
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "jitter").map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 300]}
unit=" ms"
/>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Frames per second
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of video frames displayed per second.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
x => {
return {
date: x.date,
stat: x.stat ? x.stat : null,
};
},
)}
domain={[0, 80]}
unit=" fps"
/>
)}
</div>
</GridCard>
</div>
</div>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More