Merge branch 'xo-web/master'
This commit is contained in:
commit
563ff38c25
65
packages/xo-web/.editorconfig
Normal file
65
packages/xo-web/.editorconfig
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# http://EditorConfig.org
|
||||||
|
#
|
||||||
|
# Julien Fontanet's configuration
|
||||||
|
# https://gist.github.com/julien-f/8096213
|
||||||
|
|
||||||
|
# Top-most EditorConfig file.
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Common config.
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespaces = true
|
||||||
|
|
||||||
|
# CoffeeScript
|
||||||
|
#
|
||||||
|
# https://github.com/polarmobile/coffeescript-style-guide/blob/master/README.md
|
||||||
|
[*.{,lit}coffee]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Markdown
|
||||||
|
[*.{md,mdwn,mdown,markdown}]
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Package.json
|
||||||
|
#
|
||||||
|
# This indentation style is the one used by npm.
|
||||||
|
[/package.json]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Jade
|
||||||
|
[*.jade]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# JavaScript
|
||||||
|
#
|
||||||
|
# Two spaces seems to be the standard most common style, at least in
|
||||||
|
# Node.js (http://nodeguide.com/style.html#tabs-vs-spaces).
|
||||||
|
[*.js]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Less
|
||||||
|
[*.less]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Sass
|
||||||
|
#
|
||||||
|
# Style used for http://libsass.com
|
||||||
|
[*.s[ac]ss]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# YAML
|
||||||
|
#
|
||||||
|
# Only spaces are allowed.
|
||||||
|
[*.yaml]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
12
packages/xo-web/.eslintrc.js
Normal file
12
packages/xo-web/.eslintrc.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['standard', 'standard-jsx'],
|
||||||
|
globals: {
|
||||||
|
__DEV__: true,
|
||||||
|
},
|
||||||
|
parser: 'babel-eslint',
|
||||||
|
rules: {
|
||||||
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'no-var': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
},
|
||||||
|
}
|
9
packages/xo-web/.gitignore
vendored
Normal file
9
packages/xo-web/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/dist/
|
||||||
|
/node_modules/
|
||||||
|
/src/common/intl/locales/index.js
|
||||||
|
/src/common/themes/index.js
|
||||||
|
|
||||||
|
npm-debug.log
|
||||||
|
npm-debug.log.*
|
||||||
|
pnpm-debug.log
|
||||||
|
pnpm-debug.log.*
|
10
packages/xo-web/.npmignore
Normal file
10
packages/xo-web/.npmignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/examples/
|
||||||
|
example.js
|
||||||
|
example.js.map
|
||||||
|
*.example.js
|
||||||
|
*.example.js.map
|
||||||
|
|
||||||
|
/test/
|
||||||
|
/tests/
|
||||||
|
*.spec.js
|
||||||
|
*.spec.js.map
|
4
packages/xo-web/.prettierrc.js
Normal file
4
packages/xo-web/.prettierrc.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
}
|
11
packages/xo-web/.travis.yml
Normal file
11
packages/xo-web/.travis.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
language: node_js
|
||||||
|
node_js:
|
||||||
|
- '6'
|
||||||
|
#- '4' # npm 3's flat tree is needed because some packages do not
|
||||||
|
# declare their deps correctly (e.g. chartist-plugin-tooltip)
|
||||||
|
|
||||||
|
cache: yarn
|
||||||
|
|
||||||
|
# Use containers.
|
||||||
|
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/
|
||||||
|
sudo: false
|
1420
packages/xo-web/CHANGELOG.md
Normal file
1420
packages/xo-web/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
28
packages/xo-web/ISSUE_TEMPLATE.md
Normal file
28
packages/xo-web/ISSUE_TEMPLATE.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<!--
|
||||||
|
Welcome to the issue section of Xen Orchestra!
|
||||||
|
|
||||||
|
Here you can:
|
||||||
|
- report an issue
|
||||||
|
- propose an enhancement
|
||||||
|
- ask a question
|
||||||
|
|
||||||
|
The template below is only a proposition for your ticket, feel free to
|
||||||
|
change it as appropriate :)
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
- **XO version**: XO appliance / `stable` branch / `next-release` branch
|
||||||
|
|
||||||
|
If from the sources:
|
||||||
|
|
||||||
|
- **Component**: xo-web / xo-server / *unknown*
|
||||||
|
- **Node/npm version**: *just execute `npm version`*
|
||||||
|
|
||||||
|
### Expected behavior
|
||||||
|
|
||||||
|
<!-- What you expect to happen -->
|
||||||
|
|
||||||
|
### Current behavior
|
||||||
|
|
||||||
|
<!-- What is actually happening -->
|
661
packages/xo-web/LICENSE
Normal file
661
packages/xo-web/LICENSE
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<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 Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
91
packages/xo-web/README.md
Normal file
91
packages/xo-web/README.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Xen Orchestra Web [](https://go.crisp.im/chat/embed/?website_id=-JzqzzwddSV7bKGtEyAQ) [](https://travis-ci.org/vatesfr/xo-web)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
XO-Web is part of [Xen Orchestra](https://github.com/vatesfr/xo), a web interface for XenServer or XAPI enabled hosts.
|
||||||
|
|
||||||
|
It is a web client for [XO-Server](https://github.com/vatesfr/xo-server).
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
XOA or manual install procedure is [available here](https://xen-orchestra.com/docs/installation.html)
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
Production build:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Development build:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
#### `NODE_ENV`
|
||||||
|
|
||||||
|
Set to *production* it disables many checks which result in increased
|
||||||
|
performance.
|
||||||
|
|
||||||
|
#### `XOA_PLAN`
|
||||||
|
|
||||||
|
- 1: Free
|
||||||
|
- 2: Starter
|
||||||
|
- 3: Enterprise
|
||||||
|
- 4: Premium
|
||||||
|
- 5: Sources
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (process.env.XOA_PLAN < 5) {
|
||||||
|
console.log('included only in XOA')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.XOA_PLAN > 3) {
|
||||||
|
console.log('included only in Premium and Sources')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to report a bug?
|
||||||
|
|
||||||
|
If you are certain the bug is exclusively related to XO-Web, you may use the [bugtracker of this repository](https://github.com/vatesfr/xo-web/issues).
|
||||||
|
|
||||||
|
Otherwise, please consider using the [bugtracker of the general repository](https://github.com/vatesfr/xo/issues).
|
||||||
|
|
||||||
|
## Process for new release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Switch to the stable branch.
|
||||||
|
git checkout stable
|
||||||
|
|
||||||
|
# Fetches latest changes.
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
# Merge changes of the next-release branch.
|
||||||
|
git merge next-release
|
||||||
|
|
||||||
|
# Increment the version (patch, minor or major).
|
||||||
|
npm version minor
|
||||||
|
|
||||||
|
# Go back to the next-release branch.
|
||||||
|
git checkout next-release
|
||||||
|
|
||||||
|
# Fetches the last changes (the merge and version bump) from stable to
|
||||||
|
# next-release.
|
||||||
|
git merge --ff-only stable
|
||||||
|
|
||||||
|
# Push the changes on git.
|
||||||
|
git push --follow-tags origin stable next-release
|
||||||
|
|
||||||
|
# Publish this release to npm.
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL3 © [Vates SAS](http://vates.fr)
|
303
packages/xo-web/gulpfile.js
Normal file
303
packages/xo-web/gulpfile.js
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const SRC_DIR = __dirname + '/src' // eslint-disable-line no-path-concat
|
||||||
|
const DIST_DIR = __dirname + '/dist' // eslint-disable-line no-path-concat
|
||||||
|
|
||||||
|
// Port to use for the livereload server.
|
||||||
|
//
|
||||||
|
// It must be available and if possible unique to not conflict with other projects.
|
||||||
|
// http://www.random.org/integers/?num=1&min=1024&max=65535&col=1&base=10&format=plain&rnd=new
|
||||||
|
const LIVERELOAD_PORT = 26242
|
||||||
|
|
||||||
|
const PRODUCTION = process.env.NODE_ENV === 'production'
|
||||||
|
const DEVELOPMENT = !PRODUCTION
|
||||||
|
|
||||||
|
if (!process.env.XOA_PLAN) {
|
||||||
|
process.env.XOA_PLAN = '5' // Open Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const gulp = require('gulp')
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
function lazyFn (factory) {
|
||||||
|
let fn = function () {
|
||||||
|
fn = factory()
|
||||||
|
return fn.apply(this, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
return fn.apply(this, arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
const livereload = lazyFn(function () {
|
||||||
|
const livereload = require('gulp-refresh')
|
||||||
|
livereload.listen({
|
||||||
|
port: LIVERELOAD_PORT,
|
||||||
|
})
|
||||||
|
|
||||||
|
return livereload
|
||||||
|
})
|
||||||
|
|
||||||
|
const pipe = lazyFn(function () {
|
||||||
|
let current
|
||||||
|
function pipeCore (streams) {
|
||||||
|
let i, n, stream
|
||||||
|
for (i = 0, n = streams.length; i < n; ++i) {
|
||||||
|
stream = streams[i]
|
||||||
|
if (!stream) {
|
||||||
|
// Nothing to do
|
||||||
|
} else if (stream instanceof Array) {
|
||||||
|
pipeCore(stream)
|
||||||
|
} else {
|
||||||
|
current = current ? current.pipe(stream) : stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = Array.prototype.push
|
||||||
|
return function (streams) {
|
||||||
|
try {
|
||||||
|
if (!(streams instanceof Array)) {
|
||||||
|
streams = []
|
||||||
|
push.apply(streams, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeCore(streams)
|
||||||
|
|
||||||
|
return current
|
||||||
|
} finally {
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvePath = lazyFn(function () {
|
||||||
|
return require('path').resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Similar to `gulp.src()` but the pattern is relative to `SRC_DIR`
|
||||||
|
// and files are automatically watched when not in production mode.
|
||||||
|
const src = lazyFn(function () {
|
||||||
|
function resolve (path) {
|
||||||
|
return path ? resolvePath(SRC_DIR, path) : SRC_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
return PRODUCTION
|
||||||
|
? function src (pattern, opts) {
|
||||||
|
const base = resolve(opts && opts.base)
|
||||||
|
|
||||||
|
return gulp.src(pattern, {
|
||||||
|
base: base,
|
||||||
|
cwd: base,
|
||||||
|
passthrough: opts && opts.passthrough,
|
||||||
|
sourcemaps: opts && opts.sourcemaps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: function src (pattern, opts) {
|
||||||
|
const base = resolve(opts && opts.base)
|
||||||
|
|
||||||
|
return pipe(
|
||||||
|
gulp.src(pattern, {
|
||||||
|
base: base,
|
||||||
|
cwd: base,
|
||||||
|
passthrough: opts && opts.passthrough,
|
||||||
|
sourcemaps: opts && opts.sourcemaps,
|
||||||
|
}),
|
||||||
|
require('gulp-watch')(pattern, {
|
||||||
|
base: base,
|
||||||
|
cwd: base,
|
||||||
|
}),
|
||||||
|
require('gulp-plumber')()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Similar to `gulp.dest()` but the output directory is relative to
|
||||||
|
// `DIST_DIR` and default to `./`, and files are automatically live-
|
||||||
|
// reloaded when not in production mode.
|
||||||
|
const dest = lazyFn(function () {
|
||||||
|
function resolve (path) {
|
||||||
|
return path ? resolvePath(DIST_DIR, path) : DIST_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
sourcemaps: '.',
|
||||||
|
}
|
||||||
|
|
||||||
|
return PRODUCTION
|
||||||
|
? function dest (path) {
|
||||||
|
return gulp.dest(resolve(path), opts)
|
||||||
|
}
|
||||||
|
: function dest (path) {
|
||||||
|
const stream = gulp.dest(resolve(path), opts)
|
||||||
|
stream.pipe(livereload())
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
function browserify (path, opts) {
|
||||||
|
if (opts == null) {
|
||||||
|
opts = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundler = require('browserify')(path, {
|
||||||
|
basedir: SRC_DIR,
|
||||||
|
debug: true,
|
||||||
|
extensions: opts.extensions,
|
||||||
|
fullPaths: false,
|
||||||
|
paths: SRC_DIR + '/common',
|
||||||
|
standalone: opts.standalone,
|
||||||
|
|
||||||
|
// Required by Watchify.
|
||||||
|
cache: {},
|
||||||
|
packageCache: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const plugins = opts.plugins
|
||||||
|
for (let i = 0, n = plugins && plugins.length; i < n; ++i) {
|
||||||
|
const plugin = plugins[i]
|
||||||
|
bundler.plugin(require(plugin[0]), plugin[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRODUCTION) {
|
||||||
|
// FIXME: does not work with react-intl (?!)
|
||||||
|
// bundler.plugin('bundle-collapser/plugin')
|
||||||
|
} else {
|
||||||
|
bundler = require('watchify')(bundler, {
|
||||||
|
// do not watch in `node_modules`
|
||||||
|
// https://github.com/browserify/watchify#options
|
||||||
|
ignoreWatch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the extension if necessary.
|
||||||
|
if (!/\.js$/.test(path)) {
|
||||||
|
path += '.js'
|
||||||
|
}
|
||||||
|
path = resolvePath(SRC_DIR, path)
|
||||||
|
|
||||||
|
let stream = new (require('readable-stream'))({
|
||||||
|
objectMode: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let write
|
||||||
|
function bundle () {
|
||||||
|
bundler.bundle(function onBundle (error, buffer) {
|
||||||
|
if (error) {
|
||||||
|
stream.emit('error', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
write(
|
||||||
|
new (require('vinyl'))({
|
||||||
|
base: SRC_DIR,
|
||||||
|
contents: buffer,
|
||||||
|
path: path,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRODUCTION) {
|
||||||
|
write = function (data) {
|
||||||
|
stream.push(data)
|
||||||
|
stream.push(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream = require('gulp-plumber')().pipe(stream)
|
||||||
|
write = function (data) {
|
||||||
|
stream.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundler.on('update', bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream._read = function () {
|
||||||
|
this._read = function () {}
|
||||||
|
bundle()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
gulp.task(function buildPages () {
|
||||||
|
return pipe(
|
||||||
|
src('index.pug'),
|
||||||
|
require('gulp-pug')(),
|
||||||
|
DEVELOPMENT &&
|
||||||
|
require('gulp-embedlr')({
|
||||||
|
port: LIVERELOAD_PORT,
|
||||||
|
}),
|
||||||
|
dest()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
gulp.task(function buildScripts () {
|
||||||
|
return pipe(
|
||||||
|
browserify('./index.js', {
|
||||||
|
plugins: [
|
||||||
|
// ['css-modulesify', {
|
||||||
|
[
|
||||||
|
'modular-cssify',
|
||||||
|
{
|
||||||
|
css: DIST_DIR + '/modules.css',
|
||||||
|
from: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
require('gulp-sourcemaps').init({ loadMaps: true }),
|
||||||
|
PRODUCTION && require('gulp-uglify/composer')(require('uglify-es'))(),
|
||||||
|
dest()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
gulp.task(function buildStyles () {
|
||||||
|
return pipe(
|
||||||
|
src('index.scss', { sourcemaps: true }),
|
||||||
|
require('gulp-sass')(),
|
||||||
|
require('gulp-autoprefixer')(['last 1 version', '> 1%']),
|
||||||
|
PRODUCTION && require('gulp-csso')(),
|
||||||
|
dest()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
gulp.task(function copyAssets () {
|
||||||
|
return pipe(
|
||||||
|
src(['assets/**/*', 'favicon.*']),
|
||||||
|
src('fontawesome-webfont.*', {
|
||||||
|
base: __dirname + '/node_modules/font-awesome/fonts', // eslint-disable-line no-path-concat
|
||||||
|
passthrough: true,
|
||||||
|
}),
|
||||||
|
src(['!*.css', 'font-mfizz.*'], {
|
||||||
|
base: __dirname + '/node_modules/font-mfizz/dist', // eslint-disable-line no-path-concat
|
||||||
|
passthrough: true,
|
||||||
|
}),
|
||||||
|
dest()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
gulp.task(
|
||||||
|
'build',
|
||||||
|
gulp.parallel('buildPages', 'buildScripts', 'buildStyles', 'copyAssets')
|
||||||
|
)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
gulp.task(function clean (done) {
|
||||||
|
require('rimraf')(DIST_DIR, done)
|
||||||
|
})
|
229
packages/xo-web/package.json
Normal file
229
packages/xo-web/package.json
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
{
|
||||||
|
"private": false,
|
||||||
|
"name": "xo-web",
|
||||||
|
"version": "5.16.0",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"description": "Web interface client for Xen-Orchestra",
|
||||||
|
"keywords": [
|
||||||
|
"xen",
|
||||||
|
"orchestra",
|
||||||
|
"xen-orchestra",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/vatesfr/xo-web",
|
||||||
|
"bugs": "https://github.com/vatesfr/xo-web/issues",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/vatesfr/xo-web"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Julien Fontanet",
|
||||||
|
"email": "julien.fontanet@vates.fr"
|
||||||
|
},
|
||||||
|
"preferGlobal": false,
|
||||||
|
"main": "dist/",
|
||||||
|
"bin": {},
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4",
|
||||||
|
"npm": ">=3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nraynaud/novnc": "0.6.1",
|
||||||
|
"ansi_up": "^2.0.2",
|
||||||
|
"asap": "^2.0.6",
|
||||||
|
"babel-core": "^6.26.0",
|
||||||
|
"babel-eslint": "^8.1.2",
|
||||||
|
"babel-plugin-dev": "^1.0.0",
|
||||||
|
"babel-plugin-lodash": "^3.2.11",
|
||||||
|
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||||
|
"babel-plugin-transform-react-constant-elements": "^6.5.0",
|
||||||
|
"babel-plugin-transform-react-inline-elements": "^6.6.5",
|
||||||
|
"babel-plugin-transform-react-jsx-self": "^6.11.0",
|
||||||
|
"babel-plugin-transform-react-jsx-source": "^6.9.0",
|
||||||
|
"babel-plugin-transform-runtime": "^6.6.0",
|
||||||
|
"babel-preset-env": "^1.6.1",
|
||||||
|
"babel-preset-react": "^6.5.0",
|
||||||
|
"babel-preset-stage-0": "^6.24.1",
|
||||||
|
"babel-register": "^6.26.0",
|
||||||
|
"babel-runtime": "^6.26.0",
|
||||||
|
"babelify": "^8.0.0",
|
||||||
|
"benchmark": "^2.1.0",
|
||||||
|
"bootstrap": "4.0.0-alpha.5",
|
||||||
|
"browserify": "^15.1.0",
|
||||||
|
"bundle-collapser": "^1.3.0",
|
||||||
|
"chartist": "^0.10.1",
|
||||||
|
"chartist-plugin-legend": "^0.6.1",
|
||||||
|
"chartist-plugin-tooltip": "0.0.11",
|
||||||
|
"classnames": "^2.2.3",
|
||||||
|
"complex-matcher": "^0.2.1",
|
||||||
|
"cookies-js": "^1.2.2",
|
||||||
|
"d3": "^4.12.2",
|
||||||
|
"debounce-input-decorator": "^0.1.0",
|
||||||
|
"dependency-check": "^3.0.0",
|
||||||
|
"enzyme": "^3.3.0",
|
||||||
|
"enzyme-adapter-react-15": "^1.0.5",
|
||||||
|
"enzyme-to-json": "^3.3.0",
|
||||||
|
"eslint": "^4.14.0",
|
||||||
|
"eslint-config-standard": "^10.2.1",
|
||||||
|
"eslint-config-standard-jsx": "^4.0.2",
|
||||||
|
"eslint-plugin-import": "^2.8.0",
|
||||||
|
"eslint-plugin-node": "^5.2.1",
|
||||||
|
"eslint-plugin-promise": "^3.6.0",
|
||||||
|
"eslint-plugin-react": "^7.4.0",
|
||||||
|
"eslint-plugin-standard": "^3.0.1",
|
||||||
|
"event-to-promise": "^0.8.0",
|
||||||
|
"font-awesome": "^4.7.0",
|
||||||
|
"font-mfizz": "^2.4.1",
|
||||||
|
"get-stream": "^3.0.0",
|
||||||
|
"globby": "^7.1.1",
|
||||||
|
"gulp": "^4.0.0",
|
||||||
|
"gulp-autoprefixer": "^4.1.0",
|
||||||
|
"gulp-csso": "^3.0.0",
|
||||||
|
"gulp-embedlr": "^0.5.2",
|
||||||
|
"gulp-plumber": "^1.1.0",
|
||||||
|
"gulp-pug": "^3.1.0",
|
||||||
|
"gulp-refresh": "^1.1.0",
|
||||||
|
"gulp-sass": "^3.0.0",
|
||||||
|
"gulp-sourcemaps": "^2.6.2",
|
||||||
|
"gulp-uglify": "^3.0.0",
|
||||||
|
"gulp-watch": "^5.0.0",
|
||||||
|
"human-format": "^0.10.0",
|
||||||
|
"husky": "^0.14.3",
|
||||||
|
"immutable": "^3.8.2",
|
||||||
|
"index-modules": "^0.3.0",
|
||||||
|
"is-ip": "^2.0.0",
|
||||||
|
"jest": "^22.0.4",
|
||||||
|
"jsonrpc-websocket-client": "^0.2.0",
|
||||||
|
"kindof": "^2.0.0",
|
||||||
|
"later": "^1.2.0",
|
||||||
|
"lint-staged": "^6.0.0",
|
||||||
|
"lodash": "^4.6.1",
|
||||||
|
"loose-envify": "^1.1.0",
|
||||||
|
"make-error": "^1.3.2",
|
||||||
|
"marked": "^0.3.9",
|
||||||
|
"modular-cssify": "^7.2.0",
|
||||||
|
"moment": "^2.20.1",
|
||||||
|
"moment-timezone": "^0.5.14",
|
||||||
|
"notifyjs": "^3.0.0",
|
||||||
|
"prettier": "^1.9.2",
|
||||||
|
"promise-toolbox": "^0.9.5",
|
||||||
|
"prop-types": "^15.6.0",
|
||||||
|
"random-password": "^0.1.2",
|
||||||
|
"react": "^15.4.1",
|
||||||
|
"react-addons-shallow-compare": "^15.6.2",
|
||||||
|
"react-addons-test-utils": "^15.6.2",
|
||||||
|
"react-bootstrap-4": "^0.29.1",
|
||||||
|
"react-chartist": "^0.13.0",
|
||||||
|
"react-copy-to-clipboard": "^5.0.1",
|
||||||
|
"react-dnd": "^2.5.4",
|
||||||
|
"react-dnd-html5-backend": "^2.5.4",
|
||||||
|
"react-document-title": "^2.0.2",
|
||||||
|
"react-dom": "^15.4.1",
|
||||||
|
"react-dropzone": "^4.2.3",
|
||||||
|
"react-intl": "^2.4.0",
|
||||||
|
"react-key-handler": "^1.0.1",
|
||||||
|
"react-notify": "^3.0.0",
|
||||||
|
"react-overlays": "^0.8.3",
|
||||||
|
"react-redux": "^5.0.6",
|
||||||
|
"react-router": "^3.0.0",
|
||||||
|
"react-select": "^1.1.0",
|
||||||
|
"react-shortcuts": "^2.0.0",
|
||||||
|
"react-sparklines": "1.6.0",
|
||||||
|
"react-test-renderer": "^15.6.2",
|
||||||
|
"react-virtualized": "^9.15.0",
|
||||||
|
"readable-stream": "^2.3.3",
|
||||||
|
"redux": "^3.7.2",
|
||||||
|
"redux-thunk": "^2.0.1",
|
||||||
|
"reselect": "^2.5.4",
|
||||||
|
"semver": "^5.4.1",
|
||||||
|
"styled-components": "^3.1.5",
|
||||||
|
"tar-stream": "^1.5.5",
|
||||||
|
"uglify-es": "^3.3.4",
|
||||||
|
"uncontrollable-input": "^0.1.1",
|
||||||
|
"url-parse": "^1.2.0",
|
||||||
|
"value-matcher": "^0.0.0",
|
||||||
|
"vinyl": "^2.1.0",
|
||||||
|
"watchify": "^3.7.0",
|
||||||
|
"whatwg-fetch": "^2.0.3",
|
||||||
|
"xml2js": "^0.4.19",
|
||||||
|
"xo-acl-resolver": "^0.2.3",
|
||||||
|
"xo-common": "^0.1.1",
|
||||||
|
"xo-lib": "^0.8.0",
|
||||||
|
"xo-remote-parser": "^0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"benchmarks": "./tools/run-benchmarks.js 'src/**/*.bench.js'",
|
||||||
|
"build": "npm run build-indexes && NODE_ENV=production gulp build",
|
||||||
|
"build-indexes": "index-modules --auto src",
|
||||||
|
"clean": "gulp clean",
|
||||||
|
"dev": "npm run build-indexes && NODE_ENV=development gulp build",
|
||||||
|
"dev-test": "jest --watch",
|
||||||
|
"lint-staged-stash": "touch .lint-staged && git stash save --include-untracked --keep-index && true",
|
||||||
|
"lint-staged-unstash": "git stash pop && rm -f .lint-staged && true",
|
||||||
|
"posttest": "eslint --ignore-path .gitignore src/",
|
||||||
|
"prebuild": "npm run clean",
|
||||||
|
"precommit": "lint-staged",
|
||||||
|
"predev": "npm run clean",
|
||||||
|
"prepublishOnly": "npm run build",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"browserify": {
|
||||||
|
"transform": [
|
||||||
|
"babelify",
|
||||||
|
"loose-envify"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"env": {
|
||||||
|
"development": {
|
||||||
|
"plugins": [
|
||||||
|
"transform-react-jsx-self",
|
||||||
|
"transform-react-jsx-source"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"plugins": [
|
||||||
|
"transform-react-constant-elements",
|
||||||
|
"transform-react-inline-elements"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"dev",
|
||||||
|
"lodash",
|
||||||
|
"transform-decorators-legacy",
|
||||||
|
"transform-runtime"
|
||||||
|
],
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"env",
|
||||||
|
{
|
||||||
|
"targets": {
|
||||||
|
"browsers": ">2%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react",
|
||||||
|
"stage-0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"setupTestFrameworkScriptFile": "./setup-tests.js",
|
||||||
|
"snapshotSerializers": [
|
||||||
|
"enzyme-to-json/serializer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.js": [
|
||||||
|
"lint-staged-stash",
|
||||||
|
"prettier --write",
|
||||||
|
"eslint --fix",
|
||||||
|
"jest --findRelatedTests --passWithNoTests",
|
||||||
|
"git add",
|
||||||
|
"lint-staged-unstash"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
4
packages/xo-web/setup-tests.js
Normal file
4
packages/xo-web/setup-tests.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { configure } from 'enzyme'
|
||||||
|
import Adapter from 'enzyme-adapter-react-15'
|
||||||
|
|
||||||
|
configure({ adapter: new Adapter() })
|
1
packages/xo-web/src/assets/loading.svg
Normal file
1
packages/xo-web/src/assets/loading.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><svg width='62px' height='62px' xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="uil-ring-alt"><rect x="0" y="0" width="100" height="100" fill="none" class="bk"></rect><circle cx="50" cy="50" r="40" stroke="#cfcfcf" fill="none" stroke-width="10" stroke-linecap="round"></circle><circle cx="50" cy="50" r="40" stroke="#366e98" fill="none" stroke-width="6" stroke-linecap="round"><animate attributeName="stroke-dashoffset" dur="1s" repeatCount="indefinite" from="0" to="502"></animate><animate attributeName="stroke-dasharray" dur="1s" repeatCount="indefinite" values="150.6 100.4;1 250;150.6 100.4"></animate></circle></svg>
|
After Width: | Height: | Size: 707 B |
BIN
packages/xo-web/src/assets/logo.png
Normal file
BIN
packages/xo-web/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
133
packages/xo-web/src/chartist.scss
Normal file
133
packages/xo-web/src/chartist.scss
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
// CHARTIST ===================================================================
|
||||||
|
|
||||||
|
$ct-series-colors: (
|
||||||
|
$brand-success,
|
||||||
|
$brand-primary,
|
||||||
|
#f17cb0,
|
||||||
|
#86797d,
|
||||||
|
#b276b2,
|
||||||
|
#f15854,
|
||||||
|
#b2912f,
|
||||||
|
#decf3f,
|
||||||
|
#dda458,
|
||||||
|
#60bd68,
|
||||||
|
#4d4d4d,
|
||||||
|
#eacf7d,
|
||||||
|
#b2c326,
|
||||||
|
#6188e2,
|
||||||
|
#a748ca
|
||||||
|
) !default;
|
||||||
|
|
||||||
|
@import "../node_modules/chartist/dist/scss/settings/_chartist-settings";
|
||||||
|
@import "../node_modules/chartist/dist/scss/chartist";
|
||||||
|
|
||||||
|
.ct-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// safari has a bug in flex computing that prevent charts from showing see #1755
|
||||||
|
// by fixing the height with a value found in Chrome it seems like it fixes the issue without breaking the layout
|
||||||
|
// elsewhere
|
||||||
|
.dashboardItem .ct-chart {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line in charts with only 2px in width
|
||||||
|
.ct-line {
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-bar {
|
||||||
|
stroke-width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-point {
|
||||||
|
stroke-width: 30px;
|
||||||
|
stroke-opacity: 0!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-point:hover {
|
||||||
|
stroke-opacity: 0.2!important;
|
||||||
|
stroke-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 5em;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #383838;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
// Arrow!
|
||||||
|
&:before {
|
||||||
|
bottom: -14px;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
border: solid transparent;
|
||||||
|
content: '';
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-color: rgba(251, 249, 228, 0);
|
||||||
|
border-top-color: #383838;
|
||||||
|
border-width: 7px;
|
||||||
|
margin-left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide {
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CHARTIST LEGEND =============================================================
|
||||||
|
|
||||||
|
.ct-legend {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -1em;
|
||||||
|
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
list-style-type: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
left: 0;
|
||||||
|
content: '';
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.inactive:before {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ct-legend-inside {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 0 to length($ct-series-colors) {
|
||||||
|
.ct-series-#{$i}:before {
|
||||||
|
background-color: nth($ct-series-colors, $i + 1);
|
||||||
|
border-color: nth($ct-series-colors, $i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
packages/xo-web/src/common/__snapshots__/grid.spec.js.snap
Normal file
19
packages/xo-web/src/common/__snapshots__/grid.spec.js.snap
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Col 1`] = `
|
||||||
|
<div
|
||||||
|
className="col-xs-12"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Container 1`] = `
|
||||||
|
<div
|
||||||
|
className="container-fluid"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Row 1`] = `
|
||||||
|
<div
|
||||||
|
className=" row"
|
||||||
|
/>
|
||||||
|
`;
|
60
packages/xo-web/src/common/action-bar.js
Normal file
60
packages/xo-web/src/common/action-bar.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import ActionButton from 'action-button'
|
||||||
|
import propTypes from 'prop-types-decorator'
|
||||||
|
import React, { cloneElement } from 'react'
|
||||||
|
import { noop } from 'lodash'
|
||||||
|
|
||||||
|
import ButtonGroup from './button-group'
|
||||||
|
|
||||||
|
export const Action = ({
|
||||||
|
display,
|
||||||
|
handler,
|
||||||
|
handlerParam,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
pending,
|
||||||
|
redirectOnSuccess,
|
||||||
|
}) => (
|
||||||
|
<ActionButton
|
||||||
|
handler={handler}
|
||||||
|
handlerParam={handlerParam}
|
||||||
|
icon={icon}
|
||||||
|
pending={pending}
|
||||||
|
redirectOnSuccess={redirectOnSuccess}
|
||||||
|
size='large'
|
||||||
|
tooltip={display === 'icon' ? label : undefined}
|
||||||
|
>
|
||||||
|
{display === 'both' && label}
|
||||||
|
</ActionButton>
|
||||||
|
)
|
||||||
|
|
||||||
|
Action.propTypes = {
|
||||||
|
display: propTypes.oneOf(['icon', 'both']),
|
||||||
|
handler: propTypes.func.isRequired,
|
||||||
|
icon: propTypes.string.isRequired,
|
||||||
|
label: propTypes.node,
|
||||||
|
pending: propTypes.bool,
|
||||||
|
redirectOnSuccess: propTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionBar = ({ children, handlerParam = noop, display = 'both' }) => (
|
||||||
|
<ButtonGroup>
|
||||||
|
{React.Children.map(children, (child, key) => {
|
||||||
|
if (!child) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { props } = child
|
||||||
|
return cloneElement(child, {
|
||||||
|
display: props.display || display,
|
||||||
|
handlerParam: props.handlerParam || handlerParam,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</ButtonGroup>
|
||||||
|
)
|
||||||
|
|
||||||
|
ActionBar.propTypes = {
|
||||||
|
display: propTypes.oneOf(['icon', 'both']),
|
||||||
|
handlerParam: propTypes.any,
|
||||||
|
}
|
||||||
|
export { ActionBar as default }
|
151
packages/xo-web/src/common/action-button.js
Normal file
151
packages/xo-web/src/common/action-button.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import isFunction from 'lodash/isFunction'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Button from './button'
|
||||||
|
import Component from './base-component'
|
||||||
|
import Icon from './icon'
|
||||||
|
import logError from './log-error'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import Tooltip from './tooltip'
|
||||||
|
import { error as _error } from './notification'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
// React element to use as button content
|
||||||
|
children: propTypes.node,
|
||||||
|
|
||||||
|
// whether this button is disabled (default to false)
|
||||||
|
disabled: propTypes.bool,
|
||||||
|
|
||||||
|
// form identifier
|
||||||
|
//
|
||||||
|
// if provided, this button and its action are associated to this
|
||||||
|
// form for the submit event
|
||||||
|
form: propTypes.string,
|
||||||
|
|
||||||
|
// function to call when the action is triggered (via a clik on the
|
||||||
|
// button or submit on the form)
|
||||||
|
handler: propTypes.func.isRequired,
|
||||||
|
|
||||||
|
// optional value which will be passed as first param to the handler
|
||||||
|
handlerParam: propTypes.any,
|
||||||
|
|
||||||
|
// XO icon to use for this button
|
||||||
|
icon: propTypes.string.isRequired,
|
||||||
|
|
||||||
|
// whether the action of this action is already underway
|
||||||
|
pending: propTypes.bool,
|
||||||
|
|
||||||
|
// path to redirect to when the triggered action finish successfully
|
||||||
|
//
|
||||||
|
// if a function, it will be called with the result of the action to
|
||||||
|
// compute the path
|
||||||
|
redirectOnSuccess: propTypes.oneOfType([propTypes.func, propTypes.string]),
|
||||||
|
|
||||||
|
// React element to use tooltip for the component
|
||||||
|
tooltip: propTypes.node,
|
||||||
|
})
|
||||||
|
export default class ActionButton extends Component {
|
||||||
|
static contextTypes = {
|
||||||
|
router: propTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
async _execute () {
|
||||||
|
if (this.props.pending || this.state.working) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, handler, handlerParam, tooltip } = this.props
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.setState({
|
||||||
|
error: undefined,
|
||||||
|
working: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler(handlerParam)
|
||||||
|
|
||||||
|
const { redirectOnSuccess } = this.props
|
||||||
|
if (redirectOnSuccess) {
|
||||||
|
return this.context.router.push(
|
||||||
|
isFunction(redirectOnSuccess)
|
||||||
|
? redirectOnSuccess(result)
|
||||||
|
: redirectOnSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
working: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
working: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ignore when undefined because it usually means that the action has been canceled
|
||||||
|
if (error !== undefined) {
|
||||||
|
logError(error)
|
||||||
|
_error(
|
||||||
|
children || tooltip || error.name,
|
||||||
|
error.message || String(error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_execute = ::this._execute
|
||||||
|
|
||||||
|
_eventListener = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
this._execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { form } = this.props
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
document
|
||||||
|
.getElementById(form)
|
||||||
|
.addEventListener('submit', this._eventListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
const { form } = this.props
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
document
|
||||||
|
.getElementById(form)
|
||||||
|
.removeEventListener('submit', this._eventListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
props: { children, icon, pending, tooltip, ...props },
|
||||||
|
state: { error, working },
|
||||||
|
} = this
|
||||||
|
|
||||||
|
if (error !== undefined) {
|
||||||
|
props.btnStyle = 'warning'
|
||||||
|
}
|
||||||
|
if (pending || working) {
|
||||||
|
props.disabled = true
|
||||||
|
}
|
||||||
|
delete props.handler
|
||||||
|
delete props.handlerParam
|
||||||
|
if (props.form === undefined) {
|
||||||
|
props.onClick = this._execute
|
||||||
|
}
|
||||||
|
delete props.redirectOnSuccess
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Button {...props}>
|
||||||
|
<Icon icon={pending || working ? 'loading' : icon} fixedWidth />
|
||||||
|
{children && ' '}
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return tooltip ? <Tooltip content={tooltip}>{button}</Tooltip> : button
|
||||||
|
}
|
||||||
|
}
|
7
packages/xo-web/src/common/action-row-button/index.css
Normal file
7
packages/xo-web/src/common/action-row-button/index.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.button {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover .button, tr:focus .button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
10
packages/xo-web/src/common/action-row-button/index.js
Normal file
10
packages/xo-web/src/common/action-row-button/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import ActionButton from '../action-button'
|
||||||
|
|
||||||
|
import styles from './index.css'
|
||||||
|
|
||||||
|
const ActionRowButton = props => (
|
||||||
|
<ActionButton {...props} className={styles.button} size='small' />
|
||||||
|
)
|
||||||
|
export { ActionRowButton as default }
|
16
packages/xo-web/src/common/action-toggle.js
Normal file
16
packages/xo-web/src/common/action-toggle.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import ActionButton from './action-button'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const ActionToggle = ({ className, value, ...props }) => (
|
||||||
|
<ActionButton
|
||||||
|
{...props}
|
||||||
|
btnStyle={value ? 'success' : null}
|
||||||
|
icon={value ? 'toggle-on' : 'toggle-off'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default propTypes({
|
||||||
|
value: propTypes.bool,
|
||||||
|
})(ActionToggle)
|
29
packages/xo-web/src/common/add-subscriptions.js
Normal file
29
packages/xo-web/src/common/add-subscriptions.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import map from 'lodash/map'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const call = fn => fn()
|
||||||
|
|
||||||
|
// `subscriptions` can be a function if we want to ensure that the subscription
|
||||||
|
// callbacks have been correctly initialized when there are circular dependencies
|
||||||
|
const addSubscriptions = subscriptions => Component =>
|
||||||
|
class SubscriptionWrapper extends React.PureComponent {
|
||||||
|
_unsubscribes = null
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this._unsubscribes = map(
|
||||||
|
typeof subscriptions === 'function' ? subscriptions(this.props) : subscriptions,
|
||||||
|
(subscribe, prop) =>
|
||||||
|
subscribe(value => this.setState({ [prop]: value }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
this._unsubscribes.forEach(call)
|
||||||
|
this._unsubscribes = null
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return <Component {...this.props} {...this.state} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { addSubscriptions as default }
|
119
packages/xo-web/src/common/base-component.js
Normal file
119
packages/xo-web/src/common/base-component.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { PureComponent } from 'react'
|
||||||
|
import { cowSet } from 'utils'
|
||||||
|
import { includes, isArray, forEach, map } from 'lodash'
|
||||||
|
|
||||||
|
import getEventValue from './get-event-value'
|
||||||
|
|
||||||
|
// Should components logs every renders?
|
||||||
|
//
|
||||||
|
// Usually set to process.env.NODE_ENV !== 'production'.
|
||||||
|
const VERBOSE = false
|
||||||
|
|
||||||
|
const get = (object, path, depth) => {
|
||||||
|
if (object == null || depth >= path.length) {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const prop = path[depth++]
|
||||||
|
return isArray(object) && prop === '*'
|
||||||
|
? map(object, value => get(value, path, depth))
|
||||||
|
: get(object[prop], path, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BaseComponent extends PureComponent {
|
||||||
|
constructor (props, context) {
|
||||||
|
super(props, context)
|
||||||
|
|
||||||
|
// It really should have been done in React.Component!
|
||||||
|
this.state = {}
|
||||||
|
|
||||||
|
this._linkedState = null
|
||||||
|
|
||||||
|
if (VERBOSE) {
|
||||||
|
this.render = (render => () => {
|
||||||
|
console.log('render', this.constructor.name)
|
||||||
|
|
||||||
|
return render.call(this)
|
||||||
|
})(this.render)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://preactjs.com/guide/linked-state
|
||||||
|
linkState (name, targetPath) {
|
||||||
|
const key = targetPath !== undefined ? `${name}##${targetPath}` : name
|
||||||
|
|
||||||
|
let linkedState = this._linkedState
|
||||||
|
let cb
|
||||||
|
if (linkedState === null) {
|
||||||
|
linkedState = this._linkedState = {}
|
||||||
|
} else if ((cb = linkedState[key]) !== undefined) {
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
|
let getValue
|
||||||
|
if (targetPath !== undefined) {
|
||||||
|
const path = targetPath.split('.')
|
||||||
|
getValue = event => get(getEventValue(event), path, 0)
|
||||||
|
} else {
|
||||||
|
getValue = getEventValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includes(name, '.')) {
|
||||||
|
const path = name.split('.')
|
||||||
|
return (linkedState[key] = event => {
|
||||||
|
this.setState(cowSet(this.state, path, getValue(event), 0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (linkedState[key] = event => {
|
||||||
|
this.setState({
|
||||||
|
[name]: getValue(event),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleState (name) {
|
||||||
|
let linkedState = this._linkedState
|
||||||
|
let cb
|
||||||
|
if (linkedState === null) {
|
||||||
|
linkedState = this._linkedState = {}
|
||||||
|
} else if ((cb = linkedState[name]) !== undefined) {
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includes(name, '.')) {
|
||||||
|
const path = name.split('.')
|
||||||
|
return (linkedState[path] = event => {
|
||||||
|
this.setState(cowSet(this.state, path, !get(this.state, path, 0), 0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (linkedState[name] = () => {
|
||||||
|
this.setState({
|
||||||
|
[name]: !this.state[name],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VERBOSE) {
|
||||||
|
const diff = (name, old, cur) => {
|
||||||
|
const keys = []
|
||||||
|
|
||||||
|
forEach(old, (value, key) => {
|
||||||
|
if (cur[key] !== value) {
|
||||||
|
keys.push(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (keys.length) {
|
||||||
|
console.log(name, keys.sort().join())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseComponent.prototype.componentDidUpdate = function (oldProps, oldState) {
|
||||||
|
const prefix = `${this.constructor.name} updated because of its`
|
||||||
|
diff(`${prefix} props:`, oldProps, this.props)
|
||||||
|
diff(`${prefix} state:`, oldState, this.state)
|
||||||
|
}
|
||||||
|
}
|
35
packages/xo-web/src/common/browser-notification.js
Normal file
35
packages/xo-web/src/common/browser-notification.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { noop } from 'utils'
|
||||||
|
import Notify from 'notifyjs'
|
||||||
|
|
||||||
|
let notify
|
||||||
|
export { notify as default }
|
||||||
|
|
||||||
|
const sendNotification = (title, body) => {
|
||||||
|
new Notify(title, {
|
||||||
|
body,
|
||||||
|
timeout: 5,
|
||||||
|
icon: 'assets/logo.png',
|
||||||
|
}).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPermission = (...args) => {
|
||||||
|
if (Notify.isSupported()) {
|
||||||
|
Notify.requestPermission(
|
||||||
|
() => {
|
||||||
|
console.log('notifications allowed')
|
||||||
|
|
||||||
|
return (notify = sendNotification)(...args)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('notifications denied')
|
||||||
|
|
||||||
|
notify = noop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
notify = noop
|
||||||
|
console.warn('notifications are not supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify = Notify.needsPermission ? requestPermission : sendNotification
|
9
packages/xo-web/src/common/button-group.js
Normal file
9
packages/xo-web/src/common/button-group.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const ButtonGroup = ({ children }) => (
|
||||||
|
<div className='btn-group' role='group'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ButtonGroup as default }
|
28
packages/xo-web/src/common/button-link.js
Normal file
28
packages/xo-web/src/common/button-link.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { routerShape } from 'react-router/lib/PropTypes'
|
||||||
|
|
||||||
|
import Button from './button'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const ButtonLink = ({ to, ...props }, { router }) => {
|
||||||
|
props.onClick = () => {
|
||||||
|
router.push(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
propTypes(
|
||||||
|
{
|
||||||
|
to: propTypes.oneOfType([
|
||||||
|
propTypes.func,
|
||||||
|
propTypes.object,
|
||||||
|
propTypes.string,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
router: routerShape,
|
||||||
|
}
|
||||||
|
)(ButtonLink)
|
||||||
|
|
||||||
|
export { ButtonLink as default }
|
53
packages/xo-web/src/common/button.js
Normal file
53
packages/xo-web/src/common/button.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const Button = ({
|
||||||
|
active,
|
||||||
|
block,
|
||||||
|
btnStyle = 'secondary',
|
||||||
|
children,
|
||||||
|
outline,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
props.className = classNames(
|
||||||
|
props.className,
|
||||||
|
'btn',
|
||||||
|
`btn${outline ? '-outline' : ''}-${btnStyle}`,
|
||||||
|
active !== undefined && 'active',
|
||||||
|
block && 'btn-block',
|
||||||
|
size === 'large' ? 'btn-lg' : size === 'small' ? 'btn-sm' : null
|
||||||
|
)
|
||||||
|
if (props.type === undefined && props.form === undefined) {
|
||||||
|
props.type = 'button'
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button {...props}>{children}</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
propTypes({
|
||||||
|
active: propTypes.bool,
|
||||||
|
block: propTypes.bool,
|
||||||
|
|
||||||
|
// Bootstrap button style
|
||||||
|
//
|
||||||
|
// See https://v4-alpha.getbootstrap.com/components/buttons/#examples
|
||||||
|
//
|
||||||
|
// The default value (secondary) is not listed here because it does
|
||||||
|
// not make sense to explicit it.
|
||||||
|
btnStyle: propTypes.oneOf([
|
||||||
|
'danger',
|
||||||
|
'info',
|
||||||
|
'link',
|
||||||
|
'primary',
|
||||||
|
'success',
|
||||||
|
'warning',
|
||||||
|
]),
|
||||||
|
|
||||||
|
outline: propTypes.bool,
|
||||||
|
size: propTypes.oneOf(['large', 'small']),
|
||||||
|
})(Button)
|
||||||
|
|
||||||
|
export { Button as default }
|
40
packages/xo-web/src/common/card.js
Normal file
40
packages/xo-web/src/common/card.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const CARD_STYLE = {
|
||||||
|
minHeight: '100%',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_STYLE_WITH_SHADOW = {
|
||||||
|
...CARD_STYLE,
|
||||||
|
boxShadow: '0 10px 6px -6px #777', // https://css-tricks.com/almanac/properties/b/box-shadow/
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_HEADER_STYLE = {
|
||||||
|
minHeight: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card = propTypes({
|
||||||
|
shadow: propTypes.bool,
|
||||||
|
})(({ shadow, ...props }) => {
|
||||||
|
props.className = 'card'
|
||||||
|
props.style = shadow ? CARD_STYLE_WITH_SHADOW : CARD_STYLE
|
||||||
|
|
||||||
|
return <div {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CardHeader = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
})(({ children, className }) => (
|
||||||
|
<h4 className={`card-header ${className || ''}`} style={CARD_HEADER_STYLE}>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
))
|
||||||
|
|
||||||
|
export const CardBlock = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
})(({ children, className }) => (
|
||||||
|
<div className={`card-block ${className || ''}`}>{children}</div>
|
||||||
|
))
|
9
packages/xo-web/src/common/center-panel/index.css
Normal file
9
packages/xo-web/src/common/center-panel/index.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
11
packages/xo-web/src/common/center-panel/index.js
Normal file
11
packages/xo-web/src/common/center-panel/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import styles from './index.css'
|
||||||
|
|
||||||
|
const CenterPanel = ({ children }) => (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { CenterPanel as default }
|
39
packages/xo-web/src/common/collapse.js
Normal file
39
packages/xo-web/src/common/collapse.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Button from './button'
|
||||||
|
import Component from './base-component'
|
||||||
|
import Icon from './icon'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
children: propTypes.any.isRequired,
|
||||||
|
className: propTypes.string,
|
||||||
|
buttonText: propTypes.any.isRequired,
|
||||||
|
defaultOpen: propTypes.bool,
|
||||||
|
})
|
||||||
|
export default class Collapse extends Component {
|
||||||
|
state = {
|
||||||
|
isOpened: this.props.defaultOpen,
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick = () => {
|
||||||
|
this.setState({
|
||||||
|
isOpened: !this.state.isOpened,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { props } = this
|
||||||
|
const { isOpened } = this.state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={props.className}>
|
||||||
|
<Button block btnStyle='primary' size='large' onClick={this._onClick}>
|
||||||
|
{props.buttonText}{' '}
|
||||||
|
<Icon icon={`chevron-${isOpened ? 'up' : 'down'}`} />
|
||||||
|
</Button>
|
||||||
|
{isOpened && props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
61
packages/xo-web/src/common/combobox.js
Normal file
61
packages/xo-web/src/common/combobox.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import { isEmpty, map } from 'lodash'
|
||||||
|
import { DropdownButton, MenuItem } from 'react-bootstrap-4/lib'
|
||||||
|
|
||||||
|
import Component from './base-component'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
@uncontrollableInput({
|
||||||
|
defaultValue: '',
|
||||||
|
})
|
||||||
|
@propTypes({
|
||||||
|
disabled: propTypes.bool,
|
||||||
|
options: propTypes.oneOfType([
|
||||||
|
propTypes.arrayOf(propTypes.string),
|
||||||
|
propTypes.objectOf(propTypes.string),
|
||||||
|
]),
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
value: propTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
export default class Combobox extends Component {
|
||||||
|
_handleChange = event => {
|
||||||
|
this.props.onChange(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText (value) {
|
||||||
|
this.props.onChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { options, ...props } = this.props
|
||||||
|
|
||||||
|
props.className = 'form-control'
|
||||||
|
props.onChange = this._handleChange
|
||||||
|
const Input = <input {...props} />
|
||||||
|
|
||||||
|
if (isEmpty(options)) {
|
||||||
|
return Input
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='input-group'>
|
||||||
|
<div className='input-group-btn'>
|
||||||
|
<DropdownButton
|
||||||
|
bsStyle='secondary'
|
||||||
|
disabled={props.disabled}
|
||||||
|
id='selectInput'
|
||||||
|
title=''
|
||||||
|
>
|
||||||
|
{map(options, option => (
|
||||||
|
<MenuItem key={option} onClick={() => this._setText(option)}>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownButton>
|
||||||
|
</div>
|
||||||
|
{Input}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
9
packages/xo-web/src/common/copiable/index.css
Normal file
9
packages/xo-web/src/common/copiable/index.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.container .button {
|
||||||
|
position: absolute;
|
||||||
|
margin-left: 1ex;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:hover .button {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
34
packages/xo-web/src/common/copiable/index.js
Normal file
34
packages/xo-web/src/common/copiable/index.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import React, { createElement } from 'react'
|
||||||
|
|
||||||
|
import _ from '../intl'
|
||||||
|
import Button from '../button'
|
||||||
|
import Icon from '../icon'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import Tooltip from '../tooltip'
|
||||||
|
|
||||||
|
import styles from './index.css'
|
||||||
|
|
||||||
|
const Copiable = propTypes({
|
||||||
|
data: propTypes.string,
|
||||||
|
tagName: propTypes.string,
|
||||||
|
})(({ className, tagName = 'span', ...props }) =>
|
||||||
|
createElement(
|
||||||
|
tagName,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
className: classNames(styles.container, className),
|
||||||
|
},
|
||||||
|
props.children,
|
||||||
|
' ',
|
||||||
|
<Tooltip content={_('copyToClipboard')}>
|
||||||
|
<CopyToClipboard text={props.data || props.children}>
|
||||||
|
<Button className={styles.button} size='small'>
|
||||||
|
<Icon icon='clipboard' />
|
||||||
|
</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
export { Copiable as default }
|
9
packages/xo-web/src/common/d3-utils.js
vendored
Normal file
9
packages/xo-web/src/common/d3-utils.js
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import forEach from 'lodash/forEach'
|
||||||
|
|
||||||
|
export function setStyles (style) {
|
||||||
|
forEach(style, (value, key) => {
|
||||||
|
this.style(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
60
packages/xo-web/src/common/debug.js
Normal file
60
packages/xo-web/src/common/debug.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
import { isPromise } from 'promise-toolbox'
|
||||||
|
|
||||||
|
const toString = value =>
|
||||||
|
value === undefined ? 'undefined' : JSON.stringify(value, null, 2)
|
||||||
|
|
||||||
|
// This component does not handle changes in its `promise` property.
|
||||||
|
class DebugAsync extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
promise: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
status: 'pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
props.promise.then(
|
||||||
|
value => this.setState({ status: 'resolved', value }),
|
||||||
|
value => this.setState({ status: 'rejected', value })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldComponentUpdate (_, newState) {
|
||||||
|
return this.state.status !== newState.status
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { status, value } = this.state
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <pre>{'Promise { <pending> }'}</pre>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre>
|
||||||
|
{'Promise { '}
|
||||||
|
{status === 'rejected' && '<rejected> '}
|
||||||
|
{toString(value)}
|
||||||
|
{' }'}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Debug = ({ value }) =>
|
||||||
|
isPromise(value) ? (
|
||||||
|
<DebugAsync promise={value} />
|
||||||
|
) : (
|
||||||
|
<pre>{toString(value)}</pre>
|
||||||
|
)
|
||||||
|
|
||||||
|
Debug.propTypes = {
|
||||||
|
value: PropTypes.any.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Debug as default }
|
22
packages/xo-web/src/common/dropzone/index.css
Normal file
22
packages/xo-web/src/common/dropzone/index.css
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
@value dropzoneColor: #8f8686;
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px dashed dropzoneColor;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
height: 12em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeDropzone {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzoneText {
|
||||||
|
color: dropzoneColor;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin: auto;
|
||||||
|
}
|
26
packages/xo-web/src/common/dropzone/index.js
Normal file
26
packages/xo-web/src/common/dropzone/index.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Component from 'base-component'
|
||||||
|
import propTypes from 'prop-types-decorator'
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDropzone from 'react-dropzone'
|
||||||
|
|
||||||
|
import styles from './index.css'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
onDrop: propTypes.func,
|
||||||
|
message: propTypes.node,
|
||||||
|
})
|
||||||
|
export default class Dropzone extends Component {
|
||||||
|
render () {
|
||||||
|
const { onDrop, message } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactDropzone
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={styles.dropzone}
|
||||||
|
activeClassName={styles.activeDropzone}
|
||||||
|
>
|
||||||
|
<div className={styles.dropzoneText}>{message}</div>
|
||||||
|
</ReactDropzone>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
13
packages/xo-web/src/common/editable/index.css
Normal file
13
packages/xo-web/src/common/editable/index.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.clickToEdit * {
|
||||||
|
cursor: context-menu !important;
|
||||||
|
}
|
||||||
|
.shortClick {
|
||||||
|
border-bottom: 1px dashed #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
.size {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
497
packages/xo-web/src/common/editable/index.js
Normal file
497
packages/xo-web/src/common/editable/index.js
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import findKey from 'lodash/findKey'
|
||||||
|
import isFunction from 'lodash/isFunction'
|
||||||
|
import isString from 'lodash/isString'
|
||||||
|
import map from 'lodash/map'
|
||||||
|
import pick from 'lodash/pick'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import _ from '../intl'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
import Icon from '../icon'
|
||||||
|
import logError from '../log-error'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import Tooltip from '../tooltip'
|
||||||
|
import { formatSize } from '../utils'
|
||||||
|
import { SizeInput } from '../form'
|
||||||
|
import {
|
||||||
|
SelectHost,
|
||||||
|
SelectIp,
|
||||||
|
SelectNetwork,
|
||||||
|
SelectPool,
|
||||||
|
SelectRemote,
|
||||||
|
SelectResourceSetIp,
|
||||||
|
SelectSr,
|
||||||
|
SelectSubject,
|
||||||
|
SelectTag,
|
||||||
|
SelectVgpuType,
|
||||||
|
SelectVm,
|
||||||
|
SelectVmTemplate,
|
||||||
|
} from '../select-objects'
|
||||||
|
|
||||||
|
import styles from './index.css'
|
||||||
|
|
||||||
|
const LONG_CLICK = 400
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
alt: propTypes.node.isRequired,
|
||||||
|
})
|
||||||
|
class Hover extends Component {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
hover: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onMouseEnter = () => this.setState({ hover: true })
|
||||||
|
this._onMouseLeave = () => this.setState({ hover: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
if (this.state.hover) {
|
||||||
|
return <span onMouseLeave={this._onMouseLeave}>{this.props.alt}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span onMouseEnter={this._onMouseEnter}>{this.props.children}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
onUndo: propTypes.oneOfType([propTypes.bool, propTypes.func]),
|
||||||
|
useLongClick: propTypes.bool,
|
||||||
|
value: propTypes.any.isRequired,
|
||||||
|
})
|
||||||
|
class Editable extends Component {
|
||||||
|
get value () {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeyDown = event => {
|
||||||
|
const { keyCode } = event
|
||||||
|
if (keyCode === 27) {
|
||||||
|
return this._closeEdition()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyCode === 13) {
|
||||||
|
return this._save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeEdition = () => {
|
||||||
|
this.setState({ editing: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
_openEdition = () => {
|
||||||
|
this.setState({
|
||||||
|
editing: true,
|
||||||
|
error: null,
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_undo = () => {
|
||||||
|
const { props } = this
|
||||||
|
const { onUndo } = props
|
||||||
|
if (onUndo === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.__save(
|
||||||
|
() => this.state.previous,
|
||||||
|
isFunction(onUndo) ? onUndo : props.onChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_save () {
|
||||||
|
return this.__save(() => this.value, this.props.onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
async __save (getValue, saveValue) {
|
||||||
|
const { props } = this
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = getValue()
|
||||||
|
const previous = props.value
|
||||||
|
if (value === previous) {
|
||||||
|
return this._closeEdition()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ saving: true })
|
||||||
|
|
||||||
|
await saveValue(value)
|
||||||
|
|
||||||
|
this.setState({ previous })
|
||||||
|
this._closeEdition()
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({
|
||||||
|
// `error` may be undefined if the action has been cancelled
|
||||||
|
error: error !== undefined && (isString(error) ? error : error.message),
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
logError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__startTimer = event => {
|
||||||
|
event.persist()
|
||||||
|
this._timeout = setTimeout(() => {
|
||||||
|
event.preventDefault()
|
||||||
|
this._openEdition()
|
||||||
|
}, LONG_CLICK)
|
||||||
|
}
|
||||||
|
__stopTimer = () => clearTimeout(this._timeout)
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { state, props } = this
|
||||||
|
|
||||||
|
if (!state.editing) {
|
||||||
|
const { onUndo, previous } = state
|
||||||
|
const { useLongClick } = props
|
||||||
|
|
||||||
|
const success = <Icon icon='success' />
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
styles.clickToEdit,
|
||||||
|
!useLongClick && styles.shortClick
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
onClick={useLongClick ? undefined : this._openEdition}
|
||||||
|
onMouseDown={useLongClick ? this.__startTimer : undefined}
|
||||||
|
onMouseUp={useLongClick ? this.__stopTimer : undefined}
|
||||||
|
>
|
||||||
|
{this._renderDisplay()}
|
||||||
|
</span>
|
||||||
|
{previous != null &&
|
||||||
|
(onUndo !== false ? (
|
||||||
|
<Hover
|
||||||
|
alt={
|
||||||
|
<a onClick={this._undo}>
|
||||||
|
<Icon icon='undo' />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</Hover>
|
||||||
|
) : (
|
||||||
|
success
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, saving } = state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{this._renderEdition()}
|
||||||
|
{saving && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
<Icon icon='loading' />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{error != null && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
<Tooltip content={error}>
|
||||||
|
<Icon icon='error' />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
autoComplete: propTypes.string,
|
||||||
|
maxLength: propTypes.number,
|
||||||
|
minLength: propTypes.number,
|
||||||
|
pattern: propTypes.string,
|
||||||
|
value: propTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
export class Text extends Editable {
|
||||||
|
get value () {
|
||||||
|
const { input } = this.refs
|
||||||
|
|
||||||
|
// FIXME: should be properly forwarded to the user.
|
||||||
|
const error = input.validationMessage
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.value
|
||||||
|
}
|
||||||
|
|
||||||
|
_onInput = ({ target }) => {
|
||||||
|
target.style.width = `${target.value.length + 1}ex`
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDisplay () {
|
||||||
|
const { children, value } = this.props
|
||||||
|
|
||||||
|
if (children || value) {
|
||||||
|
return <span> {children || value} </span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { placeholder, useLongClick } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className='text-muted'>
|
||||||
|
{placeholder ||
|
||||||
|
(useLongClick
|
||||||
|
? _('editableLongClickPlaceholder')
|
||||||
|
: _('editableClickPlaceholder'))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderEdition () {
|
||||||
|
const { value } = this.props
|
||||||
|
const { saving } = this.state
|
||||||
|
|
||||||
|
// Optional props that the user may set on the input.
|
||||||
|
const extraProps = pick(this.props, [
|
||||||
|
'autoComplete',
|
||||||
|
'maxLength',
|
||||||
|
'minLength',
|
||||||
|
'pattern',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...extraProps}
|
||||||
|
autoFocus
|
||||||
|
defaultValue={value}
|
||||||
|
onBlur={this._closeEdition}
|
||||||
|
onInput={this._onInput}
|
||||||
|
onKeyDown={this._onKeyDown}
|
||||||
|
readOnly={saving}
|
||||||
|
ref='input'
|
||||||
|
style={{
|
||||||
|
width: `${value.length + 1}ex`,
|
||||||
|
maxWidth: '50ex',
|
||||||
|
}}
|
||||||
|
type={this._isPassword ? 'password' : 'text'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Password extends Text {
|
||||||
|
// TODO: this is a hack, this class should probably have a better
|
||||||
|
// implementation.
|
||||||
|
_isPassword = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
nullable: propTypes.bool,
|
||||||
|
value: propTypes.number,
|
||||||
|
})
|
||||||
|
export class Number extends Component {
|
||||||
|
get value () {
|
||||||
|
return +this.refs.input.value
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = value => {
|
||||||
|
if (value === '') {
|
||||||
|
if (this.props.nullable) {
|
||||||
|
value = null
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = +value
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.onChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { value } = this.props
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
{...this.props}
|
||||||
|
onChange={this._onChange}
|
||||||
|
value={value === null ? '' : String(value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
options: propTypes.oneOfType([propTypes.array, propTypes.object]).isRequired,
|
||||||
|
renderer: propTypes.func,
|
||||||
|
})
|
||||||
|
export class Select extends Editable {
|
||||||
|
componentWillReceiveProps (props) {
|
||||||
|
if (
|
||||||
|
props.value !== this.props.value ||
|
||||||
|
props.options !== this.props.options
|
||||||
|
) {
|
||||||
|
this.setState({
|
||||||
|
valueKey: findKey(props.options, option => option === props.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value () {
|
||||||
|
return this.props.options[this.state.valueKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = event => {
|
||||||
|
this.setState({ valueKey: getEventValue(event) }, this._save)
|
||||||
|
}
|
||||||
|
|
||||||
|
_optionToJsx = (option, key) => {
|
||||||
|
const { renderer } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{renderer ? renderer(option) : option}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onEditionMount = ref => {
|
||||||
|
// Seems to work in Google Chrome (not in Firefox)
|
||||||
|
ref && ref.dispatchEvent(new window.MouseEvent('mousedown'))
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDisplay () {
|
||||||
|
const { children, renderer, value } = this.props
|
||||||
|
|
||||||
|
return children || <span>{renderer ? renderer(value) : value}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderEdition () {
|
||||||
|
const { saving, valueKey } = this.state
|
||||||
|
const { options } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
autoFocus
|
||||||
|
className={classNames('form-control', styles.select)}
|
||||||
|
onBlur={this._closeEdition}
|
||||||
|
onChange={this._onChange}
|
||||||
|
onKeyDown={this._onKeyDown}
|
||||||
|
readOnly={saving}
|
||||||
|
ref={this._onEditionMount}
|
||||||
|
value={valueKey}
|
||||||
|
>
|
||||||
|
{map(options, this._optionToJsx)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAP_TYPE_SELECT = {
|
||||||
|
host: SelectHost,
|
||||||
|
ip: SelectIp,
|
||||||
|
network: SelectNetwork,
|
||||||
|
pool: SelectPool,
|
||||||
|
remote: SelectRemote,
|
||||||
|
resourceSetIp: SelectResourceSetIp,
|
||||||
|
SR: SelectSr,
|
||||||
|
subject: SelectSubject,
|
||||||
|
tag: SelectTag,
|
||||||
|
vgpuType: SelectVgpuType,
|
||||||
|
VM: SelectVm,
|
||||||
|
'VM-template': SelectVmTemplate,
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
value: propTypes.oneOfType([propTypes.string, propTypes.object]),
|
||||||
|
})
|
||||||
|
export class XoSelect extends Editable {
|
||||||
|
get value () {
|
||||||
|
return this.state.value
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDisplay () {
|
||||||
|
return (
|
||||||
|
this.props.children || (
|
||||||
|
<span>{this.props.value[this.props.labelProp]}</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = object => this.setState({ value: object }, object && this._save)
|
||||||
|
|
||||||
|
_renderEdition () {
|
||||||
|
const { saving, xoType, ...props } = this.props
|
||||||
|
|
||||||
|
const Select = MAP_TYPE_SELECT[xoType]
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
if (!Select) {
|
||||||
|
throw new Error(`${xoType} is not a valid XoSelect type.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor is needed so that the BlockLink does not trigger a redirection
|
||||||
|
// when this element is clicked.
|
||||||
|
return (
|
||||||
|
<a onBlur={this._closeEdition}>
|
||||||
|
<Select
|
||||||
|
{...props}
|
||||||
|
autoFocus
|
||||||
|
disabled={saving}
|
||||||
|
onChange={this._onChange}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
value: propTypes.number.isRequired,
|
||||||
|
})
|
||||||
|
export class Size extends Editable {
|
||||||
|
get value () {
|
||||||
|
return this.refs.input.value
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDisplay () {
|
||||||
|
return this.props.children || formatSize(this.props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeEditionIfUnfocused = () => {
|
||||||
|
this._focused = false
|
||||||
|
setTimeout(() => {
|
||||||
|
!this._focused && this._closeEdition()
|
||||||
|
}, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
_focus = () => {
|
||||||
|
this._focused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderEdition () {
|
||||||
|
const { saving } = this.state
|
||||||
|
const { value } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
// SizeInput uses `input-group` which makes it behave as a block element (display: table).
|
||||||
|
// `form-inline` to use it as an inline element
|
||||||
|
className='form-inline'
|
||||||
|
onBlur={this._closeEditionIfUnfocused}
|
||||||
|
onFocus={this._focus}
|
||||||
|
onKeyDown={this._onKeyDown}
|
||||||
|
>
|
||||||
|
<SizeInput
|
||||||
|
autoFocus
|
||||||
|
className={styles.size}
|
||||||
|
ref='input'
|
||||||
|
readOnly={saving}
|
||||||
|
defaultValue={value}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
24
packages/xo-web/src/common/ellipsis.js
Normal file
24
packages/xo-web/src/common/ellipsis.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const ellipsisStyle = {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ellipsisContainerStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ellipsis = ({ children }) => <span style={ellipsisStyle}>{children}</span>
|
||||||
|
export { Ellipsis as default }
|
||||||
|
|
||||||
|
export const EllipsisContainer = ({ children }) => (
|
||||||
|
<div style={ellipsisContainerStyle}>
|
||||||
|
{React.Children.map(
|
||||||
|
children,
|
||||||
|
child =>
|
||||||
|
child == null || child.type === Ellipsis ? child : <span>{child}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
11
packages/xo-web/src/common/fetch.js
Normal file
11
packages/xo-web/src/common/fetch.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'whatwg-fetch'
|
||||||
|
|
||||||
|
const { fetch } = window
|
||||||
|
export { fetch as default }
|
||||||
|
|
||||||
|
export const post = (url, body, opts) =>
|
||||||
|
fetch(url, {
|
||||||
|
...opts,
|
||||||
|
body,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
37
packages/xo-web/src/common/filter-reduce.js
Normal file
37
packages/xo-web/src/common/filter-reduce.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import findIndex from 'lodash/findIndex'
|
||||||
|
import identity from 'lodash/identity'
|
||||||
|
|
||||||
|
// Returns a copy of the array containing:
|
||||||
|
// - the elements which did not matches the predicate
|
||||||
|
// - the result of the reduction of the elements matching the
|
||||||
|
// predicates
|
||||||
|
//
|
||||||
|
// As a special case, if the predicate is not provided, it is
|
||||||
|
// considered to have not matched.
|
||||||
|
const filterReduce = (array, predicate, reducer, initial) => {
|
||||||
|
const { length } = array
|
||||||
|
let i
|
||||||
|
if (!length || !predicate || (i = findIndex(array, predicate)) === -1) {
|
||||||
|
return initial == null ? array.slice(0) : array.concat(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reducer == null) {
|
||||||
|
reducer = identity
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = array.slice(0, i)
|
||||||
|
let value = initial == null ? array[i] : reducer(initial, array[i], i, array)
|
||||||
|
|
||||||
|
for (i = i + 1; i < length; ++i) {
|
||||||
|
const current = array[i]
|
||||||
|
if (predicate(current, i, array)) {
|
||||||
|
value = reducer(value, current, i, array)
|
||||||
|
} else {
|
||||||
|
result.push(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(value)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
export { filterReduce as default }
|
19
packages/xo-web/src/common/filter-reduce.spec.js
Normal file
19
packages/xo-web/src/common/filter-reduce.spec.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
import filterReduce from './filter-reduce'
|
||||||
|
|
||||||
|
const add = (a, b) => a + b
|
||||||
|
const data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||||
|
const isEven = x => !(x & 1)
|
||||||
|
|
||||||
|
it('filterReduce', () => {
|
||||||
|
// Returns all elements not matching the predicate and the result of
|
||||||
|
// a reduction over those who do.
|
||||||
|
expect(filterReduce(data, isEven, add)).toEqual([1, 3, 5, 7, 9, 20])
|
||||||
|
|
||||||
|
// The default reducer is the identity.
|
||||||
|
expect(filterReduce(data, isEven)).toEqual([1, 3, 5, 7, 9, 0])
|
||||||
|
|
||||||
|
// If an initial value is passed it is used.
|
||||||
|
expect(filterReduce(data, isEven, add, 22)).toEqual([1, 3, 5, 7, 9, 42])
|
||||||
|
})
|
18
packages/xo-web/src/common/form-grid.js
Normal file
18
packages/xo-web/src/common/form-grid.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as Grid from './grid'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
export const LabelCol = propTypes({
|
||||||
|
children: propTypes.any.isRequired,
|
||||||
|
})(({ children }) => (
|
||||||
|
<label className='col-md-2 form-control-label'>{children}</label>
|
||||||
|
))
|
||||||
|
|
||||||
|
export const InputCol = propTypes({
|
||||||
|
children: propTypes.any.isRequired,
|
||||||
|
})(({ children }) => <Grid.Col mediumSize={10}>{children}</Grid.Col>)
|
||||||
|
|
||||||
|
export const Row = propTypes({
|
||||||
|
children: propTypes.arrayOf(propTypes.element).isRequired,
|
||||||
|
})(({ children }) => <Grid.Row className='form-group'>{children}</Grid.Row>)
|
304
packages/xo-web/src/common/form/index.js
Normal file
304
packages/xo-web/src/common/form/index.js
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import BaseComponent from 'base-component'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import Icon from 'icon'
|
||||||
|
import map from 'lodash/map'
|
||||||
|
import randomPassword from 'random-password'
|
||||||
|
import React from 'react'
|
||||||
|
import round from 'lodash/round'
|
||||||
|
import SingleLineRow from 'single-line-row'
|
||||||
|
import { Container, Col } from 'grid'
|
||||||
|
import { DropdownButton, MenuItem } from 'react-bootstrap-4/lib'
|
||||||
|
|
||||||
|
import Button from '../button'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import defined from '../xo-defined'
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import { formatSizeRaw, parseSize } from '../utils'
|
||||||
|
|
||||||
|
export Select from './select'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
enableGenerator: propTypes.bool,
|
||||||
|
})
|
||||||
|
export class Password extends Component {
|
||||||
|
get value () {
|
||||||
|
return this.refs.field.value
|
||||||
|
}
|
||||||
|
|
||||||
|
set value (value) {
|
||||||
|
this.refs.field.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
_generate = () => {
|
||||||
|
const value = randomPassword(8)
|
||||||
|
const isControlled = this.props.value !== undefined
|
||||||
|
if (isControlled) {
|
||||||
|
this.props.onChange(value)
|
||||||
|
} else {
|
||||||
|
this.refs.field.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: in controlled mode, visibility should only be updated
|
||||||
|
// when the value prop is changed according to the emitted value.
|
||||||
|
this.setState({
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleVisibility = () => {
|
||||||
|
this.setState({
|
||||||
|
visible: !this.state.visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { className, enableGenerator = false, ...props } = this.props
|
||||||
|
const { visible } = this.state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='input-group'>
|
||||||
|
{enableGenerator && (
|
||||||
|
<span className='input-group-btn'>
|
||||||
|
<Button onClick={this._generate}>
|
||||||
|
<Icon icon='password' />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className={classNames(className, 'form-control')}
|
||||||
|
ref='field'
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
/>
|
||||||
|
<span className='input-group-btn'>
|
||||||
|
<Button onClick={this._toggleVisibility}>
|
||||||
|
<Icon icon={visible ? 'shown' : 'hidden'} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
max: propTypes.number.isRequired,
|
||||||
|
min: propTypes.number.isRequired,
|
||||||
|
onChange: propTypes.func,
|
||||||
|
step: propTypes.number,
|
||||||
|
value: propTypes.number,
|
||||||
|
})
|
||||||
|
export class Range extends Component {
|
||||||
|
componentDidMount () {
|
||||||
|
const { min, onChange, value } = this.props
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
onChange && onChange(min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = value => this.props.onChange(getEventValue(value))
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { max, min, step, value } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<SingleLineRow>
|
||||||
|
<Col size={2}>
|
||||||
|
<span className='pull-right'>{value}</span>
|
||||||
|
</Col>
|
||||||
|
<Col size={10}>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
onChange={this._onChange}
|
||||||
|
step={step}
|
||||||
|
type='range'
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</SingleLineRow>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export Toggle from './toggle'
|
||||||
|
|
||||||
|
const UNITS = ['kiB', 'MiB', 'GiB']
|
||||||
|
const DEFAULT_UNIT = 'GiB'
|
||||||
|
@propTypes({
|
||||||
|
autoFocus: propTypes.bool,
|
||||||
|
className: propTypes.string,
|
||||||
|
defaultUnit: propTypes.oneOf(UNITS),
|
||||||
|
defaultValue: propTypes.number,
|
||||||
|
onChange: propTypes.func,
|
||||||
|
placeholder: propTypes.string,
|
||||||
|
readOnly: propTypes.bool,
|
||||||
|
required: propTypes.bool,
|
||||||
|
style: propTypes.object,
|
||||||
|
value: propTypes.oneOfType([propTypes.number, propTypes.oneOf([null])]),
|
||||||
|
})
|
||||||
|
export class SizeInput extends BaseComponent {
|
||||||
|
constructor (props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.state = this._createStateFromBytes(
|
||||||
|
defined(props.value, props.defaultValue, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (props) {
|
||||||
|
const { value } = props
|
||||||
|
if (value !== undefined && value !== this.props.value) {
|
||||||
|
this.setState(this._createStateFromBytes(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createStateFromBytes (bytes) {
|
||||||
|
if (bytes === this._bytes) {
|
||||||
|
return {
|
||||||
|
input: this._input,
|
||||||
|
unit: this._unit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes === null) {
|
||||||
|
return {
|
||||||
|
input: '',
|
||||||
|
unit: this.props.defaultUnit || DEFAULT_UNIT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prefix, value } = formatSizeRaw(bytes)
|
||||||
|
return {
|
||||||
|
input: String(round(value, 2)),
|
||||||
|
unit: `${prefix}B`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value () {
|
||||||
|
const { input, unit } = this.state
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseSize(`${+input} ${unit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
set value (value) {
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV !== 'production' &&
|
||||||
|
this.props.value !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error('cannot set value of controlled SizeInput')
|
||||||
|
}
|
||||||
|
this.setState(this._createStateFromBytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange (input, unit) {
|
||||||
|
const { onChange } = this.props
|
||||||
|
|
||||||
|
// Empty input equals null.
|
||||||
|
const bytes = input ? parseSize(`${+input} ${unit}`) : null
|
||||||
|
|
||||||
|
const isControlled = this.props.value !== undefined
|
||||||
|
if (isControlled) {
|
||||||
|
// Store input and unit for this change to update correctly on new
|
||||||
|
// props.
|
||||||
|
this._bytes = bytes
|
||||||
|
this._input = input
|
||||||
|
this._unit = unit
|
||||||
|
} else {
|
||||||
|
this.setState({ input, unit })
|
||||||
|
|
||||||
|
// onChange is optional in uncontrolled mode.
|
||||||
|
if (!onChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateNumber = event => {
|
||||||
|
const input = event.target.value
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
return this._onChange(input, this.state.unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = +input
|
||||||
|
|
||||||
|
if (Number.isNaN(number)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same numeric value: simply update the input.
|
||||||
|
const prevInput = this.state.input
|
||||||
|
if (prevInput && +prevInput === number) {
|
||||||
|
return this.setState({ input })
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onChange(input, this.state.unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateUnit = unit => {
|
||||||
|
const { input } = this.state
|
||||||
|
|
||||||
|
// 0 is always 0, no matter the unit.
|
||||||
|
if (+input) {
|
||||||
|
this._onChange(input, unit)
|
||||||
|
} else {
|
||||||
|
this.setState({ unit })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
autoFocus,
|
||||||
|
className,
|
||||||
|
readOnly,
|
||||||
|
placeholder,
|
||||||
|
required,
|
||||||
|
style,
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classNames('input-group', className)} style={style}>
|
||||||
|
<input
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className='form-control'
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={this._updateNumber}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
type='text'
|
||||||
|
value={this.state.input}
|
||||||
|
/>
|
||||||
|
<span className='input-group-btn'>
|
||||||
|
<DropdownButton
|
||||||
|
bsStyle='secondary'
|
||||||
|
id='size'
|
||||||
|
pullRight
|
||||||
|
disabled={readOnly}
|
||||||
|
title={this.state.unit}
|
||||||
|
>
|
||||||
|
{map(UNITS, unit => (
|
||||||
|
<MenuItem key={unit} onClick={() => this._updateUnit(unit)}>
|
||||||
|
{unit}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownButton>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
177
packages/xo-web/src/common/form/select.js
Normal file
177
packages/xo-web/src/common/form/select.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import isEmpty from 'lodash/isEmpty'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React from 'react'
|
||||||
|
import ReactSelect from 'react-select'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import {
|
||||||
|
AutoSizer,
|
||||||
|
CellMeasurer,
|
||||||
|
CellMeasurerCache,
|
||||||
|
List,
|
||||||
|
} from 'react-virtualized'
|
||||||
|
|
||||||
|
const SELECT_STYLE = {
|
||||||
|
minWidth: '10em',
|
||||||
|
}
|
||||||
|
const MENU_STYLE = {
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class Select extends React.PureComponent {
|
||||||
|
static defaultProps = {
|
||||||
|
maxHeight: 200,
|
||||||
|
|
||||||
|
multi: ReactSelect.defaultProps.multi,
|
||||||
|
options: [],
|
||||||
|
required: ReactSelect.defaultProps.required,
|
||||||
|
simpleValue: ReactSelect.defaultProps.simpleValue,
|
||||||
|
valueKey: ReactSelect.defaultProps.valueKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
autoSelectSingleOption: PropTypes.bool, // default to props.required
|
||||||
|
maxHeight: PropTypes.number,
|
||||||
|
options: PropTypes.array.isRequired, // cannot be an object
|
||||||
|
}
|
||||||
|
|
||||||
|
_cellMeasurerCache = new CellMeasurerCache({
|
||||||
|
fixedWidth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/JedWatson/react-select/blob/dd32c27d7ea338a93159da5e40bc06697d0d86f9/src/utils/defaultMenuRenderer.js#L4
|
||||||
|
_renderMenu (opts) {
|
||||||
|
const { focusOption, options, selectValue } = opts
|
||||||
|
|
||||||
|
const focusFromEvent = event =>
|
||||||
|
focusOption(options[event.currentTarget.dataset.index])
|
||||||
|
const selectFromEvent = event =>
|
||||||
|
selectValue(options[event.currentTarget.dataset.index])
|
||||||
|
const renderRow = opts2 =>
|
||||||
|
this._renderRow(opts, opts2, focusFromEvent, selectFromEvent)
|
||||||
|
|
||||||
|
let focusedOptionIndex = options.indexOf(opts.focusedOption)
|
||||||
|
if (focusedOptionIndex === -1) {
|
||||||
|
focusedOptionIndex = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const { length } = options
|
||||||
|
const { maxHeight } = this.props
|
||||||
|
const { rowHeight } = this._cellMeasurerCache
|
||||||
|
|
||||||
|
let height = 0
|
||||||
|
for (let i = 0; i < length; ++i) {
|
||||||
|
height += rowHeight({ index: i })
|
||||||
|
if (height > maxHeight) {
|
||||||
|
height = maxHeight
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoSizer disableHeight>
|
||||||
|
{({ width }) => (
|
||||||
|
<List
|
||||||
|
deferredMeasurementCache={this._cellMeasurerCache}
|
||||||
|
height={height}
|
||||||
|
rowCount={length}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
rowRenderer={renderRow}
|
||||||
|
scrollToIndex={focusedOptionIndex}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_renderMenu = this._renderMenu.bind(this)
|
||||||
|
|
||||||
|
_renderRow (
|
||||||
|
{
|
||||||
|
focusedOption,
|
||||||
|
focusOption,
|
||||||
|
inputValue,
|
||||||
|
optionClassName,
|
||||||
|
optionRenderer,
|
||||||
|
options,
|
||||||
|
selectValue,
|
||||||
|
},
|
||||||
|
{ index, key, parent, style },
|
||||||
|
focusFromEvent,
|
||||||
|
selectFromEvent
|
||||||
|
) {
|
||||||
|
const option = options[index]
|
||||||
|
const { disabled } = option
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CellMeasurer
|
||||||
|
cache={this._cellMeasurerCache}
|
||||||
|
columnIndex={0}
|
||||||
|
key={key}
|
||||||
|
parent={parent}
|
||||||
|
rowIndex={index}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames('Select-option', optionClassName, {
|
||||||
|
'is-disabled': disabled,
|
||||||
|
'is-focused': option === focusedOption,
|
||||||
|
})}
|
||||||
|
data-index={index}
|
||||||
|
onClick={disabled ? undefined : selectFromEvent}
|
||||||
|
onMouseEnter={disabled ? undefined : focusFromEvent}
|
||||||
|
style={style}
|
||||||
|
title={option.title}
|
||||||
|
>
|
||||||
|
{optionRenderer(option, index, inputValue)}
|
||||||
|
</div>
|
||||||
|
</CellMeasurer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this.componentDidUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
const { props } = this
|
||||||
|
const {
|
||||||
|
autoSelectSingleOption = props.required,
|
||||||
|
multi,
|
||||||
|
options,
|
||||||
|
simpleValue,
|
||||||
|
value,
|
||||||
|
} = props
|
||||||
|
if (
|
||||||
|
autoSelectSingleOption &&
|
||||||
|
options != null &&
|
||||||
|
options.length === 1 &&
|
||||||
|
(value == null ||
|
||||||
|
(simpleValue && value === '') ||
|
||||||
|
(multi && value.length === 0))
|
||||||
|
) {
|
||||||
|
const option = options[0]
|
||||||
|
props.onChange(
|
||||||
|
simpleValue ? option[props.valueKey] : multi ? [option] : option
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { props } = this
|
||||||
|
const { multi } = props
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
backspaceToRemoveMessage=''
|
||||||
|
clearable={multi || !props.required}
|
||||||
|
closeOnSelect={!multi}
|
||||||
|
isLoading={!props.disabled && isEmpty(props.options)}
|
||||||
|
style={SELECT_STYLE}
|
||||||
|
valueRenderer={props.optionRenderer}
|
||||||
|
{...props}
|
||||||
|
menuRenderer={this._renderMenu}
|
||||||
|
menuStyle={MENU_STYLE}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
46
packages/xo-web/src/common/form/toggle.js
Normal file
46
packages/xo-web/src/common/form/toggle.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
|
||||||
|
import Component from '../base-component'
|
||||||
|
import Icon from '../icon'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
@propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
icon: propTypes.string,
|
||||||
|
iconOn: propTypes.string,
|
||||||
|
iconOff: propTypes.string,
|
||||||
|
iconSize: propTypes.number,
|
||||||
|
value: propTypes.bool.isRequired,
|
||||||
|
})
|
||||||
|
export default class Toggle extends Component {
|
||||||
|
static defaultProps = {
|
||||||
|
iconOn: 'toggle-on',
|
||||||
|
iconOff: 'toggle-off',
|
||||||
|
iconSize: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggle = () => {
|
||||||
|
const { props } = this
|
||||||
|
props.onChange(!props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { props } = this
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={classNames(
|
||||||
|
props.disabled ? 'text-muted' : props.value ? 'text-success' : null,
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
icon={props.icon || (props.value ? props.iconOn : props.iconOff)}
|
||||||
|
onClick={this._toggle}
|
||||||
|
size={props.iconSize}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
15
packages/xo-web/src/common/get-event-value.js
Normal file
15
packages/xo-web/src/common/get-event-value.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// If the param is an event, returns the value of it's target,
|
||||||
|
// otherwise returns the param.
|
||||||
|
const getEventValue = event => {
|
||||||
|
let target
|
||||||
|
if (!event || !(target = event.target)) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.nodeName.toLowerCase() === 'input' &&
|
||||||
|
target.type.toLowerCase() === 'checkbox'
|
||||||
|
? target.checked
|
||||||
|
: target.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getEventValue as default }
|
61
packages/xo-web/src/common/grid.js
Normal file
61
packages/xo-web/src/common/grid.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
// A column can contain content or a row.
|
||||||
|
export const Col = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
size: propTypes.number,
|
||||||
|
smallSize: propTypes.number,
|
||||||
|
mediumSize: propTypes.number,
|
||||||
|
largeSize: propTypes.number,
|
||||||
|
offset: propTypes.number,
|
||||||
|
smallOffset: propTypes.number,
|
||||||
|
mediumOffset: propTypes.number,
|
||||||
|
largeOffset: propTypes.number,
|
||||||
|
})(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size = 12,
|
||||||
|
smallSize = size,
|
||||||
|
mediumSize,
|
||||||
|
largeSize,
|
||||||
|
offset,
|
||||||
|
smallOffset = offset,
|
||||||
|
mediumOffset,
|
||||||
|
largeOffset,
|
||||||
|
style,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
smallSize && `col-xs-${smallSize}`,
|
||||||
|
mediumSize && `col-md-${mediumSize}`,
|
||||||
|
largeSize && `col-lg-${largeSize}`,
|
||||||
|
smallOffset && `offset-xs-${smallOffset}`,
|
||||||
|
mediumOffset && `offset-md-${mediumOffset}`,
|
||||||
|
largeOffset && `offset-lg-${largeOffset}`
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is the root component of the grid layout, containers should not be
|
||||||
|
// nested.
|
||||||
|
export const Container = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
})(({ children, className }) => (
|
||||||
|
<div className={classNames(className, 'container-fluid')}>{children}</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
// Only columns can be children of a row.
|
||||||
|
export const Row = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
})(({ children, className }) => (
|
||||||
|
<div className={`${className || ''} row`}>{children}</div>
|
||||||
|
))
|
13
packages/xo-web/src/common/grid.spec.js
Normal file
13
packages/xo-web/src/common/grid.spec.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { forEach } from 'lodash'
|
||||||
|
import { shallow } from 'enzyme'
|
||||||
|
|
||||||
|
import * as grid from './grid'
|
||||||
|
|
||||||
|
forEach(grid, (Component, name) => {
|
||||||
|
it(name, () => {
|
||||||
|
expect(shallow(<Component />)).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
33
packages/xo-web/src/common/home-filters.js
Normal file
33
packages/xo-web/src/common/home-filters.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const common = {
|
||||||
|
homeFilterNone: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VM = {
|
||||||
|
...common,
|
||||||
|
homeFilterPendingVms: 'current_operations:"" ',
|
||||||
|
homeFilterNonRunningVms: '!power_state:running ',
|
||||||
|
homeFilterHvmGuests: 'virtualizationMode:hvm ',
|
||||||
|
homeFilterRunningVms: 'power_state:running ',
|
||||||
|
homeFilterTags: 'tags:',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const host = {
|
||||||
|
...common,
|
||||||
|
homeFilterRunningHosts: 'power_state:running ',
|
||||||
|
homeFilterTags: 'tags:',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pool = {
|
||||||
|
...common,
|
||||||
|
homeFilterTags: 'tags:',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vmTemplate = {
|
||||||
|
...common,
|
||||||
|
homeFilterTags: 'tags:',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SR = {
|
||||||
|
...common,
|
||||||
|
homeFilterTags: 'tags:',
|
||||||
|
}
|
40
packages/xo-web/src/common/home-tags.js
Normal file
40
packages/xo-web/src/common/home-tags.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import * as CM from 'complex-matcher'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Component from './base-component'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import Tags from './tags'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
labels: propTypes.arrayOf(React.PropTypes.string).isRequired,
|
||||||
|
onAdd: propTypes.func,
|
||||||
|
onChange: propTypes.func,
|
||||||
|
onDelete: propTypes.func,
|
||||||
|
type: propTypes.string,
|
||||||
|
})
|
||||||
|
export default class HomeTags extends Component {
|
||||||
|
static contextTypes = {
|
||||||
|
router: React.PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick = label => {
|
||||||
|
const s = encodeURIComponent(
|
||||||
|
new CM.Property('tags', new CM.String(label)).toString()
|
||||||
|
)
|
||||||
|
const t = encodeURIComponent(this.props.type)
|
||||||
|
|
||||||
|
this.context.router.push(`/home?t=${t}&s=${s}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Tags
|
||||||
|
labels={this.props.labels}
|
||||||
|
onAdd={this.props.onAdd}
|
||||||
|
onChange={this.props.onChange}
|
||||||
|
onClick={this._onClick}
|
||||||
|
onDelete={this.props.onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
232
packages/xo-web/src/common/hosts-patches-table.js
Normal file
232
packages/xo-web/src/common/hosts-patches-table.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Portal } from 'react-overlays'
|
||||||
|
import { forEach, isEmpty, keys, map, noop } from 'lodash'
|
||||||
|
|
||||||
|
import _ from './intl'
|
||||||
|
import ActionButton from './action-button'
|
||||||
|
import Component from './base-component'
|
||||||
|
import Link from './link'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import SortedTable from './sorted-table'
|
||||||
|
import TabButton from './tab-button'
|
||||||
|
import { connectStore } from './utils'
|
||||||
|
import {
|
||||||
|
createGetObjectsOfType,
|
||||||
|
createFilter,
|
||||||
|
createSelector,
|
||||||
|
} from './selectors'
|
||||||
|
import {
|
||||||
|
installAllHostPatches,
|
||||||
|
installAllPatchesOnPool,
|
||||||
|
subscribeHostMissingPatches,
|
||||||
|
} from './xo'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const MISSING_PATCHES_COLUMNS = [
|
||||||
|
{
|
||||||
|
name: _('srHost'),
|
||||||
|
itemRenderer: host => (
|
||||||
|
<Link to={`/hosts/${host.id}`}>{host.name_label}</Link>
|
||||||
|
),
|
||||||
|
sortCriteria: host => host.name_label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: _('hostDescription'),
|
||||||
|
itemRenderer: host => host.name_description,
|
||||||
|
sortCriteria: host => host.name_description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: _('hostMissingPatches'),
|
||||||
|
itemRenderer: (host, { missingPatches }) => (
|
||||||
|
<Link to={`/hosts/${host.id}/patches`}>{missingPatches[host.id]}</Link>
|
||||||
|
),
|
||||||
|
sortCriteria: (host, { missingPatches }) => missingPatches[host.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: _('patchUpdateButton'),
|
||||||
|
itemRenderer: (host, { installAllHostPatches }) => (
|
||||||
|
<ActionButton
|
||||||
|
btnStyle='primary'
|
||||||
|
handler={installAllHostPatches}
|
||||||
|
handlerParam={host}
|
||||||
|
icon='host-patch-update'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const POOLS_MISSING_PATCHES_COLUMNS = [
|
||||||
|
{
|
||||||
|
name: _('srPool'),
|
||||||
|
itemRenderer: (host, { pools }) => {
|
||||||
|
const pool = pools[host.$pool]
|
||||||
|
return <Link to={`/pools/${pool.id}`}>{pool.name_label}</Link>
|
||||||
|
},
|
||||||
|
sortCriteria: (host, { pools }) => pools[host.$pool].name_label,
|
||||||
|
},
|
||||||
|
].concat(MISSING_PATCHES_COLUMNS)
|
||||||
|
|
||||||
|
// Small component to homogenize Button usage in HostsPatchesTable
|
||||||
|
const ActionButton_ = ({ children, labelId, ...props }) => (
|
||||||
|
<ActionButton {...props} tooltip={_(labelId)}>
|
||||||
|
{children}
|
||||||
|
</ActionButton>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@connectStore({
|
||||||
|
hostsById: createGetObjectsOfType('host').groupBy('id'),
|
||||||
|
})
|
||||||
|
class HostsPatchesTable extends Component {
|
||||||
|
constructor (props) {
|
||||||
|
super(props)
|
||||||
|
this.state.missingPatches = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getHosts = createFilter(
|
||||||
|
() => this.props.hosts,
|
||||||
|
createSelector(
|
||||||
|
() => this.state.missingPatches,
|
||||||
|
missingPatches => host => missingPatches[host.id]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_subscribeMissingPatches = (hosts = this.props.hosts) => {
|
||||||
|
const { hostsById } = this.props
|
||||||
|
|
||||||
|
const unsubs = map(
|
||||||
|
hosts,
|
||||||
|
host =>
|
||||||
|
hostsById
|
||||||
|
? subscribeHostMissingPatches(hostsById[host.id][0], patches =>
|
||||||
|
this.setState({
|
||||||
|
missingPatches: {
|
||||||
|
...this.state.missingPatches,
|
||||||
|
[host.id]: patches.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: noop
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.unsubscribeMissingPatches !== undefined) {
|
||||||
|
this.unsubscribeMissingPatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unsubscribeMissingPatches = () => forEach(unsubs, unsub => unsub())
|
||||||
|
}
|
||||||
|
|
||||||
|
_installAllMissingPatches = () => {
|
||||||
|
const pools = {}
|
||||||
|
forEach(this._getHosts(), host => {
|
||||||
|
pools[host.$pool] = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(map(keys(pools), installAllPatchesOnPool))
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
// Force one Portal refresh.
|
||||||
|
// Because Portal cannot see the container reference at first rendering.
|
||||||
|
this.forceUpdate()
|
||||||
|
this._subscribeMissingPatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (nextProps) {
|
||||||
|
if (nextProps.hosts !== this.props.hosts) {
|
||||||
|
this._subscribeMissingPatches(nextProps.hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
this.unsubscribeMissingPatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
buttonsGroupContainer,
|
||||||
|
container,
|
||||||
|
displayPools,
|
||||||
|
pools,
|
||||||
|
useTabButton,
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
const hosts = this._getHosts()
|
||||||
|
const noPatches = isEmpty(hosts)
|
||||||
|
|
||||||
|
const Container = container || 'div'
|
||||||
|
|
||||||
|
const Button = useTabButton ? TabButton : ActionButton_
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!noPatches ? (
|
||||||
|
<SortedTable
|
||||||
|
collection={hosts}
|
||||||
|
columns={
|
||||||
|
displayPools
|
||||||
|
? POOLS_MISSING_PATCHES_COLUMNS
|
||||||
|
: MISSING_PATCHES_COLUMNS
|
||||||
|
}
|
||||||
|
userData={{
|
||||||
|
installAllHostPatches,
|
||||||
|
missingPatches: this.state.missingPatches,
|
||||||
|
pools,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p>{_('patchNothing')}</p>
|
||||||
|
)}
|
||||||
|
<Portal container={() => buttonsGroupContainer()}>
|
||||||
|
<Container>
|
||||||
|
<Button
|
||||||
|
btnStyle='primary'
|
||||||
|
disabled={noPatches}
|
||||||
|
handler={this._installAllMissingPatches}
|
||||||
|
icon='host-patch-update'
|
||||||
|
labelId='installPoolPatches'
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</Portal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@connectStore(() => {
|
||||||
|
const getPools = createGetObjectsOfType('pool')
|
||||||
|
|
||||||
|
return {
|
||||||
|
pools: getPools,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
class HostsPatchesTableByPool extends Component {
|
||||||
|
render () {
|
||||||
|
const { props } = this
|
||||||
|
return <HostsPatchesTable {...props} pools={props.pools} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export default propTypes({
|
||||||
|
buttonsGroupContainer: propTypes.func.isRequired,
|
||||||
|
container: propTypes.any,
|
||||||
|
displayPools: propTypes.bool,
|
||||||
|
hosts: propTypes.oneOfType([
|
||||||
|
propTypes.arrayOf(propTypes.object),
|
||||||
|
propTypes.objectOf(propTypes.object),
|
||||||
|
]).isRequired,
|
||||||
|
useTabButton: propTypes.bool,
|
||||||
|
})(
|
||||||
|
props =>
|
||||||
|
props.displayPools ? (
|
||||||
|
<HostsPatchesTableByPool {...props} />
|
||||||
|
) : (
|
||||||
|
<HostsPatchesTable {...props} />
|
||||||
|
)
|
||||||
|
)
|
24
packages/xo-web/src/common/icon.js
Normal file
24
packages/xo-web/src/common/icon.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import isInteger from 'lodash/isInteger'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const Icon = ({ icon, size = 1, color, fixedWidth, ...props }) => {
|
||||||
|
props.className = classNames(
|
||||||
|
props.className,
|
||||||
|
icon !== undefined ? `xo-icon-${icon}` : 'fa', // Without icon prop, is a placeholder.
|
||||||
|
isInteger(size) ? `fa-${size}x` : `fa-${size}`,
|
||||||
|
color,
|
||||||
|
fixedWidth && 'fa-fw'
|
||||||
|
)
|
||||||
|
|
||||||
|
return <i {...props} />
|
||||||
|
}
|
||||||
|
propTypes(Icon)({
|
||||||
|
color: propTypes.string,
|
||||||
|
fixedWidth: propTypes.bool,
|
||||||
|
icon: propTypes.string,
|
||||||
|
size: propTypes.oneOfType([propTypes.string, propTypes.number]),
|
||||||
|
})
|
||||||
|
export default Icon
|
111
packages/xo-web/src/common/intl/index.js
Normal file
111
packages/xo-web/src/common/intl/index.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import isFunction from 'lodash/isFunction'
|
||||||
|
import isString from 'lodash/isString'
|
||||||
|
import moment from 'moment'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
import { connect } from 'react-redux'
|
||||||
|
import { FormattedMessage, IntlProvider as IntlProvider_ } from 'react-intl'
|
||||||
|
|
||||||
|
import locales from './locales'
|
||||||
|
import messages from './messages'
|
||||||
|
import Tooltip from '.././tooltip'
|
||||||
|
import { createSelector } from '.././selectors'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
// Params:
|
||||||
|
//
|
||||||
|
// - props (optional): properties to add to the FormattedMessage
|
||||||
|
// - messageId: identifier of the message to format/translate
|
||||||
|
// - values (optional): values to pass to the message
|
||||||
|
// - render (optional): a function receiving the React nodes of the
|
||||||
|
// translated message and returning the React node to render
|
||||||
|
const getMessage = (props, messageId, values, render) => {
|
||||||
|
if (isString(props)) {
|
||||||
|
render = values
|
||||||
|
values = messageId
|
||||||
|
messageId = props
|
||||||
|
props = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = messages[messageId]
|
||||||
|
if (process.env.NODE_ENV !== 'production' && !message) {
|
||||||
|
throw new Error(`no message defined for ${messageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFunction(values)) {
|
||||||
|
render = values
|
||||||
|
values = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormattedMessage {...props} {...message} values={values}>
|
||||||
|
{render}
|
||||||
|
</FormattedMessage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
getMessage.keyValue = (key, value) =>
|
||||||
|
getMessage('keyValue', {
|
||||||
|
key: <strong>{key}</strong>,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
|
||||||
|
export { getMessage as default }
|
||||||
|
|
||||||
|
export { messages }
|
||||||
|
|
||||||
|
@connect(({ lang }) => ({ lang }))
|
||||||
|
export class IntlProvider extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
lang: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { lang, children } = this.props
|
||||||
|
// Adding a key prop is a work-around suggested by react-intl documentation
|
||||||
|
// to make sure changes to the locale trigger a re-render of the child components
|
||||||
|
// https://github.com/yahoo/react-intl/wiki/Components#dynamic-language-selection
|
||||||
|
//
|
||||||
|
// FIXME: remove the key prop when React context propagation is fixed (https://github.com/facebook/react/issues/2517)
|
||||||
|
return (
|
||||||
|
<IntlProvider_ key={lang} locale={lang} messages={locales[lang]}>
|
||||||
|
{children}
|
||||||
|
</IntlProvider_>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDuration = milliseconds => {
|
||||||
|
let seconds = Math.floor(milliseconds / 1e3)
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
seconds -= days * 86400
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
seconds -= hours * 3600
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
seconds -= minutes * 60
|
||||||
|
return { days, hours, minutes, seconds }
|
||||||
|
}
|
||||||
|
|
||||||
|
@connect(({ lang }) => ({ lang }))
|
||||||
|
export class FormattedDuration extends Component {
|
||||||
|
_parseDuration = createSelector(() => this.props.duration, parseDuration)
|
||||||
|
|
||||||
|
_humanizeDuration = createSelector(
|
||||||
|
() => this.props.duration,
|
||||||
|
() => this.props.lang,
|
||||||
|
(duration, lang) =>
|
||||||
|
moment
|
||||||
|
.duration(duration)
|
||||||
|
.locale(lang)
|
||||||
|
.humanize()
|
||||||
|
)
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Tooltip content={getMessage('durationFormat', this._parseDuration())}>
|
||||||
|
<span>{this._humanizeDuration()}</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
3838
packages/xo-web/src/common/intl/locales/es.js
Normal file
3838
packages/xo-web/src/common/intl/locales/es.js
Normal file
File diff suppressed because it is too large
Load Diff
3937
packages/xo-web/src/common/intl/locales/fr.js
Normal file
3937
packages/xo-web/src/common/intl/locales/fr.js
Normal file
File diff suppressed because it is too large
Load Diff
3136
packages/xo-web/src/common/intl/locales/he.js
Normal file
3136
packages/xo-web/src/common/intl/locales/he.js
Normal file
File diff suppressed because it is too large
Load Diff
3658
packages/xo-web/src/common/intl/locales/hu.js
Normal file
3658
packages/xo-web/src/common/intl/locales/hu.js
Normal file
File diff suppressed because it is too large
Load Diff
3186
packages/xo-web/src/common/intl/locales/pl.js
Normal file
3186
packages/xo-web/src/common/intl/locales/pl.js
Normal file
File diff suppressed because it is too large
Load Diff
3170
packages/xo-web/src/common/intl/locales/pt.js
Normal file
3170
packages/xo-web/src/common/intl/locales/pt.js
Normal file
File diff suppressed because it is too large
Load Diff
2300
packages/xo-web/src/common/intl/locales/zh.js
Normal file
2300
packages/xo-web/src/common/intl/locales/zh.js
Normal file
File diff suppressed because it is too large
Load Diff
1775
packages/xo-web/src/common/intl/messages.js
Normal file
1775
packages/xo-web/src/common/intl/messages.js
Normal file
File diff suppressed because it is too large
Load Diff
33
packages/xo-web/src/common/invoke.js
Normal file
33
packages/xo-web/src/common/invoke.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Invoke a function and returns it result.
|
||||||
|
// All parameters are forwarded.
|
||||||
|
//
|
||||||
|
// Why using `invoke()`?
|
||||||
|
// - avoid tedious IIFE syntax
|
||||||
|
// - avoid declaring variables in the common scope
|
||||||
|
// - monkey-patching
|
||||||
|
//
|
||||||
|
// ```js
|
||||||
|
// const sum = invoke(1, 2, (a, b) => a + b)
|
||||||
|
//
|
||||||
|
// eventEmitter.emit = invoke(eventEmitter.emit, emit => function (event) {
|
||||||
|
// if (event === 'foo') {
|
||||||
|
// throw new Error('event foo is disabled')
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return emit.apply(this, arguments)
|
||||||
|
// })
|
||||||
|
// ```
|
||||||
|
export default function invoke (fn) {
|
||||||
|
const n = arguments.length - 1
|
||||||
|
if (!n) {
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn = arguments[n]
|
||||||
|
const args = new Array(n)
|
||||||
|
for (let i = 0; i < n; ++i) {
|
||||||
|
args[i] = arguments[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn.apply(undefined, args)
|
||||||
|
}
|
136
packages/xo-web/src/common/ip.js
Normal file
136
packages/xo-web/src/common/ip.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import forEachRight from 'lodash/forEachRight'
|
||||||
|
import forEach from 'lodash/forEach'
|
||||||
|
import isArray from 'lodash/isArray'
|
||||||
|
import isIp from 'is-ip'
|
||||||
|
import some from 'lodash/some'
|
||||||
|
|
||||||
|
export { isIp }
|
||||||
|
export const isIpV4 = isIp.v4
|
||||||
|
export const isIpV6 = isIp.v6
|
||||||
|
|
||||||
|
// Source: https://github.com/ezpaarse-project/ip-range-generator/blob/master/index.js
|
||||||
|
|
||||||
|
const ipv4 = /^(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(?:\.(?!$)|$)){4}$/
|
||||||
|
|
||||||
|
function ip2hex (ip) {
|
||||||
|
const parts = ip.split('.').map(str => parseInt(str, 10))
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
n += parts[3]
|
||||||
|
n += parts[2] * 256 // 2^8
|
||||||
|
n += parts[1] * 65536 // 2^16
|
||||||
|
n += parts[0] * 16777216 // 2^24
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIpv4 (str, msg) {
|
||||||
|
if (!ipv4.test(str)) {
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function * range (ip1, ip2) {
|
||||||
|
assertIpv4(ip1, 'argument "ip1" must be a valid IPv4 address')
|
||||||
|
assertIpv4(ip2, 'argument "ip2" must be a valid IPv4 address')
|
||||||
|
|
||||||
|
let hex = ip2hex(ip1)
|
||||||
|
let hex2 = ip2hex(ip2)
|
||||||
|
|
||||||
|
if (hex > hex2) {
|
||||||
|
const tmp = hex
|
||||||
|
hex = hex2
|
||||||
|
hex2 = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = hex; i <= hex2; i++) {
|
||||||
|
yield `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i &
|
||||||
|
0xff}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getNextIpV4 = ip => {
|
||||||
|
const splitIp = ip.split('.')
|
||||||
|
if (
|
||||||
|
splitIp.length !== 4 ||
|
||||||
|
some(splitIp, value => value < 0 || value > 255)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let index
|
||||||
|
forEachRight(splitIp, (value, i) => {
|
||||||
|
if (value < 255) {
|
||||||
|
index = i
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
splitIp[i] = 1
|
||||||
|
})
|
||||||
|
if (index === 0 && +splitIp[0] === 255) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
splitIp[index]++
|
||||||
|
|
||||||
|
return splitIp.join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatIps = ips => {
|
||||||
|
if (!isArray(ips)) {
|
||||||
|
throw new Error('ips must be an array')
|
||||||
|
}
|
||||||
|
if (ips.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const sortedIps = ips.sort((ip1, ip2) => {
|
||||||
|
const splitIp1 = ip1.split('.')
|
||||||
|
const splitIp2 = ip2.split('.')
|
||||||
|
if (splitIp1.length !== 4) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (splitIp2.length !== 4) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
splitIp1[3] -
|
||||||
|
splitIp2[3] +
|
||||||
|
(splitIp1[2] - splitIp2[2]) * 256 +
|
||||||
|
(splitIp1[1] - splitIp2[1]) * 256 * 256 +
|
||||||
|
(splitIp1[0] - splitIp2[0]) * 256 * 256 * 256
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const range = { first: '', last: '' }
|
||||||
|
const formattedIps = []
|
||||||
|
let index = 0
|
||||||
|
forEach(sortedIps, ip => {
|
||||||
|
if (ip !== getNextIpV4(range.last)) {
|
||||||
|
if (range.first) {
|
||||||
|
formattedIps[index] =
|
||||||
|
range.first === range.last ? range.first : { ...range }
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
range.first = range.last = ip
|
||||||
|
} else {
|
||||||
|
range.last = ip
|
||||||
|
}
|
||||||
|
})
|
||||||
|
formattedIps[index] = range.first === range.last ? range.first : range
|
||||||
|
|
||||||
|
return formattedIps
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseIpPattern = pattern => {
|
||||||
|
const ips = []
|
||||||
|
forEach(pattern.split(';'), rawIpRange => {
|
||||||
|
const ipRange = rawIpRange.split('-')
|
||||||
|
if (ipRange.length < 2) {
|
||||||
|
ips.push(ipRange[0])
|
||||||
|
} else if (!isIpV4(ipRange[0]) || !isIpV4(ipRange[1])) {
|
||||||
|
ips.push(rawIpRange)
|
||||||
|
} else {
|
||||||
|
ips.push(...range(ipRange[0], ipRange[1]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
103
packages/xo-web/src/common/iso-device.js
Normal file
103
packages/xo-web/src/common/iso-device.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import _ from 'intl'
|
||||||
|
import ActionButton from './action-button'
|
||||||
|
import Component from './base-component'
|
||||||
|
import Icon from 'icon'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import Tooltip from 'tooltip'
|
||||||
|
import { alert } from 'modal'
|
||||||
|
import { connectStore } from './utils'
|
||||||
|
import { SelectVdi } from './select-objects'
|
||||||
|
import {
|
||||||
|
createGetObjectsOfType,
|
||||||
|
createFinder,
|
||||||
|
createGetObject,
|
||||||
|
createSelector,
|
||||||
|
} from './selectors'
|
||||||
|
import { ejectCd, insertCd } from './xo'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
vm: propTypes.object.isRequired,
|
||||||
|
})
|
||||||
|
@connectStore(() => {
|
||||||
|
const getCdDrive = createFinder(
|
||||||
|
createGetObjectsOfType('VBD').pick((_, { vm }) => vm.$VBDs),
|
||||||
|
[vbd => vbd.is_cd_drive]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getMountedIso = createGetObject((state, props) => {
|
||||||
|
const cdDrive = getCdDrive(state, props)
|
||||||
|
if (cdDrive) {
|
||||||
|
return cdDrive.VDI
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
cdDrive: getCdDrive,
|
||||||
|
mountedIso: getMountedIso,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class IsoDevice extends Component {
|
||||||
|
_getPredicate = createSelector(
|
||||||
|
() => this.props.vm.$pool,
|
||||||
|
() => this.props.vm.$container,
|
||||||
|
(vmPool, vmContainer) => sr => {
|
||||||
|
const vmRunning = vmContainer !== vmPool
|
||||||
|
const sameHost = vmContainer === sr.$container
|
||||||
|
const samePool = vmPool === sr.$pool
|
||||||
|
|
||||||
|
return (
|
||||||
|
samePool &&
|
||||||
|
(vmRunning ? sr.shared || sameHost : true) &&
|
||||||
|
(sr.SR_type === 'iso' || (sr.SR_type === 'udev' && sr.size))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_handleInsert = iso => {
|
||||||
|
const { vm } = this.props
|
||||||
|
|
||||||
|
if (iso) {
|
||||||
|
insertCd(vm, iso.id, true)
|
||||||
|
} else {
|
||||||
|
ejectCd(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleEject = () => ejectCd(this.props.vm)
|
||||||
|
|
||||||
|
_showWarning = () => alert(_('cdDriveNotInstalled'), _('cdDriveInstallation'))
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { cdDrive, mountedIso } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='input-group'>
|
||||||
|
<SelectVdi
|
||||||
|
srPredicate={this._getPredicate()}
|
||||||
|
onChange={this._handleInsert}
|
||||||
|
value={mountedIso}
|
||||||
|
/>
|
||||||
|
<span className='input-group-btn'>
|
||||||
|
<ActionButton
|
||||||
|
disabled={!mountedIso}
|
||||||
|
handler={this._handleEject}
|
||||||
|
icon='vm-eject'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{mountedIso &&
|
||||||
|
!cdDrive.device && (
|
||||||
|
<Tooltip content={_('cdDriveNotInstalled')}>
|
||||||
|
<a
|
||||||
|
className='text-warning btn btn-link'
|
||||||
|
onClick={this._showWarning}
|
||||||
|
>
|
||||||
|
<Icon icon='alarm' size='lg' />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
126
packages/xo-web/src/common/json-schema-input/array-input.js
Normal file
126
packages/xo-web/src/common/json-schema-input/array-input.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import { filter, map } from 'lodash'
|
||||||
|
|
||||||
|
import _ from '../intl'
|
||||||
|
import Button from '../button'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import { EMPTY_ARRAY } from '../utils'
|
||||||
|
|
||||||
|
import GenericInput from './generic-input'
|
||||||
|
import { descriptionRender, forceDisplayOptionalAttr } from './helpers'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
depth: propTypes.number,
|
||||||
|
disabled: propTypes.bool,
|
||||||
|
label: propTypes.any.isRequired,
|
||||||
|
required: propTypes.bool,
|
||||||
|
schema: propTypes.object.isRequired,
|
||||||
|
uiSchema: propTypes.object,
|
||||||
|
})
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class ObjectInput extends Component {
|
||||||
|
state = {
|
||||||
|
use: this.props.required || forceDisplayOptionalAttr(this.props),
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAddItem = () => {
|
||||||
|
const { props } = this
|
||||||
|
props.onChange((props.value || EMPTY_ARRAY).concat(undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChangeItem = (value, name) => {
|
||||||
|
const key = Number(name)
|
||||||
|
|
||||||
|
const { props } = this
|
||||||
|
const newValue = (props.value || EMPTY_ARRAY).slice()
|
||||||
|
newValue[key] = value
|
||||||
|
props.onChange(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onRemoveItem = key => {
|
||||||
|
const { props } = this
|
||||||
|
props.onChange(filter(props.value, (_, i) => i !== key))
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
props: {
|
||||||
|
depth = 0,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
value = EMPTY_ARRAY,
|
||||||
|
},
|
||||||
|
state: { use },
|
||||||
|
} = this
|
||||||
|
|
||||||
|
const childDepth = depth + 2
|
||||||
|
const itemSchema = schema.items
|
||||||
|
const itemUiSchema = uiSchema && uiSchema.items
|
||||||
|
|
||||||
|
const itemLabel = itemSchema.title || _('item')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: `${depth}em` }}>
|
||||||
|
<legend>{label}</legend>
|
||||||
|
{descriptionRender(schema.description)}
|
||||||
|
<hr />
|
||||||
|
{!required && (
|
||||||
|
<div className='checkbox'>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={use}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={this.linkState('use')}
|
||||||
|
type='checkbox'
|
||||||
|
/>{' '}
|
||||||
|
{_('fillOptionalInformations')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{use && (
|
||||||
|
<div className='card-block'>
|
||||||
|
<ul style={{ paddingLeft: 0 }}>
|
||||||
|
{map(value, (value, key) => (
|
||||||
|
<li className='list-group-item clearfix' key={key}>
|
||||||
|
<GenericInput
|
||||||
|
depth={childDepth}
|
||||||
|
disabled={disabled}
|
||||||
|
label={itemLabel}
|
||||||
|
name={key}
|
||||||
|
onChange={this._onChangeItem}
|
||||||
|
required
|
||||||
|
schema={itemSchema}
|
||||||
|
uiSchema={itemUiSchema}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
btnStyle='danger'
|
||||||
|
className='pull-right'
|
||||||
|
disabled={disabled}
|
||||||
|
name={key}
|
||||||
|
onClick={() => this._onRemoveItem(key)}
|
||||||
|
>
|
||||||
|
{_('remove')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
btnStyle='primary'
|
||||||
|
className='pull-right mt-1 mr-1'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this._onAddItem}
|
||||||
|
>
|
||||||
|
{_('add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import { Toggle } from '../form'
|
||||||
|
|
||||||
|
import { PrimitiveInputWrapper } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class BooleanInput extends Component {
|
||||||
|
render () {
|
||||||
|
const { disabled, onChange, value, ...props } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimitiveInputWrapper {...props}>
|
||||||
|
<div className='checkbox form-control'>
|
||||||
|
<Toggle disabled={disabled} onChange={onChange} value={value} />
|
||||||
|
</div>
|
||||||
|
</PrimitiveInputWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
56
packages/xo-web/src/common/json-schema-input/enum-input.js
Normal file
56
packages/xo-web/src/common/json-schema-input/enum-input.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import _ from 'intl'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import Component from 'base-component'
|
||||||
|
import React from 'react'
|
||||||
|
import { createSelector } from 'reselect'
|
||||||
|
import { findIndex, map } from 'lodash'
|
||||||
|
|
||||||
|
import { PrimitiveInputWrapper } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class EnumInput extends Component {
|
||||||
|
_getSelectedIndex = createSelector(
|
||||||
|
() => this.props.schema.enum,
|
||||||
|
() => {
|
||||||
|
const { schema, value = schema.default } = this.props
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
(enumValues, value) => {
|
||||||
|
const index = findIndex(enumValues, current => current === value)
|
||||||
|
return index === -1 ? '' : index
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_onChange = event => {
|
||||||
|
this.props.onChange(this.props.schema.enum[event.target.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
schema: { enum: enumValues, enumNames = enumValues },
|
||||||
|
required,
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimitiveInputWrapper {...this.props}>
|
||||||
|
<select
|
||||||
|
className='form-control'
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={this._onChange}
|
||||||
|
required={required}
|
||||||
|
value={this._getSelectedIndex()}
|
||||||
|
>
|
||||||
|
{_('noSelectedValue', message => <option value=''>{message}</option>)}
|
||||||
|
{map(enumNames, (name, index) => (
|
||||||
|
<option value={index} key={index}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</PrimitiveInputWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import { EMPTY_OBJECT } from '../utils'
|
||||||
|
|
||||||
|
import ArrayInput from './array-input'
|
||||||
|
import BooleanInput from './boolean-input'
|
||||||
|
import EnumInput from './enum-input'
|
||||||
|
import IntegerInput from './integer-input'
|
||||||
|
import NumberInput from './number-input'
|
||||||
|
import ObjectInput from './object-input'
|
||||||
|
import StringInput from './string-input'
|
||||||
|
|
||||||
|
import { getType } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const InputByType = {
|
||||||
|
array: ArrayInput,
|
||||||
|
boolean: BooleanInput,
|
||||||
|
integer: IntegerInput,
|
||||||
|
number: NumberInput,
|
||||||
|
object: ObjectInput,
|
||||||
|
string: StringInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
depth: propTypes.number,
|
||||||
|
disabled: propTypes.bool,
|
||||||
|
label: propTypes.any.isRequired,
|
||||||
|
required: propTypes.bool,
|
||||||
|
schema: propTypes.object.isRequired,
|
||||||
|
uiSchema: propTypes.object,
|
||||||
|
})
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class GenericInput extends Component {
|
||||||
|
_onChange = event => {
|
||||||
|
const { name, onChange } = this.props
|
||||||
|
onChange && onChange(getEventValue(event), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
schema,
|
||||||
|
value = schema.default,
|
||||||
|
uiSchema = EMPTY_OBJECT,
|
||||||
|
...opts
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...opts,
|
||||||
|
onChange: this._onChange,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum, special case.
|
||||||
|
if (schema.enum) {
|
||||||
|
return <EnumInput {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = getType(schema)
|
||||||
|
const Input = uiSchema.widget || InputByType[type.toLowerCase()]
|
||||||
|
|
||||||
|
if (!Input) {
|
||||||
|
throw new Error(`Unsupported type: ${type}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Input {...props} {...uiSchema.config} />
|
||||||
|
}
|
||||||
|
}
|
90
packages/xo-web/src/common/json-schema-input/helpers.js
Normal file
90
packages/xo-web/src/common/json-schema-input/helpers.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import includes from 'lodash/includes'
|
||||||
|
import isArray from 'lodash/isArray'
|
||||||
|
import marked from 'marked'
|
||||||
|
|
||||||
|
import { Col, Row } from 'grid'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export const getType = schema => {
|
||||||
|
if (!schema) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = schema.type
|
||||||
|
|
||||||
|
if (isArray(type)) {
|
||||||
|
if (includes(type, 'integer')) {
|
||||||
|
return 'integer'
|
||||||
|
}
|
||||||
|
if (includes(type, 'number')) {
|
||||||
|
return 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getXoType = schema => {
|
||||||
|
const type = schema && (schema['xo:type'] || schema.$type)
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
return type.toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export const descriptionRender = description => (
|
||||||
|
<span
|
||||||
|
className='text-muted'
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked(description || '') }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export const PrimitiveInputWrapper = ({
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
schema,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<Row>
|
||||||
|
<Col mediumSize={6}>
|
||||||
|
<div className='input-group'>
|
||||||
|
<span className='input-group-addon'>
|
||||||
|
{label}
|
||||||
|
{required && <span className='text-warning'>*</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col mediumSize={6}>{descriptionRender(schema.description)}</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export const forceDisplayOptionalAttr = ({ schema, value }) => {
|
||||||
|
if (!schema || !value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (schema.items && Array.isArray(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object
|
||||||
|
for (const key in schema.properties) {
|
||||||
|
if (value[key]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
1
packages/xo-web/src/common/json-schema-input/index.js
Normal file
1
packages/xo-web/src/common/json-schema-input/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default from './generic-input'
|
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import Combobox from '../combobox'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
|
||||||
|
import { PrimitiveInputWrapper } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class IntegerInput extends Component {
|
||||||
|
_onChange = event => {
|
||||||
|
const value = getEventValue(event)
|
||||||
|
this.props.onChange(value ? +value : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { required, schema } = this.props
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
onChange, // eslint-disable-line no-unused-vars
|
||||||
|
placeholder = schema.default,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimitiveInputWrapper {...props}>
|
||||||
|
<Combobox
|
||||||
|
value={value === undefined ? '' : String(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
max={schema.max}
|
||||||
|
min={schema.min}
|
||||||
|
onChange={this._onChange}
|
||||||
|
options={schema.defaults}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
step={1}
|
||||||
|
type='number'
|
||||||
|
/>
|
||||||
|
</PrimitiveInputWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
46
packages/xo-web/src/common/json-schema-input/number-input.js
Normal file
46
packages/xo-web/src/common/json-schema-input/number-input.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import Combobox from '../combobox'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
|
||||||
|
import { PrimitiveInputWrapper } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class NumberInput extends Component {
|
||||||
|
_onChange = event => {
|
||||||
|
const value = getEventValue(event)
|
||||||
|
this.props.onChange(value ? +value : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { required, schema } = this.props
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
onChange, // eslint-disable-line no-unused-vars
|
||||||
|
placeholder = schema.default,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimitiveInputWrapper {...props}>
|
||||||
|
<Combobox
|
||||||
|
value={value === undefined ? '' : String(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
max={schema.max}
|
||||||
|
min={schema.min}
|
||||||
|
onChange={this._onChange}
|
||||||
|
options={schema.defaults}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
step='any'
|
||||||
|
type='number'
|
||||||
|
/>
|
||||||
|
</PrimitiveInputWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
98
packages/xo-web/src/common/json-schema-input/object-input.js
Normal file
98
packages/xo-web/src/common/json-schema-input/object-input.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
import { createSelector } from 'reselect'
|
||||||
|
import { keyBy, map } from 'lodash'
|
||||||
|
|
||||||
|
import _ from '../intl'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
import { EMPTY_OBJECT } from '../utils'
|
||||||
|
|
||||||
|
import GenericInput from './generic-input'
|
||||||
|
import { descriptionRender, forceDisplayOptionalAttr } from './helpers'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
depth: propTypes.number,
|
||||||
|
disabled: propTypes.bool,
|
||||||
|
label: propTypes.any.isRequired,
|
||||||
|
required: propTypes.bool,
|
||||||
|
schema: propTypes.object.isRequired,
|
||||||
|
uiSchema: propTypes.object,
|
||||||
|
})
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class ObjectInput extends Component {
|
||||||
|
state = {
|
||||||
|
use: this.props.required || forceDisplayOptionalAttr(this.props),
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChildChange = (value, key) => {
|
||||||
|
this.props.onChange({
|
||||||
|
...this.props.value,
|
||||||
|
[key]: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRequiredProps = createSelector(
|
||||||
|
() => this.props.schema.required,
|
||||||
|
required => (required ? keyBy(required) : EMPTY_OBJECT)
|
||||||
|
)
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
props: {
|
||||||
|
depth = 0,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
value = EMPTY_OBJECT,
|
||||||
|
},
|
||||||
|
state: { use },
|
||||||
|
} = this
|
||||||
|
|
||||||
|
const childDepth = depth + 2
|
||||||
|
const properties = (uiSchema != null && uiSchema.properties) || EMPTY_OBJECT
|
||||||
|
const requiredProps = this._getRequiredProps()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: `${depth}em` }}>
|
||||||
|
<legend>{label}</legend>
|
||||||
|
{descriptionRender(schema.description)}
|
||||||
|
<hr />
|
||||||
|
{!required && (
|
||||||
|
<div className='checkbox'>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={use}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={this.linkState('use')}
|
||||||
|
type='checkbox'
|
||||||
|
/>{' '}
|
||||||
|
{_('fillOptionalInformations')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{use && (
|
||||||
|
<div className='card-block'>
|
||||||
|
{map(schema.properties, (childSchema, key) => (
|
||||||
|
<div className='pb-1' key={key}>
|
||||||
|
<GenericInput
|
||||||
|
depth={childDepth}
|
||||||
|
disabled={disabled}
|
||||||
|
label={childSchema.title || key}
|
||||||
|
name={key}
|
||||||
|
onChange={this._onChildChange}
|
||||||
|
required={Boolean(requiredProps[key])}
|
||||||
|
schema={childSchema}
|
||||||
|
uiSchema={properties[key]}
|
||||||
|
value={value[key]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
50
packages/xo-web/src/common/json-schema-input/string-input.js
Normal file
50
packages/xo-web/src/common/json-schema-input/string-input.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import uncontrollableInput from 'uncontrollable-input'
|
||||||
|
|
||||||
|
import Combobox from '../combobox'
|
||||||
|
import Component from '../base-component'
|
||||||
|
import getEventValue from '../get-event-value'
|
||||||
|
import propTypes from '../prop-types-decorator'
|
||||||
|
|
||||||
|
import { PrimitiveInputWrapper } from './helpers'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
password: propTypes.bool,
|
||||||
|
})
|
||||||
|
@uncontrollableInput()
|
||||||
|
export default class StringInput extends Component {
|
||||||
|
// the value of this input is undefined not '' when empty to make
|
||||||
|
// it homogenous with when the user has never touched this input
|
||||||
|
_onChange = event => {
|
||||||
|
const value = getEventValue(event)
|
||||||
|
this.props.onChange(value !== '' ? value : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { required, schema } = this.props
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
password,
|
||||||
|
placeholder = schema.default,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
} = this.props
|
||||||
|
delete props.onChange
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimitiveInputWrapper {...props}>
|
||||||
|
<Combobox
|
||||||
|
value={value !== undefined ? value : ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={this._onChange}
|
||||||
|
options={schema.defaults}
|
||||||
|
placeholder={placeholder || schema.default}
|
||||||
|
required={required}
|
||||||
|
type={password && 'password'}
|
||||||
|
/>
|
||||||
|
</PrimitiveInputWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
71
packages/xo-web/src/common/link.js
Normal file
71
packages/xo-web/src/common/link.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import Link from 'react-router/lib/Link'
|
||||||
|
import React from 'react'
|
||||||
|
import { routerShape } from 'react-router/lib/PropTypes'
|
||||||
|
|
||||||
|
import Component from './base-component'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export { Link as default }
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _IGNORED_TAGNAMES = {
|
||||||
|
A: true,
|
||||||
|
BUTTON: true,
|
||||||
|
INPUT: true,
|
||||||
|
SELECT: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
tagName: propTypes.string,
|
||||||
|
})
|
||||||
|
export class BlockLink extends Component {
|
||||||
|
static contextTypes = {
|
||||||
|
router: routerShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
_style = { cursor: 'pointer' }
|
||||||
|
_onClickCapture = event => {
|
||||||
|
const { currentTarget } = event
|
||||||
|
let element = event.target
|
||||||
|
while (element !== currentTarget) {
|
||||||
|
if (_IGNORED_TAGNAMES[element.tagName]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element = element.parentNode
|
||||||
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.ctrlKey || event.button === 1) {
|
||||||
|
window.open(this.context.router.createHref(this.props.to))
|
||||||
|
} else {
|
||||||
|
this.context.router.push(this.props.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addAuxClickListener = ref => {
|
||||||
|
// FIXME: when https://github.com/facebook/react/issues/8529 is fixed,
|
||||||
|
// remove and use onAuxClickCapture.
|
||||||
|
// In Chrome ^55, middle-clicking triggers auxclick event instead of click
|
||||||
|
if (ref !== null) {
|
||||||
|
ref.addEventListener('auxclick', this._onClickCapture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { children, tagName = 'div', className } = this.props
|
||||||
|
const Component = tagName
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={className}
|
||||||
|
ref={this._addAuxClickListener}
|
||||||
|
style={this._style}
|
||||||
|
onClickCapture={this._onClickCapture}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
13
packages/xo-web/src/common/log-error.js
Normal file
13
packages/xo-web/src/common/log-error.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Logs an error properly, correctly use the source map for the stack.
|
||||||
|
//
|
||||||
|
// This is achieved by throwing the error asynchronously.
|
||||||
|
const logError = (error, ...args) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (args.length) {
|
||||||
|
console.error(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
export { logError as default }
|
286
packages/xo-web/src/common/modal.js
Normal file
286
packages/xo-web/src/common/modal.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import isArray from 'lodash/isArray'
|
||||||
|
import isString from 'lodash/isString'
|
||||||
|
import map from 'lodash/map'
|
||||||
|
import React, { Component, cloneElement } from 'react'
|
||||||
|
import { createSelector } from 'selectors'
|
||||||
|
import { injectIntl } from 'react-intl'
|
||||||
|
import { Modal as ReactModal } from 'react-bootstrap-4/lib'
|
||||||
|
|
||||||
|
import _, { messages } from './intl'
|
||||||
|
import Button from './button'
|
||||||
|
import Icon from './icon'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import Tooltip from './tooltip'
|
||||||
|
import {
|
||||||
|
disable as disableShortcuts,
|
||||||
|
enable as enableShortcuts,
|
||||||
|
} from './shortcuts'
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let instance
|
||||||
|
const modal = (content, onClose) => {
|
||||||
|
if (!instance) {
|
||||||
|
throw new Error('No modal instance.')
|
||||||
|
} else if (instance.state.showModal) {
|
||||||
|
throw new Error('Other modal still open.')
|
||||||
|
}
|
||||||
|
instance.setState({ content, onClose, showModal: true }, disableShortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const _addRef = (component, ref) => {
|
||||||
|
if (isString(component) || isArray(component)) {
|
||||||
|
return component
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return cloneElement(component, { ref })
|
||||||
|
} catch (_) {} // Stateless component.
|
||||||
|
return component
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
buttons: propTypes.arrayOf(
|
||||||
|
propTypes.shape({
|
||||||
|
btnStyle: propTypes.string,
|
||||||
|
icon: propTypes.string,
|
||||||
|
label: propTypes.node.isRequired,
|
||||||
|
tooltip: propTypes.node,
|
||||||
|
value: propTypes.any,
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
children: propTypes.node.isRequired,
|
||||||
|
icon: propTypes.string,
|
||||||
|
title: propTypes.node.isRequired,
|
||||||
|
})
|
||||||
|
class GenericModal extends Component {
|
||||||
|
_getBodyValue = () => {
|
||||||
|
const { body } = this.refs
|
||||||
|
if (body !== undefined) {
|
||||||
|
return body.getWrappedInstance === undefined
|
||||||
|
? body.value
|
||||||
|
: body.getWrappedInstance().value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolve = (value = this._getBodyValue()) => {
|
||||||
|
this.props.resolve(value)
|
||||||
|
instance.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
_reject = () => {
|
||||||
|
this.props.reject()
|
||||||
|
instance.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { buttons, icon, title } = this.props
|
||||||
|
|
||||||
|
const body = _addRef(this.props.children, 'body')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ReactModal.Header closeButton>
|
||||||
|
<ReactModal.Title>
|
||||||
|
{icon ? (
|
||||||
|
<span>
|
||||||
|
<Icon icon={icon} /> {title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</ReactModal.Title>
|
||||||
|
</ReactModal.Header>
|
||||||
|
<ReactModal.Body>{body}</ReactModal.Body>
|
||||||
|
<ReactModal.Footer>
|
||||||
|
{map(buttons, ({ label, tooltip, value, icon, ...props }, key) => {
|
||||||
|
const button = (
|
||||||
|
<Button onClick={() => this._resolve(value)} {...props}>
|
||||||
|
{icon !== undefined && <Icon icon={icon} fixedWidth />}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span key={key}>
|
||||||
|
{tooltip !== undefined ? (
|
||||||
|
<Tooltip content={tooltip}>{button}</Tooltip>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
)}{' '}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{this.props.reject !== undefined && (
|
||||||
|
<Button onClick={this._reject}>{_('genericCancel')}</Button>
|
||||||
|
)}
|
||||||
|
</ReactModal.Footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chooseAction = ({ body, buttons, icon, title }) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
modal(
|
||||||
|
<GenericModal
|
||||||
|
buttons={buttons}
|
||||||
|
icon={icon}
|
||||||
|
reject={reject}
|
||||||
|
resolve={resolve}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</GenericModal>,
|
||||||
|
reject
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
body: propTypes.node,
|
||||||
|
strongConfirm: propTypes.object.isRequired,
|
||||||
|
icon: propTypes.string,
|
||||||
|
reject: propTypes.func,
|
||||||
|
resolve: propTypes.func,
|
||||||
|
title: propTypes.node.isRequired,
|
||||||
|
})
|
||||||
|
@injectIntl
|
||||||
|
class StrongConfirm extends Component {
|
||||||
|
state = {
|
||||||
|
buttons: [{ btnStyle: 'danger', label: _('confirmOk'), disabled: true }],
|
||||||
|
}
|
||||||
|
|
||||||
|
_getStrongConfirmString = createSelector(
|
||||||
|
() => this.props.intl.formatMessage,
|
||||||
|
() => this.props.strongConfirm,
|
||||||
|
(format, { messageId, values }) => format(messages[messageId], values)
|
||||||
|
)
|
||||||
|
|
||||||
|
_onInputChange = event => {
|
||||||
|
const userInput = event.target.value
|
||||||
|
const strongConfirmString = this._getStrongConfirmString()
|
||||||
|
const confirmButton = this.state.buttons[0]
|
||||||
|
|
||||||
|
let disabled
|
||||||
|
if (
|
||||||
|
(userInput.toLowerCase() === strongConfirmString.toLowerCase()) ^
|
||||||
|
(disabled = !confirmButton.disabled)
|
||||||
|
) {
|
||||||
|
this.setState({
|
||||||
|
buttons: [{ ...confirmButton, disabled }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
body,
|
||||||
|
strongConfirm: { messageId, values },
|
||||||
|
icon,
|
||||||
|
reject,
|
||||||
|
resolve,
|
||||||
|
title,
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericModal
|
||||||
|
buttons={this.state.buttons}
|
||||||
|
icon={icon}
|
||||||
|
reject={reject}
|
||||||
|
resolve={resolve}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
<hr />
|
||||||
|
<div>
|
||||||
|
{_('enterConfirmText')}{' '}
|
||||||
|
<strong className='no-text-selection'>{_(messageId, values)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input className='form-control' onChange={this._onInputChange} />
|
||||||
|
</div>
|
||||||
|
</GenericModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ALERT_BUTTONS = [{ label: _('alertOk'), value: 'ok' }]
|
||||||
|
|
||||||
|
export const alert = (title, body) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
modal(
|
||||||
|
<GenericModal buttons={ALERT_BUTTONS} resolve={resolve} title={title}>
|
||||||
|
{body}
|
||||||
|
</GenericModal>,
|
||||||
|
resolve
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CONFIRM_BUTTONS = [{ btnStyle: 'primary', label: _('confirmOk') }]
|
||||||
|
|
||||||
|
export const confirm = ({ body, icon = 'alarm', title, strongConfirm }) =>
|
||||||
|
strongConfirm
|
||||||
|
? new Promise((resolve, reject) => {
|
||||||
|
modal(
|
||||||
|
<StrongConfirm
|
||||||
|
body={body}
|
||||||
|
icon={icon}
|
||||||
|
reject={reject}
|
||||||
|
resolve={resolve}
|
||||||
|
strongConfirm={strongConfirm}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: chooseAction({
|
||||||
|
body,
|
||||||
|
buttons: CONFIRM_BUTTONS,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default class Modal extends Component {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.state = { showModal: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
if (instance) {
|
||||||
|
throw new Error('Modal is a singleton!')
|
||||||
|
}
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
instance = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
close () {
|
||||||
|
this.setState({ showModal: false }, enableShortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onHide = () => {
|
||||||
|
this.close()
|
||||||
|
|
||||||
|
const { onClose } = this.state
|
||||||
|
onClose && onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<ReactModal show={this.state.showModal} onHide={this._onHide}>
|
||||||
|
{this.state.content}
|
||||||
|
</ReactModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
18
packages/xo-web/src/common/nav.js
Normal file
18
packages/xo-web/src/common/nav.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Link from './link'
|
||||||
|
|
||||||
|
export const NavLink = ({ children, to }) => (
|
||||||
|
<li className='nav-item' role='tab'>
|
||||||
|
<Link className='nav-link' activeClassName='active' to={to}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NavTabs = ({ children, className }) => (
|
||||||
|
<ul className={classNames(className, 'nav nav-tabs')} role='tablist'>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
)
|
41
packages/xo-web/src/common/no-objects.js
Normal file
41
packages/xo-web/src/common/no-objects.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
// This component returns :
|
||||||
|
// - A loading icon when the objects are not fetched
|
||||||
|
// - A default message if the objects are fetched and the collection is empty
|
||||||
|
// - The children if the objects are fetched and the collection is not empty
|
||||||
|
//
|
||||||
|
// ```js
|
||||||
|
// <NoObjects collection={collection} emptyMessage={message}>
|
||||||
|
// {children}
|
||||||
|
// </NoObjects>
|
||||||
|
// ````
|
||||||
|
const NoObjects = props => {
|
||||||
|
const { collection } = props
|
||||||
|
|
||||||
|
if (collection == null) {
|
||||||
|
return <img src='assets/loading.svg' alt='loading' />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(collection)) {
|
||||||
|
return <p>{props.emptyMessage}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, component: Component, ...otherProps } = props
|
||||||
|
return children !== undefined ? (
|
||||||
|
children(otherProps)
|
||||||
|
) : (
|
||||||
|
<Component {...otherProps} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
propTypes(NoObjects)({
|
||||||
|
children: propTypes.func,
|
||||||
|
collection: propTypes.oneOfType([propTypes.array, propTypes.object]),
|
||||||
|
component: propTypes.func,
|
||||||
|
emptyMessage: propTypes.node.isRequired,
|
||||||
|
})
|
||||||
|
export default NoObjects
|
88
packages/xo-web/src/common/notification.js
Normal file
88
packages/xo-web/src/common/notification.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import _ from 'intl'
|
||||||
|
import ButtonLink from 'button-link'
|
||||||
|
import Icon from 'icon'
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
import ReactNotify from 'react-notify'
|
||||||
|
import { connectStore } from 'utils'
|
||||||
|
import { isAdmin } from 'selectors'
|
||||||
|
|
||||||
|
let instance
|
||||||
|
|
||||||
|
export let error
|
||||||
|
export let info
|
||||||
|
export let success
|
||||||
|
|
||||||
|
@connectStore({
|
||||||
|
isAdmin,
|
||||||
|
})
|
||||||
|
export class Notification extends Component {
|
||||||
|
componentDidMount () {
|
||||||
|
if (instance) {
|
||||||
|
throw new Error('Notification is a singleton!')
|
||||||
|
}
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
instance = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// This special component never have to rerender!
|
||||||
|
shouldComponentUpdate () {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<ReactNotify
|
||||||
|
ref={notification => {
|
||||||
|
if (!notification) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error = (title, body) =>
|
||||||
|
notification.error(
|
||||||
|
title,
|
||||||
|
this.props.isAdmin ? (
|
||||||
|
<div>
|
||||||
|
<div>{body}</div>
|
||||||
|
<ButtonLink
|
||||||
|
btnStyle='danger'
|
||||||
|
className='mt-1'
|
||||||
|
size='small'
|
||||||
|
to='/settings/logs'
|
||||||
|
>
|
||||||
|
<Icon icon='logs' /> {_('showLogs')}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
body
|
||||||
|
),
|
||||||
|
6e3
|
||||||
|
)
|
||||||
|
info = (title, body) => notification.info(title, body, 3e3)
|
||||||
|
success = (title, body) => notification.success(title, body, 3e3)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { info as default }
|
||||||
|
|
||||||
|
/* Example:
|
||||||
|
|
||||||
|
import info, { success, error } from 'notification'
|
||||||
|
|
||||||
|
<button onClick={() => info('Info', 'This is an info notification')}>
|
||||||
|
Info notification
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => success('Success', 'This is a success notification')}>
|
||||||
|
Success notification
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => error('Error', 'This is an error notification')}>
|
||||||
|
Error notification
|
||||||
|
</button>
|
||||||
|
*/
|
15
packages/xo-web/src/common/object-name.js
Normal file
15
packages/xo-web/src/common/object-name.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/** EXPERIMENT: this is here to avoid a littel code dupplication, but is not admitted as a highly recommendable component */
|
||||||
|
import { connectStore } from 'utils'
|
||||||
|
import { createGetObject } from 'selectors'
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
|
@connectStore(() => {
|
||||||
|
const object = createGetObject()
|
||||||
|
return (state, props) => ({ object: object(state, props) })
|
||||||
|
})
|
||||||
|
export default class ObjectName extends Component {
|
||||||
|
render () {
|
||||||
|
const { object } = this.props
|
||||||
|
return <span>{object && object.name_label}</span>
|
||||||
|
}
|
||||||
|
}
|
125
packages/xo-web/src/common/pagination.js
Normal file
125
packages/xo-web/src/common/pagination.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
|
const PageItem = ({ active, children, disabled, onClick, value }) =>
|
||||||
|
active ? (
|
||||||
|
<li className='active page-item'>
|
||||||
|
<span className='page-link'>{children}</span>
|
||||||
|
</li>
|
||||||
|
) : disabled ? (
|
||||||
|
<li className='disabled page-item'>
|
||||||
|
<span className='page-link'>{children}</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li className='page-item'>
|
||||||
|
<a className='page-link' href='#' onClick={onClick} data-value={value}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default class Pagination extends React.PureComponent {
|
||||||
|
static defaultProps = {
|
||||||
|
ellipsis: true,
|
||||||
|
maxButtons: 7,
|
||||||
|
next: true,
|
||||||
|
prev: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
ariaLabel: PropTypes.string,
|
||||||
|
ellipsis: PropTypes.bool,
|
||||||
|
maxButtons: PropTypes.number,
|
||||||
|
next: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
pages: PropTypes.number.isRequired,
|
||||||
|
prev: PropTypes.bool,
|
||||||
|
value: PropTypes.number.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.props.onChange(+event.currentTarget.dataset.value)
|
||||||
|
}
|
||||||
|
_onClick = this._onClick.bind(this)
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
ariaLabel,
|
||||||
|
ellipsis,
|
||||||
|
maxButtons,
|
||||||
|
next,
|
||||||
|
pages,
|
||||||
|
prev,
|
||||||
|
value,
|
||||||
|
} = this.props
|
||||||
|
const onClick = this._onClick
|
||||||
|
|
||||||
|
let min, max
|
||||||
|
if (pages <= maxButtons) {
|
||||||
|
min = 1
|
||||||
|
max = pages
|
||||||
|
} else {
|
||||||
|
min = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(value - Math.floor(maxButtons / 2), pages - maxButtons + 1)
|
||||||
|
)
|
||||||
|
max = min + maxButtons - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageButtons = []
|
||||||
|
if (ellipsis && min !== 1) {
|
||||||
|
pageButtons.push(
|
||||||
|
<PageItem disabled key='firstEllipsis'>
|
||||||
|
…
|
||||||
|
</PageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (let page = min; page <= max; ++page) {
|
||||||
|
pageButtons.push(
|
||||||
|
<PageItem
|
||||||
|
active={page === value}
|
||||||
|
key={page}
|
||||||
|
onClick={onClick}
|
||||||
|
value={page}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ellipsis && max !== pages) {
|
||||||
|
pageButtons.push(
|
||||||
|
<PageItem disabled key='lastEllipsis'>
|
||||||
|
…
|
||||||
|
</PageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<nav aria-label={ariaLabel}>
|
||||||
|
<ul className='pagination'>
|
||||||
|
{prev && (
|
||||||
|
<PageItem
|
||||||
|
aria-label='Previous'
|
||||||
|
disabled={value === 1}
|
||||||
|
onClick={onClick}
|
||||||
|
value={value - 1}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</PageItem>
|
||||||
|
)}
|
||||||
|
{pageButtons}
|
||||||
|
{next && (
|
||||||
|
<PageItem
|
||||||
|
aria-label='Next'
|
||||||
|
disabled={value === pages}
|
||||||
|
onClick={onClick}
|
||||||
|
value={value + 1}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</PageItem>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
45
packages/xo-web/src/common/prop-types-decorator.js
Normal file
45
packages/xo-web/src/common/prop-types-decorator.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import assign from 'lodash/assign'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
|
// Deprecated because :
|
||||||
|
// - unnecessary
|
||||||
|
// - not standard in the React ecosystem
|
||||||
|
if (__DEV__) {
|
||||||
|
console.warn(`DEPRECATED: use prop-types directly:
|
||||||
|
class MyComponent extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
foo: PropTypes.string.isRequired
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorators to help declaring properties and context types on React
|
||||||
|
// components without using the tedious static properties syntax.
|
||||||
|
//
|
||||||
|
// ```js
|
||||||
|
// @propTypes({
|
||||||
|
// children: propTypes.node.isRequired
|
||||||
|
// }, {
|
||||||
|
// store: propTypes.object.isRequired
|
||||||
|
// })
|
||||||
|
// class MyComponent extends React.Component {}
|
||||||
|
// ```
|
||||||
|
const propTypes = (propTypes, contextTypes) => target => {
|
||||||
|
if (propTypes !== undefined) {
|
||||||
|
target.propTypes = {
|
||||||
|
...target.propTypes,
|
||||||
|
...propTypes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contextTypes !== undefined) {
|
||||||
|
target.contextTypes = {
|
||||||
|
...target.contextTypes,
|
||||||
|
...contextTypes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
assign(propTypes, PropTypes)
|
||||||
|
|
||||||
|
export { propTypes as default }
|
169
packages/xo-web/src/common/react-novnc.js
vendored
Normal file
169
packages/xo-web/src/common/react-novnc.js
vendored
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import RFB from '@nraynaud/novnc/lib/rfb'
|
||||||
|
import URL from 'url-parse'
|
||||||
|
import { createBackoff } from 'jsonrpc-websocket-client'
|
||||||
|
import {
|
||||||
|
enable as enableShortcuts,
|
||||||
|
disable as disableShortcuts,
|
||||||
|
} from 'shortcuts'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const PROTOCOL_ALIASES = {
|
||||||
|
'http:': 'ws:',
|
||||||
|
'https:': 'wss:',
|
||||||
|
}
|
||||||
|
const fixProtocol = url => {
|
||||||
|
const protocol = PROTOCOL_ALIASES[url.protocol]
|
||||||
|
if (protocol) {
|
||||||
|
url.protocol = protocol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
onClipboardChange: propTypes.func,
|
||||||
|
url: propTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
export default class NoVnc extends Component {
|
||||||
|
constructor (props) {
|
||||||
|
super(props)
|
||||||
|
this._rfb = null
|
||||||
|
this._retryGen = createBackoff(Infinity)
|
||||||
|
|
||||||
|
this._onUpdateState = (rfb, state) => {
|
||||||
|
if (state === 'normal') {
|
||||||
|
if (this._retryTimeout) {
|
||||||
|
clearTimeout(this._retryTimeout)
|
||||||
|
this._retryTimeout = undefined
|
||||||
|
this._retryGen = createBackoff(Infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'disconnected' || this.refs.canvas == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this._retryTimeout)
|
||||||
|
this._retryTimeout = setTimeout(
|
||||||
|
this._connect,
|
||||||
|
this._retryGen.next().value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCtrlAltDel () {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb) {
|
||||||
|
rfb.sendCtrlAltDel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setClipboard (text) {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb) {
|
||||||
|
rfb.clipboardPasteFrom(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clean () {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb) {
|
||||||
|
this._rfb = null
|
||||||
|
rfb.disconnect()
|
||||||
|
}
|
||||||
|
enableShortcuts()
|
||||||
|
}
|
||||||
|
|
||||||
|
_connect = () => {
|
||||||
|
this._clean()
|
||||||
|
|
||||||
|
const { canvas } = this.refs
|
||||||
|
if (!canvas) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(this.props.url)
|
||||||
|
fixProtocol(url)
|
||||||
|
|
||||||
|
const isSecure = url.protocol === 'wss:'
|
||||||
|
|
||||||
|
const { onClipboardChange } = this.props
|
||||||
|
const rfb = (this._rfb = new RFB({
|
||||||
|
encrypt: isSecure,
|
||||||
|
target: this.refs.canvas,
|
||||||
|
onClipboard:
|
||||||
|
onClipboardChange &&
|
||||||
|
((_, text) => {
|
||||||
|
onClipboardChange(text)
|
||||||
|
}),
|
||||||
|
onUpdateState: this._onUpdateState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove leading slashes from the path
|
||||||
|
//
|
||||||
|
// a leading slassh will be added by noVNC
|
||||||
|
const clippedPath = url.pathname.replace(/^\/+/, '')
|
||||||
|
|
||||||
|
// a port is required
|
||||||
|
//
|
||||||
|
// if not available from the URL, use the default ones
|
||||||
|
const port = url.port || (isSecure ? 443 : 80)
|
||||||
|
|
||||||
|
rfb.connect(url.hostname, port, null, clippedPath)
|
||||||
|
disableShortcuts()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
this._clean()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (props) {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb && this.props.scale !== props.scale) {
|
||||||
|
rfb.get_display().set_scale(props.scale || 1)
|
||||||
|
rfb.get_mouse().set_scale(props.scale || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_focus = () => {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb) {
|
||||||
|
const { activeElement } = document
|
||||||
|
if (activeElement) {
|
||||||
|
activeElement.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
rfb.get_keyboard().grab()
|
||||||
|
rfb.get_mouse().grab()
|
||||||
|
|
||||||
|
disableShortcuts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_unfocus = () => {
|
||||||
|
const rfb = this._rfb
|
||||||
|
if (rfb) {
|
||||||
|
rfb.get_keyboard().ungrab()
|
||||||
|
rfb.get_mouse().ungrab()
|
||||||
|
|
||||||
|
enableShortcuts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
className='center-block'
|
||||||
|
height='480'
|
||||||
|
onMouseEnter={this._focus}
|
||||||
|
onMouseLeave={this._unfocus}
|
||||||
|
ref='canvas'
|
||||||
|
width='640'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
265
packages/xo-web/src/common/render-xo-item.js
Normal file
265
packages/xo-web/src/common/render-xo-item.js
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import _ from 'intl'
|
||||||
|
import React from 'react'
|
||||||
|
import { startsWith } from 'lodash'
|
||||||
|
|
||||||
|
import Icon from './icon'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import { createGetObject } from './selectors'
|
||||||
|
import { isSrWritable } from './xo'
|
||||||
|
import { connectStore, formatSize } from './utils'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const OBJECT_TYPE_TO_ICON = {
|
||||||
|
'VM-template': 'vm',
|
||||||
|
host: 'host',
|
||||||
|
network: 'network',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host, Network, VM-template.
|
||||||
|
const PoolObjectItem = propTypes({
|
||||||
|
object: propTypes.object.isRequired,
|
||||||
|
})(
|
||||||
|
connectStore(() => {
|
||||||
|
const getPool = createGetObject((_, props) => props.object.$pool)
|
||||||
|
|
||||||
|
return (state, props) => ({
|
||||||
|
pool: getPool(state, props),
|
||||||
|
})
|
||||||
|
})(({ object, pool }) => {
|
||||||
|
const icon = OBJECT_TYPE_TO_ICON[object.type]
|
||||||
|
const { id } = object
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<Icon icon={icon} /> {`${object.name_label || id} `}
|
||||||
|
{pool && `(${pool.name_label || pool.id})`}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// SR.
|
||||||
|
const SrItem = propTypes({
|
||||||
|
sr: propTypes.object.isRequired,
|
||||||
|
})(
|
||||||
|
connectStore(() => {
|
||||||
|
const getContainer = createGetObject((_, props) => props.sr.$container)
|
||||||
|
|
||||||
|
return (state, props) => ({
|
||||||
|
container: getContainer(state, props),
|
||||||
|
})
|
||||||
|
})(({ sr, container }) => {
|
||||||
|
let label = `${sr.name_label || sr.id}`
|
||||||
|
|
||||||
|
if (isSrWritable(sr)) {
|
||||||
|
label += ` (${formatSize(sr.size - sr.physical_usage)} free)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<Icon icon='sr' /> {label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// VM.
|
||||||
|
const VmItem = propTypes({
|
||||||
|
vm: propTypes.object.isRequired,
|
||||||
|
})(
|
||||||
|
connectStore(() => {
|
||||||
|
const getContainer = createGetObject((_, props) => props.vm.$container)
|
||||||
|
|
||||||
|
return (state, props) => ({
|
||||||
|
container: getContainer(state, props),
|
||||||
|
})
|
||||||
|
})(({ vm, container }) => (
|
||||||
|
<span>
|
||||||
|
<Icon icon={`vm-${vm.power_state.toLowerCase()}`} />{' '}
|
||||||
|
{vm.name_label || vm.id}
|
||||||
|
{container && ` (${container.name_label || container.id})`}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
const VgpuItem = connectStore(() => ({
|
||||||
|
vgpuType: createGetObject((_, props) => props.vgpu.vgpuType),
|
||||||
|
}))(({ vgpu, vgpuType }) => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='vgpu' /> {vgpuType.modelName}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const xoItemToRender = {
|
||||||
|
// Subscription objects.
|
||||||
|
group: group => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='group' /> {group.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
remote: remote => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='remote' /> {remote.value.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
role: role => <span>{role.name}</span>,
|
||||||
|
user: user => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='user' /> {user.email}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
resourceSet: resourceSet => (
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
<Icon icon='resource-set' /> {resourceSet.name}
|
||||||
|
</strong>{' '}
|
||||||
|
({resourceSet.id})
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
sshKey: key => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='ssh-key' /> {key.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
ipPool: ipPool => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='ip' /> {ipPool.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
ipAddress: ({ label, used }) => {
|
||||||
|
if (used) {
|
||||||
|
return <strong className='text-warning'>{label}</strong>
|
||||||
|
}
|
||||||
|
return <span>{label}</span>
|
||||||
|
},
|
||||||
|
|
||||||
|
// XO objects.
|
||||||
|
pool: pool => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='pool' /> {pool.name_label || pool.id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
VDI: vdi => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='disk' /> {vdi.name_label}{' '}
|
||||||
|
{vdi.name_description && <span> ({vdi.name_description})</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
// Pool objects.
|
||||||
|
'VM-template': vmTemplate => <PoolObjectItem object={vmTemplate} />,
|
||||||
|
host: host => <PoolObjectItem object={host} />,
|
||||||
|
network: network => <PoolObjectItem object={network} />,
|
||||||
|
|
||||||
|
// SR.
|
||||||
|
SR: sr => <SrItem sr={sr} />,
|
||||||
|
|
||||||
|
// VM.
|
||||||
|
VM: vm => <VmItem vm={vm} />,
|
||||||
|
'VM-snapshot': vm => <VmItem vm={vm} />,
|
||||||
|
'VM-controller': vm => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='host' /> <VmItem vm={vm} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
// PIF.
|
||||||
|
PIF: pif => (
|
||||||
|
<span>
|
||||||
|
<Icon
|
||||||
|
icon='network'
|
||||||
|
color={pif.carrier ? 'text-success' : 'text-danger'}
|
||||||
|
/>{' '}
|
||||||
|
{pif.device} ({pif.deviceName})
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tags.
|
||||||
|
tag: tag => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='tag' /> {tag.value}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
// GPUs
|
||||||
|
|
||||||
|
vgpu: vgpu => <VgpuItem vgpu={vgpu} />,
|
||||||
|
|
||||||
|
vgpuType: type => (
|
||||||
|
<span>
|
||||||
|
<Icon icon='gpu' /> {type.modelName} ({type.vendorName}){' '}
|
||||||
|
{type.maxResolutionX}x{type.maxResolutionY}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
|
||||||
|
gpuGroup: group => (
|
||||||
|
<span>
|
||||||
|
{startsWith(group.name_label, 'Group of ')
|
||||||
|
? group.name_label.slice(9)
|
||||||
|
: group.name_label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderXoItem = (item, { className } = {}) => {
|
||||||
|
const { id, type, label } = item
|
||||||
|
|
||||||
|
if (item.removed) {
|
||||||
|
return (
|
||||||
|
<span key={id} className='text-danger'>
|
||||||
|
{' '}
|
||||||
|
<Icon icon='alarm' /> {id}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
if (process.env.NODE_ENV !== 'production' && !label) {
|
||||||
|
throw new Error(`an item must have at least either a type or a label`)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={id} className={className}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = xoItemToRender[type]
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production' && !Component) {
|
||||||
|
throw new Error(`no available component for type ${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Component) {
|
||||||
|
return (
|
||||||
|
<span key={id} className={className}>
|
||||||
|
<Component {...item} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { renderXoItem as default }
|
||||||
|
|
||||||
|
const GenericXoItem = connectStore(() => {
|
||||||
|
const getObject = createGetObject()
|
||||||
|
|
||||||
|
return (state, props) => ({
|
||||||
|
xoItem: getObject(state, props),
|
||||||
|
})
|
||||||
|
})(
|
||||||
|
({ xoItem, ...props }) =>
|
||||||
|
xoItem ? renderXoItem(xoItem, props) : renderXoUnknownItem()
|
||||||
|
)
|
||||||
|
|
||||||
|
export const renderXoItemFromId = (id, props) => (
|
||||||
|
<GenericXoItem {...props} id={id} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export const renderXoUnknownItem = () => (
|
||||||
|
<span className='text-muted'>{_('errorNoSuchItem')}</span>
|
||||||
|
)
|
127
packages/xo-web/src/common/resource-set-quotas.js
Normal file
127
packages/xo-web/src/common/resource-set-quotas.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import _, { messages } from 'intl'
|
||||||
|
import ChartistGraph from 'react-chartist'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardBlock, CardHeader } from 'card'
|
||||||
|
import { Container, Row, Col } from 'grid'
|
||||||
|
import { forEach, map } from 'lodash'
|
||||||
|
import { injectIntl } from 'react-intl'
|
||||||
|
|
||||||
|
import Component from './base-component'
|
||||||
|
import Icon from './icon'
|
||||||
|
import { createSelector } from './selectors'
|
||||||
|
import { formatSize } from './utils'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const RESOURCES = ['disk', 'memory', 'cpus']
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@injectIntl
|
||||||
|
export default class ResourceSetQuotas extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
limits: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
_getQuotas = createSelector(
|
||||||
|
() => this.props.limits,
|
||||||
|
limits => {
|
||||||
|
const quotas = {}
|
||||||
|
|
||||||
|
forEach(RESOURCES, resource => {
|
||||||
|
if (limits[resource] != null) {
|
||||||
|
const { available, total } = limits[resource]
|
||||||
|
quotas[resource] = {
|
||||||
|
available,
|
||||||
|
total,
|
||||||
|
usage: total - available,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return quotas
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl: { formatMessage } } = this.props
|
||||||
|
const labels = [
|
||||||
|
formatMessage(messages.availableResourceLabel),
|
||||||
|
formatMessage(messages.usedResourceLabel),
|
||||||
|
]
|
||||||
|
const { cpus, disk, memory } = this._getQuotas()
|
||||||
|
const quotas = [
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<span>
|
||||||
|
<Icon icon='cpu' /> {_('cpuStatePanel')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
validFormat: true,
|
||||||
|
quota: cpus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<span>
|
||||||
|
<Icon icon='memory' /> {_('memoryStatePanel')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
validFormat: false,
|
||||||
|
quota: memory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<span>
|
||||||
|
<Icon icon='disk' /> {_('srUsageStatePanel')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
validFormat: false,
|
||||||
|
quota: disk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Row>
|
||||||
|
{map(quotas, ({ header, validFormat, quota }, key) => (
|
||||||
|
<Col key={key} mediumSize={4}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{header}</CardHeader>
|
||||||
|
<CardBlock className='text-center'>
|
||||||
|
{quota !== undefined ? (
|
||||||
|
<div>
|
||||||
|
<ChartistGraph
|
||||||
|
data={{
|
||||||
|
labels,
|
||||||
|
series: [quota.available, quota.usage],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
donut: true,
|
||||||
|
donutWidth: 40,
|
||||||
|
showLabel: false,
|
||||||
|
}}
|
||||||
|
type='Pie'
|
||||||
|
/>
|
||||||
|
<p className='text-xs-center'>
|
||||||
|
{_('resourceSetQuota', {
|
||||||
|
total: validFormat
|
||||||
|
? quota.total.toString()
|
||||||
|
: formatSize(quota.total),
|
||||||
|
usage: validFormat
|
||||||
|
? quota.usage.toString()
|
||||||
|
: formatSize(quota.usage),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className='text-xs-center display-1'>∞</p>
|
||||||
|
)}
|
||||||
|
</CardBlock>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
582
packages/xo-web/src/common/scheduling.js
Normal file
582
packages/xo-web/src/common/scheduling.js
Normal file
@ -0,0 +1,582 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import later from 'later'
|
||||||
|
import React from 'react'
|
||||||
|
import { FormattedDate, FormattedTime } from 'react-intl'
|
||||||
|
import { forEach, includes, isArray, map, sortedIndex } from 'lodash'
|
||||||
|
|
||||||
|
import _ from './intl'
|
||||||
|
import Button from './button'
|
||||||
|
import Component from './base-component'
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
import TimezonePicker from './timezone-picker'
|
||||||
|
import Icon from './icon'
|
||||||
|
import Tooltip from './tooltip'
|
||||||
|
import { Card, CardHeader, CardBlock } from './card'
|
||||||
|
import { Col, Row } from './grid'
|
||||||
|
import { Range, Toggle } from './form'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
// By default, later uses UTC but we use this line for future versions.
|
||||||
|
later.date.UTC()
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const CLICKABLE = { cursor: 'pointer' }
|
||||||
|
const PREVIEW_SLIDER_STYLE = { width: '400px' }
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const UNITS = ['minute', 'hour', 'monthDay', 'month', 'weekDay']
|
||||||
|
|
||||||
|
const MINUTES_RANGE = [2, 30]
|
||||||
|
const HOURS_RANGE = [2, 12]
|
||||||
|
const MONTH_DAYS_RANGE = [2, 15]
|
||||||
|
const MONTHS_RANGE = [2, 6]
|
||||||
|
|
||||||
|
const MIN_PREVIEWS = 5
|
||||||
|
const MAX_PREVIEWS = 20
|
||||||
|
|
||||||
|
const MONTHS = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11]]
|
||||||
|
|
||||||
|
const DAYS = (() => {
|
||||||
|
const days = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
days[i] = []
|
||||||
|
|
||||||
|
for (let j = 1; j < 8; j++) {
|
||||||
|
days[i].push(7 * i + j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push([29, 30, 31])
|
||||||
|
|
||||||
|
return days
|
||||||
|
})()
|
||||||
|
|
||||||
|
const WEEK_DAYS = [[0, 1, 2], [3, 4, 5], [6]]
|
||||||
|
|
||||||
|
const HOURS = (() => {
|
||||||
|
const hours = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
hours[i] = []
|
||||||
|
|
||||||
|
for (let j = 0; j < 6; j++) {
|
||||||
|
hours[i].push(6 * i + j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hours
|
||||||
|
})()
|
||||||
|
|
||||||
|
const MINS = (() => {
|
||||||
|
const minutes = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
minutes[i] = []
|
||||||
|
|
||||||
|
for (let j = 0; j < 10; j++) {
|
||||||
|
minutes[i].push(10 * i + j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minutes
|
||||||
|
})()
|
||||||
|
|
||||||
|
const PICKTIME_TO_ID = {
|
||||||
|
minute: 0,
|
||||||
|
hour: 1,
|
||||||
|
monthDay: 2,
|
||||||
|
month: 3,
|
||||||
|
weekDay: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FORMAT = {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
|
||||||
|
// The timezone is not significant for displaying the date previews
|
||||||
|
// as long as it is the same used to generate the next occurrences
|
||||||
|
// from the cron patterns.
|
||||||
|
|
||||||
|
// Therefore we can use UTC everywhere and say to the user that the
|
||||||
|
// previews are in the configured timezone.
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
// monthNum: [ 0 : 11 ]
|
||||||
|
const getMonthName = monthNum => (
|
||||||
|
<FormattedDate value={Date.UTC(1970, monthNum)} month='long' timeZone='UTC' />
|
||||||
|
)
|
||||||
|
|
||||||
|
// dayNum: [ 0 : 6 ]
|
||||||
|
const getDayName = dayNum => (
|
||||||
|
// January, 1970, 5th => Monday
|
||||||
|
<FormattedDate
|
||||||
|
value={Date.UTC(1970, 0, 4 + dayNum)}
|
||||||
|
weekday='long'
|
||||||
|
timeZone='UTC'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
cronPattern: propTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
export class SchedulePreview extends Component {
|
||||||
|
render () {
|
||||||
|
const { cronPattern } = this.props
|
||||||
|
const { value } = this.state
|
||||||
|
|
||||||
|
const cronSched = later.parse.cron(cronPattern)
|
||||||
|
|
||||||
|
// Due to implementation, the range used for months is 0-11
|
||||||
|
// instead of 1-12
|
||||||
|
forEach(cronSched.schedules[0].M, (v, i, a) => {
|
||||||
|
a[i] = v + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const dates = later.schedule(cronSched).next(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='alert alert-info' role='alert'>
|
||||||
|
{_('cronPattern')} <strong>{cronPattern}</strong>
|
||||||
|
</div>
|
||||||
|
<div className='mb-1' style={PREVIEW_SLIDER_STYLE}>
|
||||||
|
<Range
|
||||||
|
min={MIN_PREVIEWS}
|
||||||
|
max={MAX_PREVIEWS}
|
||||||
|
onChange={this.linkState('value')}
|
||||||
|
value={+value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className='list-group'>
|
||||||
|
{map(dates, (date, id) => (
|
||||||
|
<li className='list-group-item' key={id}>
|
||||||
|
<FormattedTime value={date} {...TIME_FORMAT} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className='list-group-item'>...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
children: propTypes.any.isRequired,
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
tdId: propTypes.number.isRequired,
|
||||||
|
value: propTypes.bool.isRequired,
|
||||||
|
})
|
||||||
|
class ToggleTd extends Component {
|
||||||
|
_onClick = () => {
|
||||||
|
const { props } = this
|
||||||
|
props.onChange(props.tdId, !props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { props } = this
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={classNames('text-xs-center', props.value && 'table-success')}
|
||||||
|
onClick={this._onClick}
|
||||||
|
style={CLICKABLE}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
labelId: propTypes.string.isRequired,
|
||||||
|
options: propTypes.array.isRequired,
|
||||||
|
optionRenderer: propTypes.func,
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
value: propTypes.array.isRequired,
|
||||||
|
})
|
||||||
|
class TableSelect extends Component {
|
||||||
|
static defaultProps = {
|
||||||
|
optionRenderer: value => value,
|
||||||
|
}
|
||||||
|
|
||||||
|
_reset = () => {
|
||||||
|
this.props.onChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChange = (tdId, tdValue) => {
|
||||||
|
const { props } = this
|
||||||
|
|
||||||
|
const newValue = props.value.slice()
|
||||||
|
const index = sortedIndex(newValue, tdId)
|
||||||
|
|
||||||
|
if (tdValue) {
|
||||||
|
// Add
|
||||||
|
if (newValue[index] !== tdId) {
|
||||||
|
newValue.splice(index, 0, tdId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove
|
||||||
|
if (newValue[index] === tdId) {
|
||||||
|
newValue.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onChange(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { labelId, options, optionRenderer, value } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<table className='table table-bordered table-sm'>
|
||||||
|
<tbody>
|
||||||
|
{map(options, (line, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{map(line, tdOption => (
|
||||||
|
<ToggleTd
|
||||||
|
children={optionRenderer(tdOption)}
|
||||||
|
tdId={tdOption}
|
||||||
|
key={tdOption}
|
||||||
|
onChange={this._handleChange}
|
||||||
|
value={includes(value, tdOption)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Button className='pull-right' onClick={this._reset}>
|
||||||
|
{_(`selectTableAll${labelId}`)}{' '}
|
||||||
|
{value && !value.length && <Icon icon='success' />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
// "2,7" => [2,7] "*/2" => 2 "*" => []
|
||||||
|
const cronToValue = (cron, range) => {
|
||||||
|
if (cron.indexOf('/') === 1) {
|
||||||
|
return +cron.split('/')[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cron === '*') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(cron.split(','), Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [2,7] => "2,7" 2 => "*/2" [] => "*"
|
||||||
|
const valueToCron = value => {
|
||||||
|
if (!isArray(value)) {
|
||||||
|
return `*/${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.length) {
|
||||||
|
return '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
headerAddon: propTypes.node,
|
||||||
|
optionRenderer: propTypes.func,
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
range: propTypes.array,
|
||||||
|
labelId: propTypes.string.isRequired,
|
||||||
|
value: propTypes.any.isRequired,
|
||||||
|
})
|
||||||
|
class TimePicker extends Component {
|
||||||
|
_update = cron => {
|
||||||
|
const { tableValue, rangeValue } = this.state
|
||||||
|
|
||||||
|
const newValue = cronToValue(cron)
|
||||||
|
const periodic = !isArray(newValue)
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
periodic,
|
||||||
|
tableValue: periodic ? tableValue : newValue,
|
||||||
|
rangeValue: periodic ? newValue : rangeValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (props) {
|
||||||
|
if (props.value !== this.props.value) {
|
||||||
|
this._update(props.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._update(this.props.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = value => {
|
||||||
|
this.props.onChange(valueToCron(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
_tableTab = () => this._onChange(this.state.tableValue || [])
|
||||||
|
_periodicTab = () =>
|
||||||
|
this._onChange(this.state.rangeValue || this.props.range[0])
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { headerAddon, labelId, options, optionRenderer, range } = this.props
|
||||||
|
|
||||||
|
const { periodic, tableValue, rangeValue } = this.state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
{_(`scheduling${labelId}`)}
|
||||||
|
{headerAddon}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBlock>
|
||||||
|
{range && (
|
||||||
|
<ul className='nav nav-tabs mb-1'>
|
||||||
|
<li className='nav-item'>
|
||||||
|
<a
|
||||||
|
onClick={this._tableTab}
|
||||||
|
className={classNames('nav-link', !periodic && 'active')}
|
||||||
|
style={CLICKABLE}
|
||||||
|
>
|
||||||
|
{_(`schedulingEachSelected${labelId}`)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className='nav-item'>
|
||||||
|
<a
|
||||||
|
onClick={this._periodicTab}
|
||||||
|
className={classNames('nav-link', periodic && 'active')}
|
||||||
|
style={CLICKABLE}
|
||||||
|
>
|
||||||
|
{_(`schedulingEveryN${labelId}`)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{periodic ? (
|
||||||
|
<Range
|
||||||
|
ref='range'
|
||||||
|
min={range[0]}
|
||||||
|
max={range[1]}
|
||||||
|
onChange={this._onChange}
|
||||||
|
value={rangeValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TableSelect
|
||||||
|
labelId={labelId}
|
||||||
|
onChange={this._onChange}
|
||||||
|
options={options}
|
||||||
|
optionRenderer={optionRenderer}
|
||||||
|
value={tableValue || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardBlock>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWeekDayMode = ({ monthDayPattern, weekDayPattern }) => {
|
||||||
|
if (monthDayPattern === '*' && weekDayPattern === '*') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return weekDayPattern !== '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
monthDayPattern: propTypes.string.isRequired,
|
||||||
|
weekDayPattern: propTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
class DayPicker extends Component {
|
||||||
|
state = {
|
||||||
|
weekDayMode: isWeekDayMode(this.props),
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (props) {
|
||||||
|
const weekDayMode = isWeekDayMode(props)
|
||||||
|
|
||||||
|
if (weekDayMode !== undefined) {
|
||||||
|
this.setState({ weekDayMode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setWeekDayMode = weekDayMode => {
|
||||||
|
this.props.onChange(['*', '*'])
|
||||||
|
this.setState({ weekDayMode })
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange = cron => {
|
||||||
|
const isMonthDayPattern = !this.state.weekDayMode || includes(cron, '/')
|
||||||
|
|
||||||
|
this.props.onChange([
|
||||||
|
isMonthDayPattern ? cron : '*',
|
||||||
|
isMonthDayPattern ? '*' : cron,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { monthDayPattern, weekDayPattern } = this.props
|
||||||
|
const { weekDayMode } = this.state
|
||||||
|
|
||||||
|
const dayModeToggle = (
|
||||||
|
<Tooltip
|
||||||
|
content={_(
|
||||||
|
weekDayMode ? 'schedulingSetMonthDayMode' : 'schedulingSetWeekDayMode'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className='pull-right'>
|
||||||
|
<Toggle
|
||||||
|
onChange={this._setWeekDayMode}
|
||||||
|
iconSize={1}
|
||||||
|
value={weekDayMode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimePicker
|
||||||
|
headerAddon={dayModeToggle}
|
||||||
|
key={weekDayMode ? 'week' : 'month'}
|
||||||
|
labelId='Day'
|
||||||
|
optionRenderer={weekDayMode ? getDayName : undefined}
|
||||||
|
options={weekDayMode ? WEEK_DAYS : DAYS}
|
||||||
|
onChange={this._onChange}
|
||||||
|
range={MONTH_DAYS_RANGE}
|
||||||
|
setWeekDayMode={this._setWeekDayMode}
|
||||||
|
value={weekDayMode ? weekDayPattern : monthDayPattern}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
cronPattern: propTypes.string,
|
||||||
|
onChange: propTypes.func,
|
||||||
|
timezone: propTypes.string,
|
||||||
|
value: propTypes.shape({
|
||||||
|
cronPattern: propTypes.string.isRequired,
|
||||||
|
timezone: propTypes.string,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export default class Scheduler extends Component {
|
||||||
|
constructor (props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this._onCronChange = newCrons => {
|
||||||
|
const cronPattern = this._getCronPattern().split(' ')
|
||||||
|
forEach(newCrons, (cron, unit) => {
|
||||||
|
cronPattern[PICKTIME_TO_ID[unit]] = cron
|
||||||
|
})
|
||||||
|
|
||||||
|
this.props.onChange({
|
||||||
|
cronPattern: cronPattern.join(' '),
|
||||||
|
timezone: this._getTimezone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(UNITS, unit => {
|
||||||
|
this[`_${unit}Change`] = cron => this._onCronChange({ [unit]: cron })
|
||||||
|
})
|
||||||
|
this._dayChange = ([monthDay, weekDay]) =>
|
||||||
|
this._onCronChange({ monthDay, weekDay })
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTimezoneChange = timezone => {
|
||||||
|
this.props.onChange({
|
||||||
|
cronPattern: this._getCronPattern(),
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCronPattern = () => {
|
||||||
|
const { value, cronPattern = value.cronPattern } = this.props
|
||||||
|
return cronPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
_getTimezone = () => {
|
||||||
|
const { value, timezone = value && value.timezone } = this.props
|
||||||
|
return timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const cronPatternArr = this._getCronPattern().split(' ')
|
||||||
|
const timezone = this._getTimezone()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='card-block'>
|
||||||
|
<Row>
|
||||||
|
<Col largeSize={6}>
|
||||||
|
<TimePicker
|
||||||
|
labelId='Month'
|
||||||
|
optionRenderer={getMonthName}
|
||||||
|
options={MONTHS}
|
||||||
|
onChange={this._monthChange}
|
||||||
|
range={MONTHS_RANGE}
|
||||||
|
value={cronPatternArr[PICKTIME_TO_ID['month']]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col largeSize={6}>
|
||||||
|
<DayPicker
|
||||||
|
onChange={this._dayChange}
|
||||||
|
monthDayPattern={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
|
||||||
|
weekDayPattern={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col largeSize={6}>
|
||||||
|
<TimePicker
|
||||||
|
labelId='Hour'
|
||||||
|
options={HOURS}
|
||||||
|
range={HOURS_RANGE}
|
||||||
|
onChange={this._hourChange}
|
||||||
|
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col largeSize={6}>
|
||||||
|
<TimePicker
|
||||||
|
labelId='Minute'
|
||||||
|
options={MINS}
|
||||||
|
range={MINUTES_RANGE}
|
||||||
|
onChange={this._minuteChange}
|
||||||
|
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<hr />
|
||||||
|
<TimezonePicker
|
||||||
|
value={timezone}
|
||||||
|
onChange={this._onTimezoneChange}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
34
packages/xo-web/src/common/select-files.js
Normal file
34
packages/xo-web/src/common/select-files.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import _ from 'intl'
|
||||||
|
import Component from 'base-component'
|
||||||
|
import Icon from 'icon'
|
||||||
|
import propTypes from 'prop-types-decorator'
|
||||||
|
import React from 'react'
|
||||||
|
import { omit } from 'lodash'
|
||||||
|
|
||||||
|
@propTypes({
|
||||||
|
multi: propTypes.bool,
|
||||||
|
label: propTypes.node,
|
||||||
|
onChange: propTypes.func.isRequired,
|
||||||
|
})
|
||||||
|
export default class SelectFiles extends Component {
|
||||||
|
_onChange = e => {
|
||||||
|
const { multi, onChange } = this.props
|
||||||
|
const { files } = e.target
|
||||||
|
|
||||||
|
onChange(multi ? files : files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<label className='btn btn-secondary btn-file hidden'>
|
||||||
|
<Icon icon='file' /> {this.props.label || _('browseFiles')}
|
||||||
|
<input
|
||||||
|
{...omit(this.props, ['hidden', 'label', 'onChange', 'multi'])}
|
||||||
|
hidden
|
||||||
|
onChange={this._onChange}
|
||||||
|
type='file'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
1017
packages/xo-web/src/common/select-objects.js
Normal file
1017
packages/xo-web/src/common/select-objects.js
Normal file
File diff suppressed because it is too large
Load Diff
542
packages/xo-web/src/common/selectors.js
Normal file
542
packages/xo-web/src/common/selectors.js
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
import add from 'lodash/add'
|
||||||
|
import checkPermissions from 'xo-acl-resolver'
|
||||||
|
import { createSelector as create } from 'reselect'
|
||||||
|
import {
|
||||||
|
filter,
|
||||||
|
find,
|
||||||
|
forEach,
|
||||||
|
groupBy,
|
||||||
|
isArray,
|
||||||
|
isArrayLike,
|
||||||
|
isFunction,
|
||||||
|
keys,
|
||||||
|
map,
|
||||||
|
orderBy,
|
||||||
|
pickBy,
|
||||||
|
size,
|
||||||
|
slice,
|
||||||
|
} from 'lodash'
|
||||||
|
|
||||||
|
import invoke from './invoke'
|
||||||
|
import shallowEqual from './shallow-equal'
|
||||||
|
import { EMPTY_ARRAY, EMPTY_OBJECT } from './utils'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// That's usually the name we want to import.
|
||||||
|
createSelector,
|
||||||
|
// But selectors.create is nice too :)
|
||||||
|
createSelector as create,
|
||||||
|
} from 'reselect'
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Wraps a function which returns a collection to returns the previous
|
||||||
|
// result if the collection has not really changed (ie still has the
|
||||||
|
// same items).
|
||||||
|
//
|
||||||
|
// Use case: in connect, to avoid rerendering a component where the
|
||||||
|
// objects are still the same.
|
||||||
|
const _createCollectionWrapper = selector => {
|
||||||
|
let cache, previous
|
||||||
|
|
||||||
|
return (...args) => {
|
||||||
|
const value = selector(...args)
|
||||||
|
if (value !== previous) {
|
||||||
|
previous = value
|
||||||
|
|
||||||
|
if (!shallowEqual(value, cache)) {
|
||||||
|
cache = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { _createCollectionWrapper as createCollectionWrapper }
|
||||||
|
|
||||||
|
const _SELECTOR_PLACEHOLDER = Symbol('selector placeholder')
|
||||||
|
|
||||||
|
// Experimental!
|
||||||
|
//
|
||||||
|
// Similar to reselect's createSelector() but inputs can be either
|
||||||
|
// selectors or plain values.
|
||||||
|
//
|
||||||
|
// To pass a function as a plain value, simply wrap it with an array.
|
||||||
|
const _create2 = (...inputs) => {
|
||||||
|
const resultFn = inputs.pop()
|
||||||
|
|
||||||
|
if (inputs.length === 1 && isArray(inputs[0])) {
|
||||||
|
inputs = inputs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = inputs.length
|
||||||
|
|
||||||
|
const inputSelectors = []
|
||||||
|
for (let i = 0; i < n; ++i) {
|
||||||
|
const input = inputs[i]
|
||||||
|
|
||||||
|
if (isFunction(input)) {
|
||||||
|
inputSelectors.push(input)
|
||||||
|
inputs[i] = _SELECTOR_PLACEHOLDER
|
||||||
|
} else if (isArray(input) && input.length === 1) {
|
||||||
|
inputs[i] = input[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputSelectors.length) {
|
||||||
|
throw new Error('no input selectors')
|
||||||
|
}
|
||||||
|
|
||||||
|
return create(inputSelectors, function () {
|
||||||
|
const args = new Array(n)
|
||||||
|
for (let i = 0, j = 0; i < n; ++i) {
|
||||||
|
const input = inputs[i]
|
||||||
|
args[i] = input === _SELECTOR_PLACEHOLDER ? arguments[j++] : input
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultFn.apply(this, args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Generic selector creators.
|
||||||
|
|
||||||
|
export const createCounter = (collection, predicate) =>
|
||||||
|
_create2(collection, predicate, (collection, predicate) => {
|
||||||
|
if (!predicate) {
|
||||||
|
return size(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
forEach(collection, item => {
|
||||||
|
if (predicate(item)) {
|
||||||
|
++count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Creates an object selector from an object selector and a properties
|
||||||
|
// selector.
|
||||||
|
//
|
||||||
|
// Should only be used with a reasonable number of properties.
|
||||||
|
export const createPicker = (object, props) =>
|
||||||
|
_create2(
|
||||||
|
object,
|
||||||
|
props,
|
||||||
|
_createCollectionWrapper((object, props) => {
|
||||||
|
const values = {}
|
||||||
|
forEach(props, prop => {
|
||||||
|
const value = object[prop]
|
||||||
|
if (value) {
|
||||||
|
values[prop] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return values
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Special cases:
|
||||||
|
// - predicate == null → no filtering
|
||||||
|
// - predicate === false → everything is filtered out
|
||||||
|
export const createFilter = (collection, predicate) =>
|
||||||
|
_create2(
|
||||||
|
collection,
|
||||||
|
predicate,
|
||||||
|
_createCollectionWrapper(
|
||||||
|
(collection, predicate) =>
|
||||||
|
predicate === false
|
||||||
|
? isArrayLike(collection) ? EMPTY_ARRAY : EMPTY_OBJECT
|
||||||
|
: predicate
|
||||||
|
? (isArrayLike(collection) ? filter : pickBy)(collection, predicate)
|
||||||
|
: collection
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createFinder = (collection, predicate) =>
|
||||||
|
_create2(collection, predicate, find)
|
||||||
|
|
||||||
|
export const createGroupBy = (collection, getter) =>
|
||||||
|
_create2(collection, getter, groupBy)
|
||||||
|
|
||||||
|
export const createPager = (array, page, n = 25) =>
|
||||||
|
_create2(
|
||||||
|
array,
|
||||||
|
page,
|
||||||
|
n,
|
||||||
|
_createCollectionWrapper((array, page, n) => {
|
||||||
|
const start = (page - 1) * n
|
||||||
|
return slice(array, start, start + n)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createSort = (collection, getter = 'name_label', order = 'asc') =>
|
||||||
|
_create2(collection, getter, order, orderBy)
|
||||||
|
|
||||||
|
export const createSumBy = (itemsSelector, iterateeSelector) =>
|
||||||
|
_create2(itemsSelector, iterateeSelector, (items, iteratee) =>
|
||||||
|
map(items, iteratee).reduce(add, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createTop = (collection, iteratee, n) =>
|
||||||
|
_create2(
|
||||||
|
collection,
|
||||||
|
iteratee,
|
||||||
|
n,
|
||||||
|
_createCollectionWrapper((objects, iteratee, n) => {
|
||||||
|
const results = orderBy(objects, iteratee, 'desc')
|
||||||
|
if (n < results.length) {
|
||||||
|
results.length = n
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Root-ish selectors (no dependencies).
|
||||||
|
|
||||||
|
export const areObjectsFetched = state => state.objects.fetched
|
||||||
|
|
||||||
|
const _getId = (state, { routeParams, id }) =>
|
||||||
|
routeParams ? routeParams.id : id
|
||||||
|
|
||||||
|
export const getLang = state => state.lang
|
||||||
|
|
||||||
|
export const getStatus = state => state.status
|
||||||
|
|
||||||
|
export const getUser = state => state.user
|
||||||
|
|
||||||
|
export const getCheckPermissions = invoke(() => {
|
||||||
|
const getPredicate = create(
|
||||||
|
state => state.permissions,
|
||||||
|
state => state.objects,
|
||||||
|
(permissions, objects) => {
|
||||||
|
objects = objects.all
|
||||||
|
const getObject = id => objects[id] || EMPTY_OBJECT
|
||||||
|
|
||||||
|
return (id, permission) =>
|
||||||
|
checkPermissions(permissions, getObject, id, permission)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isTrue = () => true
|
||||||
|
const isFalse = () => false
|
||||||
|
|
||||||
|
return state => {
|
||||||
|
const user = getUser(state)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.permission === 'admin') {
|
||||||
|
return isTrue
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPredicate(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const _getPermissionsPredicate = invoke(() => {
|
||||||
|
const getPredicate = create(
|
||||||
|
state => state.permissions,
|
||||||
|
state => state.objects,
|
||||||
|
(permissions, objects) => {
|
||||||
|
objects = objects.all
|
||||||
|
const getObject = id => objects[id] || EMPTY_OBJECT
|
||||||
|
|
||||||
|
return id => checkPermissions(permissions, getObject, id.id || id, 'view')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return state => {
|
||||||
|
const user = getUser(state)
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.permission === 'admin') {
|
||||||
|
return // No predicate means no filtering.
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPredicate(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const isAdmin = (...args) => {
|
||||||
|
const user = getUser(...args)
|
||||||
|
|
||||||
|
return user && user.permission === 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Common selector creators.
|
||||||
|
|
||||||
|
// Creates an object selector from an id selector.
|
||||||
|
export const createGetObject = (idSelector = _getId) => (
|
||||||
|
state,
|
||||||
|
props,
|
||||||
|
useResourceSet
|
||||||
|
) => {
|
||||||
|
const object = state.objects.all[idSelector(state, props)]
|
||||||
|
if (!object) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useResourceSet) {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const predicate = _getPermissionsPredicate(state)
|
||||||
|
|
||||||
|
if (!predicate) {
|
||||||
|
if (predicate == null) {
|
||||||
|
return object // no filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
// predicate is false.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predicate(object)) {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized createSort() configured for a given type.
|
||||||
|
export const createSortForType = invoke(() => {
|
||||||
|
const iterateesByType = {
|
||||||
|
message: message => message.time,
|
||||||
|
PIF: pif => pif.device,
|
||||||
|
pool: pool => pool.name_label,
|
||||||
|
pool_patch: patch => patch.name,
|
||||||
|
tag: tag => tag,
|
||||||
|
VBD: vbd => vbd.position,
|
||||||
|
'VDI-snapshot': snapshot => snapshot.snapshot_time,
|
||||||
|
'VM-snapshot': snapshot => snapshot.snapshot_time,
|
||||||
|
}
|
||||||
|
const defaultIteratees = [object => object.$pool, object => object.name_label]
|
||||||
|
const getIteratees = type => iterateesByType[type] || defaultIteratees
|
||||||
|
|
||||||
|
const ordersByType = {
|
||||||
|
message: 'desc',
|
||||||
|
'VDI-snapshot': 'desc',
|
||||||
|
'VM-snapshot': 'desc',
|
||||||
|
}
|
||||||
|
const getOrders = type => ordersByType[type]
|
||||||
|
|
||||||
|
const autoSelector = (type, fn) =>
|
||||||
|
isFunction(type) ? (state, props) => fn(type(state, props)) : [fn(type)]
|
||||||
|
|
||||||
|
return (type, collection) =>
|
||||||
|
createSort(
|
||||||
|
collection,
|
||||||
|
autoSelector(type, getIteratees),
|
||||||
|
autoSelector(type, getOrders)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add utility methods to a collection selector.
|
||||||
|
const _extendCollectionSelector = (selector, objectsType) => {
|
||||||
|
// Terminal methods.
|
||||||
|
const _addCount = selector => {
|
||||||
|
selector.count = predicate => createCounter(selector, predicate)
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
_addCount(selector)
|
||||||
|
const _addGroupBy = selector => {
|
||||||
|
selector.groupBy = getter => createGroupBy(selector, getter)
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
_addGroupBy(selector)
|
||||||
|
const _addFind = selector => {
|
||||||
|
selector.find = predicate => createFinder(selector, predicate)
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
_addFind(selector)
|
||||||
|
|
||||||
|
// groupBy can be chained.
|
||||||
|
const _addSort = selector => {
|
||||||
|
// TODO: maybe memoize when no idsSelector.
|
||||||
|
selector.sort = () => _addGroupBy(createSortForType(objectsType, selector))
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
_addSort(selector)
|
||||||
|
|
||||||
|
// count, groupBy and sort can be chained.
|
||||||
|
const _addFilter = selector => {
|
||||||
|
selector.filter = predicate =>
|
||||||
|
_addCount(_addGroupBy(_addSort(createFilter(selector, predicate))))
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
_addFilter(selector)
|
||||||
|
|
||||||
|
// filter, groupBy and sort can be chained.
|
||||||
|
selector.pick = idsSelector =>
|
||||||
|
_addFind(
|
||||||
|
_addFilter(_addGroupBy(_addSort(createPicker(selector, idsSelector))))
|
||||||
|
)
|
||||||
|
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a collection selector which returns all objects of a given
|
||||||
|
// type.
|
||||||
|
//
|
||||||
|
// The selector as the following methods:
|
||||||
|
//
|
||||||
|
// - count: returns a selector which returns the number of objects
|
||||||
|
// - filter: returns a selector which returns the objects filtered by
|
||||||
|
// a predicate (count, groupBy and sort can be chained)
|
||||||
|
// - find: returns a selector which returns the first object matching
|
||||||
|
// a predicate
|
||||||
|
// - groupBy: returns a selector which returns the objects grouped by
|
||||||
|
// a value determined by a getter selector
|
||||||
|
// - pick: returns a selector which returns only the objects with given
|
||||||
|
// ids (filter, find, groupBy and sort can be chained)
|
||||||
|
// - sort: returns a selector which returns the objects appropriately
|
||||||
|
// sorted (groupBy can be chained)
|
||||||
|
export const createGetObjectsOfType = type => {
|
||||||
|
const getObjects = isFunction(type)
|
||||||
|
? (state, props) => state.objects.byType[type(state, props)] || EMPTY_OBJECT
|
||||||
|
: state => state.objects.byType[type] || EMPTY_OBJECT
|
||||||
|
|
||||||
|
return _extendCollectionSelector(
|
||||||
|
createFilter(getObjects, _getPermissionsPredicate),
|
||||||
|
type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGetTags = collectionSelectors => {
|
||||||
|
if (!collectionSelectors) {
|
||||||
|
collectionSelectors = [
|
||||||
|
createGetObjectsOfType('host'),
|
||||||
|
createGetObjectsOfType('pool'),
|
||||||
|
createGetObjectsOfType('VM'),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTags = create(collectionSelectors, (...collections) => {
|
||||||
|
const tags = {}
|
||||||
|
|
||||||
|
const addTag = tag => {
|
||||||
|
tags[tag] = null
|
||||||
|
}
|
||||||
|
const addItemTags = item => {
|
||||||
|
forEach(item.tags, addTag)
|
||||||
|
}
|
||||||
|
const addCollectionTags = collection => {
|
||||||
|
forEach(collection, addItemTags)
|
||||||
|
}
|
||||||
|
forEach(collections, addCollectionTags)
|
||||||
|
|
||||||
|
return keys(tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
return _extendCollectionSelector(getTags, 'tag')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGetVmLastShutdownTime = (
|
||||||
|
getVmId = (_, { vm }) => (vm != null ? vm.id : undefined)
|
||||||
|
) =>
|
||||||
|
create(getVmId, createGetObjectsOfType('message'), (vmId, messages) => {
|
||||||
|
let max = null
|
||||||
|
forEach(messages, message => {
|
||||||
|
if (
|
||||||
|
message.$object === vmId &&
|
||||||
|
message.name === 'VM_SHUTDOWN' &&
|
||||||
|
(max === null || message.time > max)
|
||||||
|
) {
|
||||||
|
max = message.time
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return max
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createGetObjectMessages = objectSelector =>
|
||||||
|
createGetObjectsOfType('message')
|
||||||
|
.filter(
|
||||||
|
create(
|
||||||
|
(...args) => objectSelector(...args).id,
|
||||||
|
id => message => message.$object === id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
// Example of use:
|
||||||
|
// import store from 'store'
|
||||||
|
// const object = getObject(store.getState(), objectId)
|
||||||
|
// ...
|
||||||
|
export const getObject = createGetObject((_, id) => id)
|
||||||
|
|
||||||
|
export const createDoesHostNeedRestart = hostSelector => {
|
||||||
|
// XS < 7.1
|
||||||
|
const patchRequiresReboot = createGetObjectsOfType('pool_patch')
|
||||||
|
.pick(
|
||||||
|
// Returns the first patch of the host which requires it to be
|
||||||
|
// restarted.
|
||||||
|
create(
|
||||||
|
createGetObjectsOfType('host_patch')
|
||||||
|
.pick((state, props) => {
|
||||||
|
const host = hostSelector(state, props)
|
||||||
|
return host && host.patches
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
create(
|
||||||
|
(state, props) => {
|
||||||
|
const host = hostSelector(state, props)
|
||||||
|
return host && host.startTime
|
||||||
|
},
|
||||||
|
startTime => patch => patch.time > startTime
|
||||||
|
)
|
||||||
|
),
|
||||||
|
hostPatches => map(hostPatches, hostPatch => hostPatch.pool_patch)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.find([
|
||||||
|
({ guidance }) =>
|
||||||
|
find(
|
||||||
|
guidance,
|
||||||
|
action => action === 'restartHost' || action === 'restartXapi'
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
return create(
|
||||||
|
hostSelector,
|
||||||
|
(...args) => args,
|
||||||
|
(host, args) => host.rebootRequired || !!patchRequiresReboot(...args)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGetHostMetrics = hostSelector =>
|
||||||
|
create(
|
||||||
|
hostSelector,
|
||||||
|
_createCollectionWrapper(hosts => {
|
||||||
|
const metrics = {
|
||||||
|
count: 0,
|
||||||
|
cpus: 0,
|
||||||
|
memoryTotal: 0,
|
||||||
|
memoryUsage: 0,
|
||||||
|
}
|
||||||
|
forEach(hosts, host => {
|
||||||
|
metrics.count++
|
||||||
|
metrics.cpus += host.cpus.cores
|
||||||
|
metrics.memoryTotal += host.memory.size
|
||||||
|
metrics.memoryUsage += host.memory.usage
|
||||||
|
})
|
||||||
|
return metrics
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createGetVmDisks = vmSelector =>
|
||||||
|
createGetObjectsOfType('VDI').pick(
|
||||||
|
create(
|
||||||
|
createGetObjectsOfType('VBD').pick(
|
||||||
|
(state, props) => vmSelector(state, props).$VBDs
|
||||||
|
),
|
||||||
|
_createCollectionWrapper(vbds =>
|
||||||
|
map(vbds, vbd => (vbd.is_cd_drive ? undefined : vbd.VDI))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
49
packages/xo-web/src/common/shallow-equal.js
Normal file
49
packages/xo-web/src/common/shallow-equal.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import kindOf from 'kindof'
|
||||||
|
|
||||||
|
// Tests that two collections (arrays or objects) have strictly equals
|
||||||
|
// values (items or properties)
|
||||||
|
const shallowEqual = (c1, c2) => {
|
||||||
|
if (c1 === c2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = kindOf(c1)
|
||||||
|
if (type !== kindOf(c2)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'array') {
|
||||||
|
const { length } = c1
|
||||||
|
if (length !== c2.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < length; ++i) {
|
||||||
|
if (c1[i] !== c2[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = 0
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
for (const _ in c2) {
|
||||||
|
++n
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in c1) {
|
||||||
|
if (c1[key] !== c2[key]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
--n
|
||||||
|
}
|
||||||
|
|
||||||
|
return !n
|
||||||
|
}
|
||||||
|
export { shallowEqual as default }
|
35
packages/xo-web/src/common/shortcuts.js
Normal file
35
packages/xo-web/src/common/shortcuts.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import Component from 'base-component'
|
||||||
|
import forEach from 'lodash/forEach'
|
||||||
|
import React from 'react'
|
||||||
|
import remove from 'lodash/remove'
|
||||||
|
import { Shortcuts as ReactShortcuts } from 'react-shortcuts'
|
||||||
|
|
||||||
|
let enabled = true
|
||||||
|
const instances = []
|
||||||
|
|
||||||
|
const updateInstances = () => {
|
||||||
|
forEach(instances, instance => instance.forceUpdate())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enable = () => {
|
||||||
|
enabled = true
|
||||||
|
updateInstances()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const disable = () => {
|
||||||
|
enabled = false
|
||||||
|
updateInstances()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Shortcuts extends Component {
|
||||||
|
componentDidMount () {
|
||||||
|
instances.push(this)
|
||||||
|
}
|
||||||
|
componentWillUnmount () {
|
||||||
|
remove(instances, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return enabled ? <ReactShortcuts {...this.props} /> : null
|
||||||
|
}
|
||||||
|
}
|
18
packages/xo-web/src/common/single-line-row.js
Normal file
18
packages/xo-web/src/common/single-line-row.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React, { cloneElement } from 'react'
|
||||||
|
|
||||||
|
import propTypes from './prop-types-decorator'
|
||||||
|
|
||||||
|
const SINGLE_LINE_STYLE = { display: 'flex' }
|
||||||
|
const COL_STYLE = { marginTop: 'auto', marginBottom: 'auto' }
|
||||||
|
|
||||||
|
const SingleLineRow = propTypes({
|
||||||
|
className: propTypes.string,
|
||||||
|
})(({ children, className }) => (
|
||||||
|
<div className={`${className || ''} row`} style={SINGLE_LINE_STYLE}>
|
||||||
|
{React.Children.map(
|
||||||
|
children,
|
||||||
|
child => child && cloneElement(child, { style: COL_STYLE })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
export { SingleLineRow as default }
|
53
packages/xo-web/src/common/smart-backup-pattern.js
Normal file
53
packages/xo-web/src/common/smart-backup-pattern.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as CM from 'complex-matcher'
|
||||||
|
import { flatten, identity, map } from 'lodash'
|
||||||
|
|
||||||
|
import { EMPTY_OBJECT } from './utils'
|
||||||
|
|
||||||
|
export const destructPattern = (pattern, valueTransform = identity) =>
|
||||||
|
pattern && {
|
||||||
|
not: !!pattern.__not,
|
||||||
|
values: valueTransform((pattern.__not || pattern).__or),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const constructPattern = (
|
||||||
|
{ not, values } = EMPTY_OBJECT,
|
||||||
|
valueTransform = identity
|
||||||
|
) => {
|
||||||
|
if (values == null || !values.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = { __or: valueTransform(values) }
|
||||||
|
return not ? { __not: pattern } : pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePattern = pattern => {
|
||||||
|
const patternValues = flatten(
|
||||||
|
pattern.__not !== undefined ? pattern.__not.__or : pattern.__or
|
||||||
|
)
|
||||||
|
|
||||||
|
const queryString = new CM.Or(
|
||||||
|
map(patternValues, array => new CM.String(array))
|
||||||
|
)
|
||||||
|
return pattern.__not !== undefined ? CM.Not(queryString) : queryString
|
||||||
|
}
|
||||||
|
|
||||||
|
export const constructQueryString = pattern => {
|
||||||
|
const powerState = pattern.power_state
|
||||||
|
const pool = pattern.$pool
|
||||||
|
const tags = pattern.tags
|
||||||
|
|
||||||
|
const filter = []
|
||||||
|
|
||||||
|
if (powerState !== undefined) {
|
||||||
|
filter.push(new CM.Property('power_state', new CM.String(powerState)))
|
||||||
|
}
|
||||||
|
if (pool !== undefined) {
|
||||||
|
filter.push(new CM.Property('$pool', parsePattern(pool)))
|
||||||
|
}
|
||||||
|
if (tags !== undefined) {
|
||||||
|
filter.push(new CM.Property('tags', parsePattern(tags)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.length !== 0 ? new CM.And(filter).toString() : ''
|
||||||
|
}
|
17
packages/xo-web/src/common/sorted-table/index.css
Normal file
17
packages/xo-web/src/common/sorted-table/index.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
.clickableColumn {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickableColumn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #96b8d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickableRow {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
outline: 2px solid #366e98;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user