Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84de431872 | ||
|
|
716f2c6df3 | ||
|
|
6ce91df127 | ||
|
|
1a53a320da | ||
|
|
cfe279b511 | ||
|
|
08cfe4171c | ||
|
|
6c37e88ab1 | ||
|
|
8b27794cc7 | ||
|
|
9cf18503ee | ||
|
|
3b36cefbca | ||
|
|
02e09dfdab | ||
|
|
63a75cc765 | ||
|
|
630697a0cb | ||
|
|
f6aa444a23 | ||
|
|
139dc5410e | ||
|
|
e4582695b7 | ||
|
|
0064c2b951 | ||
|
|
47c2d6ee6f | ||
|
|
f290ce696f | ||
|
|
556424d4d6 | ||
|
|
7e507125c4 | ||
|
|
2ca0caa258 | ||
|
|
478a0af781 | ||
|
|
9eab36cd73 | ||
|
|
8f69b74a89 | ||
|
|
fccda8d726 | ||
|
|
111cb106d0 | ||
|
|
5b17d1b2ca | ||
|
|
02dfe4b554 | ||
|
|
688811317c | ||
|
|
4871613c67 | ||
|
|
540898de21 | ||
|
|
e9a4676f5c | ||
|
|
12197f3b2a | ||
|
|
bdad2f9a32 | ||
|
|
2e32b5d2a6 | ||
|
|
51e160560c | ||
|
|
a60a212a10 | ||
|
|
a692dddd50 | ||
|
|
f62a42ada4 | ||
|
|
97ed9797c5 | ||
|
|
405f9d6a0f | ||
|
|
995a232387 | ||
|
|
1e6ab38ede | ||
|
|
ca36bf1b64 | ||
|
|
79cb0b02d0 | ||
|
|
e3acaf925d | ||
|
|
44748e5769 | ||
|
|
6cca59c666 | ||
|
|
7594883404 | ||
|
|
cc89fd92d7 | ||
|
|
134ca46dde | ||
|
|
03c3c456eb | ||
|
|
edcee33780 | ||
|
|
5571ce96b4 | ||
|
|
a40e4b863b | ||
|
|
80269ec6bd | ||
|
|
5f44f6597c | ||
|
|
13ff0724e7 | ||
|
|
f613ba25c2 | ||
|
|
c950c73e76 | ||
|
|
8d8e122633 | ||
|
|
d29448e3a7 | ||
|
|
a91dbe5021 | ||
|
|
63cd8dd2de | ||
|
|
bded263adf | ||
|
|
fa1e40e370 | ||
|
|
30bea8ac57 | ||
|
|
d787f66e3f | ||
|
|
a9b0e3a1f7 | ||
|
|
bd55d6f512 | ||
|
|
e64001cddc | ||
|
|
60d8dcb682 | ||
|
|
e0d648ea3e | ||
|
|
6ba67c3fb6 | ||
|
|
04a7c08a20 | ||
|
|
92091b52a2 | ||
|
|
a43bba2405 | ||
|
|
c8a6eb4c72 | ||
|
|
36798cfb5f | ||
|
|
c52ff6f6ec | ||
|
|
dfab724515 | ||
|
|
4fd919ccfa | ||
|
|
62849e8081 | ||
|
|
9f4b421b59 | ||
|
|
44a2fe11ee | ||
|
|
91d7152ca7 | ||
|
|
3fccc1d8a4 | ||
|
|
5bb6f60c4d | ||
|
|
c2f002b95a | ||
|
|
c55a256d52 | ||
|
|
3fc0b1740d | ||
|
|
1956441d52 | ||
|
|
01e01dae69 | ||
|
|
f14e6ceb97 | ||
|
|
82124d471e | ||
|
|
9286bd6aa0 | ||
|
|
328bfa03e0 | ||
|
|
de65ca0921 |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
!/bin/.*.ts
|
||||
!/tools/.*.ts
|
||||
+15
-5
@@ -8,19 +8,29 @@
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "prettier/@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"no-shadow": ["error", { "allow": ["err"] }],
|
||||
"handle-callback-err": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"curly": ["error", "multi-or-nest", "consistent"],
|
||||
"no-buffer-constructor": "error",
|
||||
"prefer-const": ["error", {"destructuring": "all"}],
|
||||
"eqeqeq": ["error", "always", {"null": "ignore"}],
|
||||
"prefer-const": ["error", { "destructuring": "all" }],
|
||||
"eqeqeq": ["error", "always", { "null": "ignore" }],
|
||||
"no-return-await": "error",
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-use-before-define": ["error", { "functions": false }],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }],
|
||||
"@typescript-eslint/no-use-before-define": [
|
||||
"error",
|
||||
{ "functions": false }
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
{ "allowExpressions": true }
|
||||
],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"require-atomic-updates": 0,
|
||||
"no-prototype-builtins": 0
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
*~
|
||||
node_modules
|
||||
dist
|
||||
bin/.*.ts
|
||||
tools/.*.ts
|
||||
docs/_build
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
*~
|
||||
.npmignore
|
||||
|
||||
config/config.json
|
||||
config/cwmp.key
|
||||
config/cwmp.crt
|
||||
config/cwmp.ca-bundle
|
||||
config/nbi.key
|
||||
config/nbi.crt
|
||||
config/nbi.ca-bundle
|
||||
config/fs.key
|
||||
config/fs.crt
|
||||
config/fs.ca-bundle
|
||||
config/ext
|
||||
|
||||
debug/*
|
||||
!debug/.gitkeep
|
||||
|
||||
dist
|
||||
.eslintrc.json
|
||||
.eslintignore
|
||||
ava.config.js
|
||||
tsconfig.json
|
||||
bin/.*
|
||||
tools/.*
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
npm-shrinkwrap.json
|
||||
+132
-64
@@ -1,77 +1,143 @@
|
||||
# Change Log
|
||||
|
||||
## 1.2.5 (2021-03-12)
|
||||
|
||||
- Support specifying custom types when uploading files.
|
||||
- Fix JS compatibility issue with Safari browser.
|
||||
|
||||
## 1.2.4 (2021-02-24)
|
||||
|
||||
- The data model state of a CPE is no longer forgotten after unsuccessful
|
||||
session termination (e.g. timeout). This addresses a number of undesired side
|
||||
effects that arise when a CPE does not terminate the session properly.
|
||||
- Executing tasks that take a long time to complete (e.g. refreshing the entire
|
||||
data model) no longer shows a timeout error while the task is still being
|
||||
processed.
|
||||
- New function `ROUND()` available to expressions. It works similar to the
|
||||
function by the same name in SQLite and PostgreSQL.
|
||||
- Log access events for `genieacs-nbi` service.
|
||||
- Pipe stdout/stderr from extension scripts to the `genieacs-cwmp` process log.
|
||||
- Parameter values of type `xsd:dateTime` are now displayed in the UI and CSV
|
||||
downloads as a date string rather than a numeric value.
|
||||
- Add file download link in the files listing page.
|
||||
- Display spinner loading animation throughout the UI.
|
||||
- Display GenieACS version and build number underneath the logo.
|
||||
- New option to specify how many parameters are displayed at a time in the
|
||||
all-parameters component. Simply set `limit` property in the component config.
|
||||
- Reduce overly strong Brotli compression level which was causing significant
|
||||
page load slowdown when Brotli is used.
|
||||
- Retire dump-data-model tool. `genieacs-sim` can now use a CSV file as its data
|
||||
model.
|
||||
- Reduce the number of concurrent database connections from each process.
|
||||
- Remove dependency on 'hostInfo' MongoDB command which is a privileged action.
|
||||
It is now possible to use shared MongoDB instances with limited privileged.
|
||||
- Fix bug in NBI where querying files returns 404 error.
|
||||
- Fix ping not working for devices with an IPv6 address.
|
||||
- Fix an elusive memory leak in `genieacs-fs` that slowly eats up memory and can
|
||||
go unnoticed for long periods of time.
|
||||
- Fix a rare edge case where a `declare()` call to set a parameter value may not
|
||||
work as intended if the parameter was originally received as part of the
|
||||
Inform message.
|
||||
- A number of other fixes and stability improvements.
|
||||
|
||||
## 1.2.3 (2020-10-26)
|
||||
|
||||
- New config option 'cwmp.skipWritableCheck' for when some CPEs incorrectly
|
||||
report writable parameters as non-writable. When set to true, the scripts will
|
||||
no longer respect the 'writable' attribute of the CPE parameters and will send
|
||||
a SetParamteerValues, AddObject, or DeleteObject request anyway.
|
||||
- Tags no longer restrict what characters are allowed. Any character other than
|
||||
alphanumeric characters, hyphen, or underscore is now encoded in the data
|
||||
model (i.e. Tags.\<tag>) using its hex value preceded by "0x".
|
||||
- Ask for a confirmation before closing a pop-up dialog with unsaved changes.
|
||||
- Better XML validation to avoid crashes caused by invalid CPE requests.
|
||||
- Fix confusing 404 error message when the user attempts to modify a resource
|
||||
when they don't have the necessary permissions.
|
||||
- Fix a rare issue where genieacs-cwmp stops accepting new connections after
|
||||
running for a few weeks.
|
||||
- Fix exception when IS NULL operator is used in certain situations.
|
||||
|
||||
## 1.2.2 (2020-10-03)
|
||||
|
||||
- Added button to push files to selected devices from device listing page.
|
||||
- A few minor UI improvements.
|
||||
- Fix exception that can happen and persist after a Download request.
|
||||
- Fix validation bug preventing running refreshObject task on data model root.
|
||||
- Fix invalid arguments fault in refresh preset configuration when upgrading
|
||||
from v1.1.
|
||||
|
||||
## 1.2.1 (2020-09-08)
|
||||
|
||||
- Fix bug causing faults to not be displayed in the UI.
|
||||
- Fix bug where deleting objects does not get reflected immediately in the UI.
|
||||
- Improve conversion between filters written in the expression format and
|
||||
MongoDB queries. There should now be fewer edge cases where the two are not
|
||||
equisatisfiable.
|
||||
MongoDB queries. There should now be fewer edge cases where the two are not
|
||||
equisatisfiable.
|
||||
|
||||
## 1.2.0 (2020-09-01)
|
||||
|
||||
- Support GetParameterAttributes and SetParameterAttributes TR-069 methods.
|
||||
- Support CASE statement and COALESCE function in expressions.
|
||||
- Provision arguments can now be a list of expressions that are dynamically
|
||||
evaluated.
|
||||
evaluated.
|
||||
- Support Forwarded HTTP header to display in the logs the correct IP of CPEs
|
||||
behind a reverse proxy. Must be configured using FORWARDED_HEADER option.
|
||||
behind a reverse proxy. Must be configured using FORWARDED_HEADER option.
|
||||
- Config expressions can now access all available device parameters, not only
|
||||
serial number, product class, and OUI.
|
||||
- Use relative URLs throughout the UI to allow serving from a subdirectory
|
||||
using a reverse proxy.
|
||||
serial number, product class, and OUI.
|
||||
- Use relative URLs throughout the UI to allow serving from a subdirectory using
|
||||
a reverse proxy.
|
||||
- Make Date.parse() and Date.UTC() available to provision scripts.
|
||||
- libxmljs has been entirely removed in favor of our bespoke XML parser.
|
||||
- Removed the config option CWMP_KEEP_ALIVE_TIMEOUT. SESSION_TIMEOUT is now
|
||||
used to determine the TCP connection timeout.
|
||||
- The all-parameters component now limits the number of parameters displayed
|
||||
for better performance.
|
||||
- Removed the config option CWMP_KEEP_ALIVE_TIMEOUT. SESSION_TIMEOUT is now used
|
||||
to determine the TCP connection timeout.
|
||||
- The all-parameters component now limits the number of parameters displayed for
|
||||
better performance.
|
||||
- The process genieacs-cwmp is now much less likely to throw exceptions as a
|
||||
result of invalid requests from CPE.
|
||||
result of invalid requests from CPE.
|
||||
- A large number of bug fixes and stability improvements.
|
||||
|
||||
## 1.2.0-beta.0 (2019-07-30)
|
||||
|
||||
- A brand new UI superseding genieacs-gui.
|
||||
- New initialization wizard on first run.
|
||||
- New expression/query language used in search filters and preset
|
||||
preconditions.
|
||||
- New expression/query language used in search filters and preset preconditions.
|
||||
- CPE -> ACS authentication is now supported.
|
||||
- New config option (CWMP_KEEP_ALIVE_TIMEOUT) to specify how long to wait for a
|
||||
reply from the CPE before closing the TCP connection.
|
||||
reply from the CPE before closing the TCP connection.
|
||||
- Debug logging has been reimplemented utilizing YAML format for logs.
|
||||
- Handle 9005 faults (Invalid Parameter Name) gracefully by attempting to
|
||||
rediscover the path of the missing parameter recursively.
|
||||
rediscover the path of the missing parameter recursively.
|
||||
- declare() statements not followed by an explicit commit() are now deferred
|
||||
until all currently active scripts have been executed.
|
||||
until all currently active scripts have been executed.
|
||||
- FS_HOSTNAME now defaults to the server's hostname or IP.
|
||||
- The API now validates the structure of task objects before saving.
|
||||
- New XML parser implementation for better performance. You can revert to the
|
||||
old parser by enabling the config option XML_LIBXMLJS. Requires Node.js v11 or
|
||||
v10.
|
||||
old parser by enabling the config option XML_LIBXMLJS. Requires Node.js v11 or
|
||||
v10.
|
||||
- Performance optimizations. While performance has improved for the majority of
|
||||
use cases, there may be situations where performance has degraded. It's
|
||||
recommended to revisit your hardware requirements.
|
||||
use cases, there may be situations where performance has degraded. It's
|
||||
recommended to revisit your hardware requirements.
|
||||
- Connection request authentication no longer uses 'auth.js' file. Instead, the
|
||||
connection request authentication behavior can now be customized using an
|
||||
'expression'.
|
||||
connection request authentication behavior can now be customized using an
|
||||
'expression'.
|
||||
- The config file (config.json) has been deprecated. System configuration (e.g.
|
||||
listen ports, worker count) are now recommended to be passed as environment.
|
||||
variables. Other general configuration options are stored in the database so as
|
||||
to not require service restart for changes to take effect.
|
||||
listen ports, worker count) are now recommended to be passed as environment.
|
||||
variables. Other general configuration options are stored in the database so
|
||||
as to not require service restart for changes to take effect.
|
||||
- Optional redis dependency has been removed completely.
|
||||
- Tags now allow only alphanumeric characters and underscore.
|
||||
- Supported versions of NodeJs and MongoDB are 10.x and up and 2.6 and up
|
||||
respectively.
|
||||
- Supported versions of Node.js and MongoDB are 10.x and up and 2.6 and up
|
||||
respectively.
|
||||
|
||||
## 1.1.3 (2018-10-23)
|
||||
|
||||
- New config option (MAX_COMMIT_ITERATIONS) to avoid max commit iterations
|
||||
faults for more complex scripts.
|
||||
faults for more complex scripts.
|
||||
- Support base64 and hexBinary parameter types.
|
||||
- Strict parsing of number values in queries (e.g. "123abc" no longer accepted
|
||||
as 123).
|
||||
as 123).
|
||||
- Mixing $ne and $not operators is not allowed. Now it throws an error instead
|
||||
of returning incorrect results.
|
||||
of returning incorrect results.
|
||||
- When a task expires, any associated fault is also deleted.
|
||||
- API now accepts 'timeout' argument when posting a task.
|
||||
- A number of stability fixes.
|
||||
@@ -81,66 +147,68 @@ of returning incorrect results.
|
||||
- A large number of bug fixes as well as stability and performance improvements.
|
||||
- Three security vulnerabilities disclosed by Maximilian Hils have been patched.
|
||||
- New config option UDP_CONNECTION_REQUEST_PORT to specify binding port for UDP
|
||||
connection requests.
|
||||
connection requests.
|
||||
- New config option DATETIME_MILLISECONDS to strip milliseconds from dateTime
|
||||
values.
|
||||
values.
|
||||
- New config option BOOLEAN_LITERAL to use 1/0 or true/false for boolean values.
|
||||
- Parameter values that cannot be parsed according to the reported type now show
|
||||
a warning message.
|
||||
a warning message.
|
||||
- Virtual parameter scripts now use the variable 'args' instead of the special
|
||||
'TIMESTAMPS' and 'VALUES' variables. The content of the args array is: {declare
|
||||
timestamps}, {declare values}, {current timestamps}, {current values}.
|
||||
'TIMESTAMPS' and 'VALUES' variables. The content of the args array is:
|
||||
{declare timestamps}, {declare values}, {current timestamps}, {current
|
||||
values}.
|
||||
- Virtual parameter value types are now inferred from the JavaScript type if the
|
||||
returned value attribute is not a value-type pair.
|
||||
returned value attribute is not a value-type pair.
|
||||
- Show a fault when a virtual parameter script doesn't return the required
|
||||
attributes.
|
||||
attributes.
|
||||
- Redis is now optional (and disabled by default), reducing the complexity of
|
||||
scalable deployments.
|
||||
scalable deployments.
|
||||
- Better detection of cyclical presets resulting in fewer faults for complex
|
||||
provisioning scripts.
|
||||
provisioning scripts.
|
||||
- Math.random() is now deterministic on per-device basis. A function has been
|
||||
added to allow specifying a seed value (e.g. Math.random.seed(Date.now())).
|
||||
added to allow specifying a seed value (e.g. Math.random.seed(Date.now())).
|
||||
- Overload spikes are now handled gracefully by refusing to accept new sessions
|
||||
temporarily when under abnormal load.
|
||||
temporarily when under abnormal load.
|
||||
- Added log messages for session timeouts, connection drops, and XML parsing
|
||||
errors.
|
||||
errors.
|
||||
- Date.now() now takes an optional argument to specify "time steps" (in
|
||||
milliseconds). This can be used to ensure a group of parameters are all
|
||||
refreshed at the same time intervals.
|
||||
milliseconds). This can be used to ensure a group of parameters are all
|
||||
refreshed at the same time intervals.
|
||||
- Only the non-default configuration options are now logged at process start.
|
||||
- Faults caused by errors from extensions now show a cleaner stack trace.
|
||||
- Exit main process if there are too many worker crashes (e.g. when DB is down).
|
||||
- Updated dependencies and included a lockfile to ensure installations get the
|
||||
exact dependencies it was tested against.
|
||||
exact dependencies it was tested against.
|
||||
|
||||
## 1.1.1 (2017-03-23)
|
||||
|
||||
- Avoid crashing when connection request credentials are missing.
|
||||
- Show a warning instead of crashing when failing to parse parameter values
|
||||
according to the expected value type.
|
||||
according to the expected value type.
|
||||
- Add missing "Registered" event.
|
||||
- Fix bug where in certain cases many more instances than declared are created.
|
||||
- Fix parameter discovery bug when declared path timestamp is 1 or is not set.
|
||||
- Fix preset precondition failing when testing against datetime parameters and
|
||||
certain other parameters like \_deviceId.\_ProductClass.
|
||||
certain other parameters like \_deviceId.\_ProductClass.
|
||||
|
||||
## 1.1.0 (2017-03-10)
|
||||
|
||||
- Provisions enable implementing dynamic device configuration or complex device
|
||||
provisioning work flow using arbitrary scripts.
|
||||
provisioning work flow using arbitrary scripts.
|
||||
- Virtual parameters are user-defined parameters whose values are evaluated from
|
||||
a custom script.
|
||||
a custom script.
|
||||
- Extensions are sandboxed Node.js scripts that are accessible from provision
|
||||
and virtual parameter scripts to facilitate integration with external entities.
|
||||
- Support for UDP/STUN based connection requests for reaching devices behind
|
||||
NAT (TR-069 Annex G).
|
||||
and virtual parameter scripts to facilitate integration with external
|
||||
entities.
|
||||
- Support for UDP/STUN based connection requests for reaching devices behind NAT
|
||||
(TR-069 Annex G).
|
||||
- Presets can now be scheduled using a cron-like expression.
|
||||
- Presets can now be tied to specific device events (e.g. boot).
|
||||
- Presets precondition queries no longer support "$or" or other MongoDB logical
|
||||
operators.
|
||||
- Presets precondition queries no longer support "\$or" or other MongoDB logical
|
||||
operators.
|
||||
- Faults are no longer a part of tasks but are now first class objects.
|
||||
- Presets are now assigned to channels. A fault in one channel only blocks
|
||||
presets in that channel.
|
||||
presets in that channel.
|
||||
- New API CRUD functions for provisions, virtual parameters, and faults.
|
||||
- New config options for XML output.
|
||||
- API responses now include "GenieACS-Version" header.
|
||||
@@ -149,19 +217,19 @@ presets in that channel.
|
||||
- Supported Node.js versions are 6.x and 7.x.
|
||||
- Supported MongoDB versions are 2.6 through 3.4.
|
||||
- Expect performance differences due to major under the hood changes. Some
|
||||
operations are faster and some are slower. Overall performance is improved.
|
||||
operations are faster and some are slower. Overall performance is improved.
|
||||
- GenieACS will no longer fetch the entire device data model upon first contact
|
||||
but will instead only fetch the parameters it needs to fulfill the presets.
|
||||
but will instead only fetch the parameters it needs to fulfill the presets.
|
||||
- Logs have been overhauled and split into two streams: process log (stderr) and
|
||||
access log (stdout). Also added config options to dump logs to files rather than
|
||||
standard streams.
|
||||
access log (stdout). Also added config options to dump logs to files rather
|
||||
than standard streams.
|
||||
- Connection request authentication credentials are picked up from the device
|
||||
data model if available. config/auth.js is still supported as a fallback and now
|
||||
supports an optional callback argument.
|
||||
data model if available. config/auth.js is still supported as a fallback and
|
||||
now supports an optional callback argument.
|
||||
- Custom commands have been removed. Use virtual parameters and/or extensions.
|
||||
- Aliases and value normalizers (config/parameters.json) have been removed. Use
|
||||
virtual parameters.
|
||||
virtual parameters.
|
||||
- The API /devices/<device_id>/preset has been removed.
|
||||
- Rarely used RequestDownload method no longer supported.
|
||||
- The TR-069 client simulator has moved to its own repo at
|
||||
https://github.com/zaidka/genieacs-sim
|
||||
https://github.com/zaidka/genieacs-sim
|
||||
+10
-9
@@ -5,8 +5,8 @@ tracker is strictly for bug reports.
|
||||
|
||||
# Issues
|
||||
|
||||
We use GitHub issues to track bugs. Please ensure your bug description is
|
||||
clear and has sufficient instructions to be able to reproduce the issue.
|
||||
We use GitHub issues to track bugs. Please ensure your bug description is clear
|
||||
and has sufficient instructions to be able to reproduce the issue.
|
||||
|
||||
If you face interoperability issues with your CPE, more often than not it's a
|
||||
device related issue. Please consult the [forum] before submitting an Issue.
|
||||
@@ -26,13 +26,14 @@ By submitting a pull request to this repository, you acknowledge that, while
|
||||
maintaining copyright, you grant Zaid Abdulla a perpetual, worldwide, non-
|
||||
exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare
|
||||
derivative works of, publicly display, publicly perform, sublicense, and
|
||||
distribute your contributions and such derivative works under the AGPLv3
|
||||
license or any other license terms, including, but not limited to, proprietary
|
||||
or commercial license terms.
|
||||
distribute your contributions and such derivative works under the AGPLv3 license
|
||||
or any other license terms, including, but not limited to, proprietary or
|
||||
commercial license terms.
|
||||
|
||||
You confirm that you own or have rights to distribute and sublicense the
|
||||
source code contained therein, and that your content does not infringe upon
|
||||
the intellectual property rights of a third party.
|
||||
You confirm that you own or have rights to distribute and sublicense the source
|
||||
code contained therein, and that your content does not infringe upon the
|
||||
intellectual property rights of a third party.
|
||||
|
||||
[commit-guidelines]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
|
||||
[commit-guidelines]:
|
||||
http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
|
||||
[forum]: https://forum.genieacs.com
|
||||
@@ -17,10 +17,10 @@ supported versions are:
|
||||
|
||||
Install GenieACS from NPM:
|
||||
|
||||
sudo npm install -g --unsafe-perm genieacs
|
||||
sudo npm install -g genieacs
|
||||
|
||||
To build from source instead, clone this repo or download the source archive
|
||||
then *cd* into the source directory then run:
|
||||
then _cd_ into the source directory then run:
|
||||
|
||||
npm install
|
||||
npm run build
|
||||
@@ -30,8 +30,8 @@ source):
|
||||
|
||||
### genieacs-cwmp
|
||||
|
||||
This is the service that the CPEs will communicate with. It listens on port
|
||||
7547 by default. Configure the ACS URL in your devices accordingly.
|
||||
This is the service that the CPEs will communicate with. It listens on port 7547
|
||||
by default. Configure the ACS URL in your devices accordingly.
|
||||
|
||||
You may optionally use [genieacs-sim](https://github.com/genieacs/genieacs-sim)
|
||||
as a dummy TR-069 simulator if you don't have a CPE at hand.
|
||||
@@ -50,7 +50,7 @@ such. It listens on port 7567 by default.
|
||||
### genieacs-ui
|
||||
|
||||
This serves the web based user interface. It listens on port 3000 by default.
|
||||
You must pass *--ui-jwt-secret* argument to supply the secret key used for
|
||||
You must pass _--ui-jwt-secret_ argument to supply the secret key used for
|
||||
signing browser cookies:
|
||||
|
||||
genieacs-ui --ui-jwt-secret secret
|
||||
@@ -64,14 +64,13 @@ a complete installation guide for production deployments.
|
||||
|
||||
## Support
|
||||
|
||||
The [forum](https://forum.genieacs.com) is a good place to get guidance and
|
||||
help from the community. Head on over and join the conversation!
|
||||
The [forum](https://forum.genieacs.com) is a good place to get guidance and help
|
||||
from the community. Head on over and join the conversation!
|
||||
|
||||
For commercial support options, please visit
|
||||
[genieacs.com](https://genieacs.com/support/).
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2020 GenieACS Inc. GenieACS is released under the [AGPLv3
|
||||
license
|
||||
terms](https://raw.githubusercontent.com/genieacs/genieacs/master/LICENSE).
|
||||
Copyright 2013-2020 GenieACS Inc. GenieACS is released under the
|
||||
[AGPLv3 license terms](https://raw.githubusercontent.com/genieacs/genieacs/master/LICENSE).
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
export default {
|
||||
"extensions": ["ts"],
|
||||
"require": ["esm", "ts-node/register/transpile-only"]
|
||||
extensions: ["ts"],
|
||||
require: ["esm", "ts-node/register/transpile-only"],
|
||||
};
|
||||
|
||||
+13
-14
@@ -24,7 +24,6 @@ import * as server from "../lib/server";
|
||||
import * as cwmp from "../lib/cwmp";
|
||||
import * as db from "../lib/db";
|
||||
import * as extensions from "../lib/extensions";
|
||||
import * as cache from "../lib/cache";
|
||||
import { version as VERSION } from "../package.json";
|
||||
|
||||
logger.init("cwmp", VERSION);
|
||||
@@ -36,7 +35,6 @@ function exitWorkerGracefully(): void {
|
||||
setTimeout(exitWorkerUngracefully, 5000).unref();
|
||||
Promise.all([
|
||||
db.disconnect(),
|
||||
cache.disconnect(),
|
||||
extensions.killAll(),
|
||||
cluster.worker.disconnect(),
|
||||
]).catch(exitWorkerUngracefully);
|
||||
@@ -77,9 +75,16 @@ if (!cluster.worker) {
|
||||
cluster.stop();
|
||||
});
|
||||
} else {
|
||||
const ssl = {
|
||||
key: config.get("CWMP_SSL_KEY") as string,
|
||||
cert: config.get("CWMP_SSL_CERT") as string,
|
||||
const key = config.get("CWMP_SSL_KEY") as string;
|
||||
const cert = config.get("CWMP_SSL_CERT") as string;
|
||||
|
||||
const options = {
|
||||
port: SERVICE_PORT,
|
||||
host: SERVICE_ADDRESS,
|
||||
ssl: key && cert ? { key, cert } : null,
|
||||
onConnection: cwmp.onConnection,
|
||||
timeout: 30000,
|
||||
keepAliveTimeout: 0,
|
||||
};
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
@@ -92,16 +97,10 @@ if (!cluster.worker) {
|
||||
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
|
||||
});
|
||||
|
||||
const initPromise = Promise.all([db.connect(), cache.connect()])
|
||||
const initPromise = db
|
||||
.connect()
|
||||
.then(() => {
|
||||
server.start(
|
||||
SERVICE_PORT,
|
||||
SERVICE_ADDRESS,
|
||||
ssl,
|
||||
cwmp.listener,
|
||||
cwmp.onConnection,
|
||||
0
|
||||
);
|
||||
server.start(options, cwmp.listener);
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
|
||||
+14
-12
@@ -23,7 +23,6 @@ import * as cluster from "../lib/cluster";
|
||||
import * as server from "../lib/server";
|
||||
import { listener } from "../lib/fs";
|
||||
import * as db from "../lib/db";
|
||||
import * as cache from "../lib/cache";
|
||||
import { version as VERSION } from "../package.json";
|
||||
|
||||
logger.init("fs", VERSION);
|
||||
@@ -33,11 +32,9 @@ const SERVICE_PORT = config.get("FS_PORT") as number;
|
||||
|
||||
function exitWorkerGracefully(): void {
|
||||
setTimeout(exitWorkerUngracefully, 5000).unref();
|
||||
Promise.all([
|
||||
db.disconnect(),
|
||||
cache.disconnect(),
|
||||
cluster.worker.disconnect(),
|
||||
]).catch(exitWorkerUngracefully);
|
||||
Promise.all([db.disconnect(), cluster.worker.disconnect()]).catch(
|
||||
exitWorkerUngracefully
|
||||
);
|
||||
}
|
||||
|
||||
function exitWorkerUngracefully(): void {
|
||||
@@ -73,9 +70,13 @@ if (!cluster.worker) {
|
||||
cluster.stop();
|
||||
});
|
||||
} else {
|
||||
const ssl = {
|
||||
key: config.get("FS_SSL_KEY") as string,
|
||||
cert: config.get("FS_SSL_CERT") as string,
|
||||
const key = config.get("FS_SSL_KEY") as string;
|
||||
const cert = config.get("FS_SSL_CERT") as string;
|
||||
const options = {
|
||||
port: SERVICE_PORT,
|
||||
host: SERVICE_ADDRESS,
|
||||
ssl: key && cert ? { key, cert } : null,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
let stopping = false;
|
||||
@@ -93,12 +94,13 @@ if (!cluster.worker) {
|
||||
|
||||
const _listener = (req, res): void => {
|
||||
if (stopping) res.setHeader("Connection", "close");
|
||||
listener(req, res);
|
||||
void listener(req, res);
|
||||
};
|
||||
|
||||
const initPromise = Promise.all([db.connect(), cache.connect()])
|
||||
const initPromise = db
|
||||
.connect()
|
||||
.then(() => {
|
||||
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener);
|
||||
server.start(options, _listener);
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
|
||||
+10
-7
@@ -24,7 +24,6 @@ import * as server from "../lib/server";
|
||||
import { listener } from "../lib/nbi";
|
||||
import * as db from "../lib/db";
|
||||
import * as extensions from "../lib/extensions";
|
||||
import * as cache from "../lib/cache";
|
||||
import { version as VERSION } from "../package.json";
|
||||
|
||||
logger.init("nbi", VERSION);
|
||||
@@ -36,7 +35,6 @@ function exitWorkerGracefully(): void {
|
||||
setTimeout(exitWorkerUngracefully, 5000).unref();
|
||||
Promise.all([
|
||||
db.disconnect(),
|
||||
cache.disconnect(),
|
||||
extensions.killAll(),
|
||||
cluster.worker.disconnect(),
|
||||
]).catch(exitWorkerUngracefully);
|
||||
@@ -77,9 +75,13 @@ if (!cluster.worker) {
|
||||
cluster.stop();
|
||||
});
|
||||
} else {
|
||||
const ssl = {
|
||||
key: config.get("NBI_SSL_KEY") as string,
|
||||
cert: config.get("NBI_SSL_CERT") as string,
|
||||
const key = config.get("NBI_SSL_KEY") as string;
|
||||
const cert = config.get("NBI_SSL_CERT") as string;
|
||||
const options = {
|
||||
port: SERVICE_PORT,
|
||||
host: SERVICE_ADDRESS,
|
||||
ssl: key && cert ? { key, cert } : null,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
let stopping = false;
|
||||
@@ -100,9 +102,10 @@ if (!cluster.worker) {
|
||||
listener(req, res);
|
||||
};
|
||||
|
||||
const initPromise = Promise.all([db.connect(), cache.connect()])
|
||||
const initPromise = db
|
||||
.connect()
|
||||
.then(() => {
|
||||
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener);
|
||||
server.start(options, _listener);
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
|
||||
+12
-13
@@ -23,9 +23,7 @@ import * as cluster from "../lib/cluster";
|
||||
import * as server from "../lib/server";
|
||||
import { listener } from "../lib/ui";
|
||||
import * as extensions from "../lib/extensions";
|
||||
import * as db from "../lib/ui/db";
|
||||
import * as db2 from "../lib/db";
|
||||
import * as cache from "../lib/cache";
|
||||
import * as db from "../lib/db";
|
||||
import { version as VERSION } from "../package.json";
|
||||
|
||||
logger.init("ui", VERSION);
|
||||
@@ -37,8 +35,6 @@ function exitWorkerGracefully(): void {
|
||||
setTimeout(exitWorkerUngracefully, 5000).unref();
|
||||
Promise.all([
|
||||
db.disconnect(),
|
||||
db2.disconnect(),
|
||||
cache.disconnect(),
|
||||
extensions.killAll(),
|
||||
cluster.worker.disconnect(),
|
||||
]).catch(exitWorkerUngracefully);
|
||||
@@ -79,9 +75,13 @@ if (!cluster.worker) {
|
||||
cluster.stop();
|
||||
});
|
||||
} else {
|
||||
const ssl = {
|
||||
key: config.get("UI_SSL_KEY") as string,
|
||||
cert: config.get("UI_SSL_CERT") as string,
|
||||
const key = config.get("UI_SSL_KEY") as string;
|
||||
const cert = config.get("UI_SSL_CERT") as string;
|
||||
const options = {
|
||||
port: SERVICE_PORT,
|
||||
host: SERVICE_ADDRESS,
|
||||
ssl: key && cert ? { key, cert } : null,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
let stopping = false;
|
||||
@@ -99,14 +99,13 @@ if (!cluster.worker) {
|
||||
|
||||
const _listener = (req, res): void => {
|
||||
if (stopping) res.setHeader("Connection", "close");
|
||||
listener(req, res).catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
listener(req, res);
|
||||
};
|
||||
|
||||
const initPromise = Promise.all([db2.connect(), cache.connect()])
|
||||
const initPromise = db
|
||||
.connect()
|
||||
.then(() => {
|
||||
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener);
|
||||
server.start(options, _listener);
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
|
||||
+39
-108
@@ -19,19 +19,17 @@
|
||||
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { promisify } from "util";
|
||||
import { rollup, WarningHandler } from "rollup";
|
||||
import rollupJson from "@rollup/plugin-json";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { terser } from "rollup-plugin-terser";
|
||||
import nodeResolve from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import webpack from "webpack";
|
||||
import postcss from "postcss";
|
||||
import postcssImport from "postcss-import";
|
||||
import postcssPresetEnv from "postcss-preset-env";
|
||||
import cssnano from "cssnano";
|
||||
import SVGO from "svgo";
|
||||
import { optimize, extendDefaultPlugins } from "svgo";
|
||||
import * as xmlParser from "../lib/xml-parser";
|
||||
|
||||
const MODE = process.env["NODE_ENV"] || "production";
|
||||
@@ -44,7 +42,7 @@ const BUILD_METADATA = new Date()
|
||||
const INPUT_DIR = path.resolve(__dirname, "..");
|
||||
const OUTPUT_DIR = path.resolve(__dirname, "../dist");
|
||||
|
||||
const externals = [
|
||||
const builtins = [
|
||||
"path",
|
||||
"fs",
|
||||
"cluster",
|
||||
@@ -55,33 +53,13 @@ const externals = [
|
||||
"zlib",
|
||||
"crypto",
|
||||
"util",
|
||||
"mongodb",
|
||||
"vm",
|
||||
"later",
|
||||
"parsimmon",
|
||||
"seedrandom",
|
||||
"querystring",
|
||||
"child_process",
|
||||
"dgram",
|
||||
"url",
|
||||
"iconv-lite",
|
||||
"koa",
|
||||
"koa-router",
|
||||
"koa-compress",
|
||||
"koa-bodyparser",
|
||||
"koa-jwt",
|
||||
"koa-static",
|
||||
"jsonwebtoken",
|
||||
"readline",
|
||||
"stream",
|
||||
"mithril",
|
||||
"parsimmon",
|
||||
"yaml",
|
||||
"codemirror",
|
||||
"codemirror/mode/javascript/javascript",
|
||||
"codemirror/mode/yaml/yaml",
|
||||
"ipaddr.js",
|
||||
"jsbi",
|
||||
"espresso-iisojs",
|
||||
];
|
||||
|
||||
function rmDirSync(dirPath): void {
|
||||
@@ -131,7 +109,7 @@ function generateSymbol(id: string, svgStr: string): string {
|
||||
return `<symbol id="icon-${id}" ${viewBox}>${symbolBody}</symbol>`;
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
async function init(): Promise<string[]> {
|
||||
// Delete any old output directory
|
||||
rmDirSync(OUTPUT_DIR);
|
||||
|
||||
@@ -139,13 +117,13 @@ async function init(): Promise<void> {
|
||||
fs.mkdirSync(OUTPUT_DIR);
|
||||
fs.mkdirSync(OUTPUT_DIR + "/bin");
|
||||
fs.mkdirSync(OUTPUT_DIR + "/public");
|
||||
fs.mkdirSync(OUTPUT_DIR + "/tools");
|
||||
|
||||
// Create package.json
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.resolve(INPUT_DIR, "package.json")).toString()
|
||||
);
|
||||
delete packageJson["devDependencies"];
|
||||
delete packageJson["private"];
|
||||
packageJson["scripts"] = {
|
||||
install: packageJson["scripts"].install,
|
||||
configure: packageJson["scripts"].configure,
|
||||
@@ -166,6 +144,7 @@ async function init(): Promise<void> {
|
||||
path.resolve(OUTPUT_DIR, "npm-shrinkwrap.json"),
|
||||
JSON.stringify(npmShrinkwrapJson, null, 2)
|
||||
);
|
||||
return Object.keys(packageJson["dependencies"]);
|
||||
}
|
||||
|
||||
async function copyStatic(): Promise<void> {
|
||||
@@ -203,41 +182,7 @@ async function generateCss(): Promise<void> {
|
||||
fs.writeFileSync(cssOutPath, cssOut.css);
|
||||
}
|
||||
|
||||
async function generateToolsJs(): Promise<void> {
|
||||
for (const bin of ["dump-data-model"]) {
|
||||
const inputFile = path.resolve(INPUT_DIR, `tools/${bin}.ts`);
|
||||
const outputFile = path.resolve(OUTPUT_DIR, `tools/${bin}`);
|
||||
const bundle = await rollup({
|
||||
input: inputFile,
|
||||
external: externals,
|
||||
acorn: {
|
||||
allowHashBang: true,
|
||||
},
|
||||
plugins: [
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
include: [`tools/${bin}.ts`, "lib/**/*.ts"],
|
||||
}),
|
||||
MODE === "production" ? terser() : null,
|
||||
],
|
||||
});
|
||||
|
||||
await bundle.write({
|
||||
format: "cjs",
|
||||
preferConst: true,
|
||||
sourcemap: "inline",
|
||||
sourcemapExcludeSources: true,
|
||||
banner: "#!/usr/bin/env node",
|
||||
file: outputFile,
|
||||
});
|
||||
|
||||
// Mark as executable
|
||||
const mode = fs.statSync(outputFile).mode;
|
||||
fs.chmodSync(outputFile, mode | 73);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBackendJs(): Promise<void> {
|
||||
async function generateBackendJs(externals: string[]): Promise<void> {
|
||||
for (const bin of [
|
||||
"genieacs-cwmp",
|
||||
"genieacs-ext",
|
||||
@@ -249,7 +194,7 @@ async function generateBackendJs(): Promise<void> {
|
||||
const outputFile = path.resolve(OUTPUT_DIR, `bin/${bin}`);
|
||||
const bundle = await rollup({
|
||||
input: inputFile,
|
||||
external: externals,
|
||||
external: [...builtins, ...externals],
|
||||
acorn: {
|
||||
allowHashBang: true,
|
||||
},
|
||||
@@ -293,26 +238,26 @@ async function generateBackendJs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateFrontendJs(): Promise<void> {
|
||||
async function generateFrontendJs(externals: string[]): Promise<void> {
|
||||
const inputFile = path.resolve(INPUT_DIR, "ui/app.ts");
|
||||
const outputFile = path.resolve(OUTPUT_DIR, "public/app.js");
|
||||
const outputDir = path.resolve(OUTPUT_DIR, "public");
|
||||
|
||||
const inlineDeps = ["mithril", "parsimmon", "jsbi", "espresso-iisojs"];
|
||||
const inlineDeps = ["parsimmon", "espresso-iisojs"];
|
||||
const bundle = await rollup({
|
||||
input: inputFile,
|
||||
external: externals.filter((e) => !inlineDeps.includes(e)),
|
||||
external: [
|
||||
...builtins,
|
||||
...externals.filter((e) => !inlineDeps.includes(e)),
|
||||
],
|
||||
plugins: [
|
||||
rollupJson({ preferConst: true }),
|
||||
{
|
||||
name: "",
|
||||
resolveId: function (importee, importer) {
|
||||
if (importee.endsWith("/bigint")) {
|
||||
return this.resolve(importee + "-jsbi", importer);
|
||||
} else if (importee === "espresso-iisojs") {
|
||||
return this.resolve(
|
||||
"espresso-iisojs/dist/espresso-iisojs-jsbi.mjs",
|
||||
importer
|
||||
);
|
||||
if (importee.endsWith("/package.json")) {
|
||||
const p = path.resolve(path.dirname(importer), importee);
|
||||
if (p === path.resolve(INPUT_DIR, "package.json"))
|
||||
return path.resolve(OUTPUT_DIR, "package.json");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -320,8 +265,9 @@ async function generateFrontendJs(): Promise<void> {
|
||||
typescript({ tsconfig: "./tsconfig.json" }),
|
||||
nodeResolve(),
|
||||
commonjs(),
|
||||
MODE === "production" ? terser() : null,
|
||||
],
|
||||
inlineDynamicImports: true,
|
||||
preserveEntrySignatures: false,
|
||||
treeshake: {
|
||||
propertyReadSideEffects: false,
|
||||
moduleSideEffects: false,
|
||||
@@ -333,47 +279,33 @@ async function generateFrontendJs(): Promise<void> {
|
||||
});
|
||||
|
||||
await bundle.write({
|
||||
manualChunks: (id) => {
|
||||
if (id.includes("node_modules/codemirror")) return "codemirror";
|
||||
else if (id.includes("node_modules/yaml")) return "yaml";
|
||||
return "app";
|
||||
},
|
||||
preferConst: true,
|
||||
format: "es",
|
||||
sourcemap: "inline",
|
||||
sourcemap: true,
|
||||
sourcemapExcludeSources: true,
|
||||
file: outputFile,
|
||||
dir: outputDir,
|
||||
});
|
||||
|
||||
const webpackConf = {
|
||||
mode: MODE,
|
||||
entry: outputFile,
|
||||
resolve: {
|
||||
aliasFields: ["module"],
|
||||
},
|
||||
devtool: "nosources-source-map",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
use: ["source-map-loader"],
|
||||
enforce: "pre",
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(OUTPUT_DIR, "public"),
|
||||
filename: "app.js",
|
||||
},
|
||||
};
|
||||
|
||||
const stats = await promisify(webpack)(webpackConf);
|
||||
process.stdout.write(stats.toString({ colors: true }) + "\n");
|
||||
}
|
||||
|
||||
async function generateIconsSprite(): Promise<void> {
|
||||
const svgo = new SVGO({ plugins: [{ removeViewBox: false }] });
|
||||
const symbols = [];
|
||||
const iconsDir = path.resolve(INPUT_DIR, "ui/icons");
|
||||
for (const file of fs.readdirSync(iconsDir)) {
|
||||
const id = path.parse(file).name;
|
||||
const filePath = path.join(iconsDir, file);
|
||||
const { data } = await svgo.optimize(fs.readFileSync(filePath).toString());
|
||||
const { data } = await optimize(fs.readFileSync(filePath).toString(), {
|
||||
plugins: extendDefaultPlugins([
|
||||
{
|
||||
name: "removeViewBox",
|
||||
active: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
symbols.push(generateSymbol(id, data));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
@@ -383,14 +315,13 @@ async function generateIconsSprite(): Promise<void> {
|
||||
}
|
||||
|
||||
init()
|
||||
.then(() => {
|
||||
.then((externals) => {
|
||||
Promise.all([
|
||||
copyStatic(),
|
||||
generateCss(),
|
||||
generateIconsSprite(),
|
||||
generateToolsJs(),
|
||||
generateBackendJs(),
|
||||
generateFrontendJs(),
|
||||
generateBackendJs(externals),
|
||||
generateFrontendJs(externals),
|
||||
])
|
||||
.then(() => {
|
||||
// Ignore
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
personal_ws-1.1 en 0
|
||||
genieacs
|
||||
GenieACS
|
||||
javascript
|
||||
sudo
|
||||
config
|
||||
cwmp
|
||||
CPE
|
||||
systemctl
|
||||
nbi
|
||||
CWMP
|
||||
NBI
|
||||
ACS
|
||||
SSL
|
||||
xsd
|
||||
args
|
||||
DeviceID
|
||||
GENIEACS
|
||||
env
|
||||
param
|
||||
arg
|
||||
AUTH
|
||||
igd
|
||||
TCP
|
||||
toctree
|
||||
auth
|
||||
EnvironmentFile
|
||||
JSON
|
||||
json
|
||||
parameterValues
|
||||
setParameterValues
|
||||
stderr
|
||||
stdout
|
||||
attr
|
||||
cfg
|
||||
CPEs
|
||||
ExecStart
|
||||
faq
|
||||
FileType
|
||||
GenieACS's
|
||||
JWT
|
||||
maxdepth
|
||||
npm
|
||||
objectName
|
||||
refreshObject
|
||||
SerialNumber
|
||||
systemd
|
||||
usr
|
||||
WantedBy
|
||||
yaml
|
||||
chown
|
||||
Config
|
||||
getParameterValues
|
||||
https
|
||||
InternetGatewayDevice
|
||||
mkdir
|
||||
OUI
|
||||
sql
|
||||
TODO
|
||||
UDP
|
||||
addObject
|
||||
AGPLv
|
||||
const
|
||||
cpe
|
||||
cron
|
||||
deleteObject
|
||||
FactoryReset
|
||||
factoryReset
|
||||
FileName
|
||||
fileType
|
||||
Github
|
||||
hostname
|
||||
HTTPS
|
||||
jwt
|
||||
latlong
|
||||
NPM
|
||||
oui
|
||||
parameterNames
|
||||
ProductClass
|
||||
productClass
|
||||
repo
|
||||
SSID
|
||||
sublicense
|
||||
TLS
|
||||
WANIPConnection
|
||||
WPA
|
||||
YourProvisionName
|
||||
abc
|
||||
Abdulla
|
||||
accessors
|
||||
AddObject
|
||||
AddressingType
|
||||
api
|
||||
APIs
|
||||
chmod
|
||||
cwmp's
|
||||
dateext
|
||||
DATETIME
|
||||
dateTime
|
||||
datetime
|
||||
declaratively
|
||||
delaycompress
|
||||
DeleteObject
|
||||
deviceId
|
||||
DHCP
|
||||
dir
|
||||
distro
|
||||
equisatisfiable
|
||||
ExternalIPAddress
|
||||
failover
|
||||
favor
|
||||
func
|
||||
GetParameterAttributes
|
||||
getPassword
|
||||
gte
|
||||
gui
|
||||
hexBinary
|
||||
Hils
|
||||
HOSTNAME
|
||||
init
|
||||
journalctl
|
||||
journald
|
||||
LastFileName
|
||||
LastFileType
|
||||
lastInform
|
||||
LIBXMLJS
|
||||
libxmljs
|
||||
literalinclude
|
||||
lockfile
|
||||
logrotate
|
||||
lte
|
||||
mipsbe
|
||||
normalizers
|
||||
pre
|
||||
quickstart
|
||||
README
|
||||
redis
|
||||
reimplemented
|
||||
RequestDownload
|
||||
RESTful
|
||||
rollout
|
||||
sandboxed
|
||||
scalable
|
||||
SetParameterAttributes
|
||||
SetParamteerValues
|
||||
SIGINT
|
||||
SIGTERM
|
||||
skipWritableCheck
|
||||
stringify
|
||||
subdirectory
|
||||
TargetFileName
|
||||
useradd
|
||||
VirtualParameters
|
||||
vparam
|
||||
xml
|
||||
YAML
|
||||
Zaid
|
||||
cacheExpire
|
||||
iss
|
||||
http
|
||||
statusCode
|
||||
rawData
|
||||
pos
|
||||
Brotli
|
||||
CSV
|
||||
hostInfo
|
||||
IPv
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#! /bin/sh
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FILES=`ls ../docs/*.rst ../docs/*.js ../*.md`
|
||||
|
||||
for FILE in $FILES
|
||||
do
|
||||
echo $FILE
|
||||
cat $FILE | aspell list --lang=en --add-extra-dicts=./spellcheck-dict.pws --ignore 2
|
||||
echo
|
||||
done
|
||||
+16
-16
@@ -1,7 +1,7 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
GenieACS expoeses a rich RESTful API through its NBI component. This document
|
||||
GenieACS exposes a rich RESTful API through its NBI component. This document
|
||||
serves as a reference for the available APIs.
|
||||
|
||||
This API makes use of MongoDB's query language in some of its endpoints. Refer
|
||||
@@ -16,7 +16,7 @@ to MongoDB's documentation for details.
|
||||
|
||||
.. warning::
|
||||
|
||||
A common pitfll is not properly percent-encoding special characters in the
|
||||
A common pitfall is not properly percent-encoding special characters in the
|
||||
device ID or query in the URL.
|
||||
|
||||
Endpoints
|
||||
@@ -41,11 +41,11 @@ Examples
|
||||
|
||||
.. code:: javascript
|
||||
|
||||
query = {"_id": "202BC1-BM632w-0000000"}
|
||||
query = {"_id": "202BC1-BM632w-000000"}
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices/?query=%7B%22_id%22%3A%22202BC1-BM632w-0000000%22%7D'
|
||||
curl -i 'http://localhost:7557/devices/?query=%7B%22_id%22%3A%22202BC1-BM632w-000000%22%7D'
|
||||
|
||||
- Find a device by its MAC address:
|
||||
|
||||
@@ -77,21 +77,21 @@ Examples
|
||||
|
||||
.. code:: javascript
|
||||
|
||||
query = {"device": "202BC1-BM632w-0000000"}
|
||||
query = {"device": "202BC1-BM632w-000000"}
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/tasks/?query=%7B%22device%22%3A%22202BC1-BM632w-0000000%22%7D'
|
||||
curl -i 'http://localhost:7557/tasks/?query=%7B%22device%22%3A%22202BC1-BM632w-000000%22%7D'
|
||||
|
||||
- Return specific parameters for a given device:
|
||||
|
||||
.. code:: javascript
|
||||
|
||||
query = {"_id": "202BC1-BM632w-0000000"}
|
||||
query = {"_id": "202BC1-BM632w-000000"}
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices?query=%7B%22_id%22%3A%22202BC1-BM632w-0000000%22%7D&projection=InternetGatewayDevice.DeviceInfo.ModelName,InternetGatewayDevice.DeviceInfo.Manufacturer'
|
||||
curl -i 'http://localhost:7557/devices?query=%7B%22_id%22%3A%22202BC1-BM632w-000000%22%7D&projection=InternetGatewayDevice.DeviceInfo.ModelName,InternetGatewayDevice.DeviceInfo.Manufacturer'
|
||||
|
||||
The ``projection`` URL param is a comma-separated list of the parameters to receive.
|
||||
|
||||
@@ -106,7 +106,7 @@ if the tasks have been queued to be executed at the next inform.
|
||||
*device_id*: The ID of the device.
|
||||
|
||||
*connection_request*: Indicates that a connection request will be triggered to
|
||||
execute the tasks immediatly. Otherwise, the tasks will be queued and be
|
||||
execute the tasks immediately. Otherwise, the tasks will be queued and be
|
||||
processed at the next inform.
|
||||
|
||||
The response body is the task object as it is inserted in the database. The
|
||||
@@ -120,7 +120,7 @@ Examples
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-0000000/tasks?connection_request' \
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tasks?connection_request' \
|
||||
-X POST \
|
||||
--data '{"name": "refreshObject", "objectName": ""}'
|
||||
|
||||
@@ -138,7 +138,7 @@ Examples
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-0000000/tasks?connection_request' \
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tasks?connection_request' \
|
||||
-X POST \
|
||||
--data '{"name":"setParameterValues", "parameterValues": [["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", "GenieACS", "xsd:string"],["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey", "hello world", "xsd:string"]]}'
|
||||
|
||||
@@ -183,7 +183,7 @@ Example
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/faults/202BC1-BM632w-0000000:default' -X DELETE
|
||||
curl -i 'http://localhost:7557/faults/202BC1-BM632w-000000:default' -X DELETE
|
||||
|
||||
DELETE /devices/\<device_id\>
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -218,7 +218,7 @@ Assign the tag "testing" to a device:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-0000000/tags/testing' -X POST
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tags/testing' -X POST
|
||||
|
||||
DELETE /devices/\<device_id\>/tags/\<tag\>
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -236,7 +236,7 @@ Remove the tag "testing" from a device:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-0000000/tags/testing' -X DELETE
|
||||
curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tags/testing' -X DELETE
|
||||
|
||||
PUT /presets/\<preset_name\>
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -299,7 +299,7 @@ The following file metadata may be sent as request headers:
|
||||
- ``fileType``: For firmware images it should be "1 Firmware Upgrade Image".
|
||||
Other common types are "2 Web Content" and "3 Vendor Configuration File".
|
||||
|
||||
- ``oui``: The OUI of the device model that this file belogs to.
|
||||
- ``oui``: The OUI of the device model that this file belongs to.
|
||||
|
||||
- ``productClass``: The product class of the device.
|
||||
|
||||
@@ -344,7 +344,7 @@ Find files using a query.
|
||||
Tasks
|
||||
-----
|
||||
|
||||
Find the different availabe tasks and their object structure.
|
||||
Find the different available tasks and their object structure.
|
||||
|
||||
``getParameterValues``
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -3,4 +3,70 @@
|
||||
CPE Authentication
|
||||
==================
|
||||
|
||||
CPE to ACS
|
||||
----------
|
||||
|
||||
.. note::
|
||||
|
||||
By default GenieACS will accept any incoming connection via HTTP/HTTPS and
|
||||
respond to it.
|
||||
|
||||
The following parameters are used to set and get (password is redacted but
|
||||
can be set) the username/password used to authenticate against the ACS:
|
||||
|
||||
Username: ``Device.ManagementServer.Username`` or ``InternetGatewayDevice.ManagementServer.Username``
|
||||
|
||||
Password: ``Device.ManagementServer.Password`` or ``InternetGatewayDevice.ManagementServer.Password``
|
||||
|
||||
Enable CPE to ACS Authentication
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
CPE to ACS authentication can be configured in the web interface by using the
|
||||
`Config` option in the `Admin` tab.
|
||||
|
||||
Go to the `Admin` -> `Config` page and click on `New config` button at the
|
||||
bottom of the page. This will open pop-up which requires you to fill in a key
|
||||
and value. The key should be ``cwmp.auth``. The value accepts a boolean.
|
||||
Setting the value to ``true`` makes it so that GenieACS accepts any incoming
|
||||
connection, setting it to ``false`` makes GenieACS deny all incoming
|
||||
connections. This can be further configured using the ``AUTH()`` and ``EXT()``
|
||||
functions.
|
||||
|
||||
The ``AUTH()`` function
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``AUTH()`` function accepts two parameters, username and password. It
|
||||
checks the given username and password with the incoming request to determine
|
||||
whether to return true or false.
|
||||
|
||||
Basic usage of the ``AUTH()`` function could be as follows:
|
||||
|
||||
.. code:: sql
|
||||
|
||||
AUTH("fixed-username", "fixed-password")
|
||||
|
||||
This will only accept incoming request who authenticate with
|
||||
"fixed-username" and "fixed-password".
|
||||
|
||||
The various device parameters can be referenced from within the ``cwmp.auth``
|
||||
expression. For example:
|
||||
|
||||
.. code:: sql
|
||||
|
||||
AUTH(Device.ManagementServer.Username, Device.ManagementServer.Password)
|
||||
|
||||
The ``EXT()`` function
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``EXT()`` function makes it possible to call an :ref:`extension
|
||||
<extensions>` script from the auth expression. This can be used to fetch
|
||||
the credentials from an external source:
|
||||
|
||||
.. code:: sql
|
||||
|
||||
AUTH(DeviceID.SerialNumber, EXT("authenticate", "getPassword", DeviceID.SerialNumber))
|
||||
|
||||
ACS to CPE
|
||||
----------
|
||||
|
||||
TODO
|
||||
@@ -86,7 +86,7 @@ CWMP_ACCESS_LOG_FILE
|
||||
File to log incoming requests for genieacs-cwmp. If omitted, logs will go to
|
||||
stdout.
|
||||
|
||||
Default: unest
|
||||
Default: unset
|
||||
|
||||
NBI_WORKER_PROCESSES
|
||||
The number of worker processes to spawn for genieacs-nbi. A value of 0 means
|
||||
@@ -107,7 +107,7 @@ NBI_INTERFACE
|
||||
NBI_SSL_CERT
|
||||
Path to certificate file. If omitted, non-secure HTTP will be used.
|
||||
|
||||
Default: unest
|
||||
Default: unset
|
||||
|
||||
NBI_SSL_KEY
|
||||
Path to certificate key file. If omitted, non-secure HTTP will be used.
|
||||
|
||||
+19
-16
@@ -1,4 +1,4 @@
|
||||
// This is an exmaple GenieACS extension to get the current latitude/longitude
|
||||
// This is an example GenieACS extension to get the current latitude/longitude
|
||||
// of the International Space Station. Why, you ask? Because why not.
|
||||
// To install, copy this file to config/ext/iss.js.
|
||||
|
||||
@@ -10,25 +10,28 @@ let cache = null;
|
||||
let cacheExpire = 0;
|
||||
|
||||
function latlong(args, callback) {
|
||||
if (Date.now() < cacheExpire)
|
||||
return callback(null, cache);
|
||||
if (Date.now() < cacheExpire) return callback(null, cache);
|
||||
|
||||
http.get("http://api.open-notify.org/iss-now.json", (res) => {
|
||||
if (res.statusCode !== 200)
|
||||
return callback(new Error(`Request failed (status code: ${res.statusCode})`));
|
||||
http
|
||||
.get("http://api.open-notify.org/iss-now.json", (res) => {
|
||||
if (res.statusCode !== 200)
|
||||
return callback(
|
||||
new Error(`Request failed (status code: ${res.statusCode})`)
|
||||
);
|
||||
|
||||
let rawData = "";
|
||||
res.on("data", (chunk) => rawData += chunk);
|
||||
let rawData = "";
|
||||
res.on("data", (chunk) => (rawData += chunk));
|
||||
|
||||
res.on("end", () => {
|
||||
let pos = JSON.parse(rawData)["iss_position"];
|
||||
cache = [+pos["latitude"], +pos["longitude"]];
|
||||
cacheExpire = Date.now() + 10000;
|
||||
callback(null, cache);
|
||||
res.on("end", () => {
|
||||
let pos = JSON.parse(rawData)["iss_position"];
|
||||
cache = [+pos["latitude"], +pos["longitude"]];
|
||||
cacheExpire = Date.now() + 10000;
|
||||
callback(null, cache);
|
||||
});
|
||||
})
|
||||
.on("error", (err) => {
|
||||
callback(err);
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
exports.latlong = latlong;
|
||||
@@ -4,7 +4,7 @@ Installation Guide
|
||||
This guide is for installing GenieACS on a single server on any Linux distro
|
||||
that uses *systemd* as its init system.
|
||||
|
||||
The various GenieACS services are independant of each other and may be
|
||||
The various GenieACS services are independent of each other and may be
|
||||
installed on different servers. You may also run multiple instances of each in
|
||||
a load-balancing/failover setup.
|
||||
|
||||
@@ -34,7 +34,7 @@ Install GenieACS
|
||||
|
||||
.. parsed-literal::
|
||||
|
||||
sudo npm install -g --unsafe-perm genieacs@\ |release|
|
||||
sudo npm install -g genieacs@\ |release|
|
||||
|
||||
.. topic:: Installing from source
|
||||
|
||||
@@ -73,6 +73,7 @@ Configure systemd
|
||||
GENIEACS_FS_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-fs-access.log
|
||||
GENIEACS_UI_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-ui-access.log
|
||||
GENIEACS_DEBUG_FILE=/var/log/genieacs/genieacs-debug.yaml
|
||||
NODE_OPTIONS=--enable-source-maps
|
||||
GENIEACS_EXT_DIR=/opt/genieacs/ext
|
||||
GENIEACS_UI_JWT_SECRET=secret
|
||||
|
||||
|
||||
+70
-40
@@ -37,7 +37,7 @@ import { evaluate } from "./common/expression";
|
||||
export async function connectionRequest(
|
||||
deviceId: string,
|
||||
device?: Record<string, { value?: [boolean | number | string, string] }>
|
||||
): Promise<void> {
|
||||
): Promise<string> {
|
||||
if (!device) {
|
||||
const res = await db.devicesCollection.findOne({ _id: deviceId });
|
||||
if (!res) throw new Error("No such device");
|
||||
@@ -145,54 +145,71 @@ export async function connectionRequest(
|
||||
|
||||
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback);
|
||||
|
||||
let udpProm;
|
||||
let udpProm = Promise.resolve(false);
|
||||
if (udpConnectionRequestAddress) {
|
||||
udpProm = udpConnectionRequest(
|
||||
udpConnectionRequestAddress,
|
||||
authExp,
|
||||
UDP_CONNECTION_REQUEST_PORT,
|
||||
debug,
|
||||
deviceId
|
||||
);
|
||||
try {
|
||||
const u = new URL("udp://" + udpConnectionRequestAddress);
|
||||
udpProm = udpConnectionRequest(
|
||||
u.hostname,
|
||||
parseInt(u.port || "80"),
|
||||
authExp,
|
||||
UDP_CONNECTION_REQUEST_PORT,
|
||||
debug,
|
||||
deviceId
|
||||
).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore invalid address
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await httpConnectionRequest(
|
||||
connectionRequestUrl,
|
||||
authExp,
|
||||
CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
|
||||
CONNECTION_REQUEST_TIMEOUT,
|
||||
debug,
|
||||
deviceId
|
||||
);
|
||||
} catch (err) {
|
||||
if (!udpProm) throw err;
|
||||
await udpProm;
|
||||
}
|
||||
const status = await httpConnectionRequest(
|
||||
connectionRequestUrl,
|
||||
authExp,
|
||||
CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
|
||||
CONNECTION_REQUEST_TIMEOUT,
|
||||
debug,
|
||||
deviceId
|
||||
);
|
||||
|
||||
if (await udpProm) return "";
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function watchTask(
|
||||
export async function awaitSessionStart(
|
||||
deviceId: string,
|
||||
taskId: string,
|
||||
lastInform: number,
|
||||
timeout: number
|
||||
): Promise<string> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const task = await db.tasksCollection.findOne(
|
||||
{ _id: taskId },
|
||||
{ projection: { _id: 1 } }
|
||||
): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const device = await db.devicesCollection.findOne(
|
||||
{ _id: deviceId },
|
||||
{ projection: { _lastInform: 1 } }
|
||||
);
|
||||
if (!task) return "completed";
|
||||
const li = (device["_lastInform"] as Date).getTime();
|
||||
if (li > lastInform) return true;
|
||||
const token = await cache.get(`cwmp_session_${deviceId}`);
|
||||
if (token) return true;
|
||||
if (timeout < 500) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
timeout -= Date.now() - now;
|
||||
return awaitSessionStart(deviceId, lastInform, timeout);
|
||||
}
|
||||
|
||||
const q = { _id: `${deviceId}:task_${taskId}` };
|
||||
const fault = await db.faultsCollection.findOne(q, {
|
||||
projection: { _id: 1 },
|
||||
});
|
||||
if (fault) return "fault";
|
||||
|
||||
if ((timeout -= 500) <= 0) return "timeout";
|
||||
|
||||
return watchTask(deviceId, taskId, timeout);
|
||||
export async function awaitSessionEnd(
|
||||
deviceId: string,
|
||||
timeout: number
|
||||
): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const token = await cache.get(`cwmp_session_${deviceId}`);
|
||||
if (!token) return true;
|
||||
if (timeout < 500) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
timeout -= Date.now() - now;
|
||||
return awaitSessionEnd(deviceId, timeout);
|
||||
}
|
||||
|
||||
function sanitizeTask(task): void {
|
||||
@@ -236,6 +253,10 @@ function sanitizeTask(task): void {
|
||||
break;
|
||||
|
||||
case "refreshObject":
|
||||
if (typeof task.objectName !== "string")
|
||||
throw new Error("Missing 'objectName' property");
|
||||
break;
|
||||
|
||||
case "deleteObject":
|
||||
if (typeof task.objectName !== "string" || !task.objectName.length)
|
||||
throw new Error("Missing 'objectName' property");
|
||||
@@ -281,6 +302,15 @@ function sanitizeTask(task): void {
|
||||
)
|
||||
throw new Error("Invalid 'provisions' property");
|
||||
break;
|
||||
|
||||
case "reboot":
|
||||
break;
|
||||
|
||||
case "factoryReset":
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("Invalid task name");
|
||||
}
|
||||
|
||||
return task;
|
||||
|
||||
+69
-93
@@ -17,126 +17,102 @@
|
||||
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MongoClient, Collection } from "mongodb";
|
||||
import { Collection } from "mongodb";
|
||||
import { onConnect } from "./db";
|
||||
import * as config from "./config";
|
||||
|
||||
const CLOCK_SKEW_TOLERANCE = 30000;
|
||||
const MAX_CACHE_TTL = +config.get("MAX_CACHE_TTL");
|
||||
|
||||
let clientPromise: Promise<MongoClient>;
|
||||
let mongoCollection: Collection;
|
||||
let mongoTimeOffset = 0;
|
||||
let cacheCollection: Collection;
|
||||
|
||||
export async function connect(): Promise<void> {
|
||||
const MONGODB_CONNECTION_URL = "" + config.get("MONGODB_CONNECTION_URL");
|
||||
clientPromise = MongoClient.connect(MONGODB_CONNECTION_URL, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
});
|
||||
const db = (await clientPromise).db();
|
||||
mongoCollection = db.collection("cache");
|
||||
await mongoCollection.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
|
||||
const now = Date.now();
|
||||
const res = await db.command({ hostInfo: 1 });
|
||||
mongoTimeOffset = res.system.currentTime.getTime() - now;
|
||||
onConnect(async (db) => {
|
||||
cacheCollection = db.collection("cache");
|
||||
await cacheCollection.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
|
||||
});
|
||||
|
||||
export async function get(key: string): Promise<string> {
|
||||
const res = await cacheCollection.findOne({ _id: key });
|
||||
if (res) return res["value"];
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function disconnect(): Promise<void> {
|
||||
if (clientPromise) await (await clientPromise).close();
|
||||
}
|
||||
|
||||
export async function get(key: string | string[]): Promise<any> {
|
||||
const expire = new Date(Date.now() - mongoTimeOffset);
|
||||
if (Array.isArray(key)) {
|
||||
const res = await mongoCollection.find({ _id: { $in: key } }).toArray();
|
||||
|
||||
const indices = {};
|
||||
key.forEach((v, i) => {
|
||||
indices[v] = i;
|
||||
});
|
||||
|
||||
const values = [];
|
||||
res.forEach((r) => {
|
||||
if (r["expire"] > expire) values[indices[r["_id"]]] = r["value"];
|
||||
});
|
||||
|
||||
return values;
|
||||
} else {
|
||||
const res = await mongoCollection.findOne({ _id: { $in: [key] } });
|
||||
if (res && res["expire"] > expire) return res["value"];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function del(key: string | string[]): Promise<void> {
|
||||
if (Array.isArray(key))
|
||||
await mongoCollection.deleteMany({ _id: { $in: key } });
|
||||
else await mongoCollection.deleteOne({ _id: key });
|
||||
export async function del(key: string): Promise<void> {
|
||||
await cacheCollection.deleteOne({ _id: key });
|
||||
}
|
||||
|
||||
export async function set(
|
||||
key: string,
|
||||
value: string | number,
|
||||
value: string,
|
||||
ttl: number = MAX_CACHE_TTL
|
||||
): Promise<void> {
|
||||
const expire = new Date(Date.now() - mongoTimeOffset + ttl * 1000);
|
||||
await mongoCollection.replaceOne(
|
||||
const timestamp = new Date();
|
||||
const expire = new Date(
|
||||
timestamp.getTime() + CLOCK_SKEW_TOLERANCE + ttl * 1000
|
||||
);
|
||||
await cacheCollection.replaceOne(
|
||||
{ _id: key },
|
||||
{ _id: key, value: value, expire: expire },
|
||||
{ _id: key, value, expire, timestamp },
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
export async function pop(key: string): Promise<any> {
|
||||
const res = await mongoCollection.findOneAndDelete({ _id: key });
|
||||
|
||||
if (
|
||||
res &&
|
||||
res["value"] &&
|
||||
+res["value"]["expire"] - (Date.now() - mongoTimeOffset)
|
||||
)
|
||||
return res["value"]["value"];
|
||||
|
||||
const res = await cacheCollection.findOneAndDelete({ _id: key });
|
||||
if (res && res["value"]) return res["value"]["value"];
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function lock(
|
||||
export async function acquireLock(
|
||||
lockName: string,
|
||||
ttl: number
|
||||
): Promise<(extendTtl?: number) => Promise<void>> {
|
||||
const token = Math.random().toString(36).slice(2);
|
||||
|
||||
async function unlockOrExtend(extendTtl?: number): Promise<void> {
|
||||
if (!extendTtl) {
|
||||
const res = await mongoCollection.deleteOne({
|
||||
_id: lockName,
|
||||
value: token,
|
||||
});
|
||||
if (res["result"]["n"] !== 1) throw new Error("Lock expired");
|
||||
} else {
|
||||
const expire = new Date(Date.now() - mongoTimeOffset + extendTtl * 1000);
|
||||
const res = await mongoCollection.updateOne(
|
||||
{ _id: lockName, value: token },
|
||||
{ expire: expire }
|
||||
);
|
||||
if (res["result"]["n"] !== 1) throw new Error("Lock expired");
|
||||
}
|
||||
}
|
||||
|
||||
const expireTest = new Date(Date.now() - mongoTimeOffset);
|
||||
const expireSet = new Date(Date.now() - mongoTimeOffset + ttl * 1000);
|
||||
|
||||
ttl: number,
|
||||
timeout = 0,
|
||||
token = Math.random().toString(36).slice(2)
|
||||
): Promise<string> {
|
||||
try {
|
||||
await mongoCollection.updateOne(
|
||||
{ _id: lockName, expire: { $lte: expireTest } },
|
||||
{ $set: { value: token, expire: expireSet } },
|
||||
{ upsert: true }
|
||||
const now = Date.now();
|
||||
const r = await cacheCollection.findOneAndUpdate(
|
||||
{ _id: lockName, token },
|
||||
{
|
||||
$set: {
|
||||
value: token,
|
||||
expire: new Date(now + ttl + CLOCK_SKEW_TOLERANCE),
|
||||
},
|
||||
$currentDate: { timestamp: true },
|
||||
},
|
||||
{ upsert: true, returnOriginal: false }
|
||||
);
|
||||
const v = r.value;
|
||||
if (Math.abs(v["timestamp"].getTime() - now) > CLOCK_SKEW_TOLERANCE)
|
||||
throw new Error("Database clock skew too great");
|
||||
} catch (err) {
|
||||
if (err && err.code === 11000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return lock(lockName, ttl);
|
||||
if (err.code === 11000) {
|
||||
if (timeout > 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = 50 + Math.random() * 50;
|
||||
setTimeout(() => {
|
||||
acquireLock(lockName, ttl, timeout - w, token).then(
|
||||
resolve,
|
||||
reject
|
||||
);
|
||||
}, w);
|
||||
});
|
||||
}
|
||||
throw new Error("Failed to acquire lock");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return unlockOrExtend;
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function releaseLock(
|
||||
lockName: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const res = await cacheCollection.deleteOne({
|
||||
_id: lockName,
|
||||
value: token,
|
||||
});
|
||||
if (res["result"]["n"] !== 1) throw new Error("Lock expired");
|
||||
}
|
||||
@@ -47,3 +47,17 @@ export function generateDeviceId(
|
||||
export function escapeRegExp(str: string): string {
|
||||
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function encodeTag(tag: string): string {
|
||||
return encodeURIComponent(tag)
|
||||
.replace(
|
||||
/[!~*'().]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
|
||||
)
|
||||
.replace(/0x(?=[0-9A-Z]{2})/g, "0%78")
|
||||
.replace(/%/g, "0x");
|
||||
}
|
||||
|
||||
export function decodeTag(tag: string): string {
|
||||
return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, "%"));
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Copyright 2013-2020 GenieACS Inc.
|
||||
*
|
||||
* This file is part of GenieACS.
|
||||
*
|
||||
* GenieACS 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.
|
||||
*
|
||||
* GenieACS 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 GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import JSBI from "jsbi";
|
||||
|
||||
export function and(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.bitwiseAnd(a, b);
|
||||
}
|
||||
|
||||
export function or(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.bitwiseOr(a, b);
|
||||
}
|
||||
|
||||
export function xor(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.bitwiseXor(a, b);
|
||||
}
|
||||
|
||||
export function not(a: JSBI): JSBI {
|
||||
return JSBI.bitwiseNot(a);
|
||||
}
|
||||
|
||||
export function lshift(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.leftShift(a, b);
|
||||
}
|
||||
|
||||
export function rshift(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.signedRightShift(a, b);
|
||||
}
|
||||
|
||||
export function add(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.add(a, b);
|
||||
}
|
||||
|
||||
export function sub(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.subtract(a, b);
|
||||
}
|
||||
|
||||
export function mul(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.multiply(a, b);
|
||||
}
|
||||
|
||||
export function div(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.divide(a, b);
|
||||
}
|
||||
|
||||
export function exp(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.exponentiate(a, b);
|
||||
}
|
||||
|
||||
export function rem(a: JSBI, b: JSBI): JSBI {
|
||||
return JSBI.remainder(a, b);
|
||||
}
|
||||
|
||||
export function toNumber(a: JSBI): number {
|
||||
return JSBI.toNumber(a);
|
||||
}
|
||||
|
||||
export function eq(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.equal(a, b);
|
||||
}
|
||||
|
||||
export function ne(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.notEqual(a, b);
|
||||
}
|
||||
|
||||
export function lt(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.lessThan(a, b);
|
||||
}
|
||||
|
||||
export function lte(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.lessThanOrEqual(a, b);
|
||||
}
|
||||
|
||||
export function gt(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.greaterThan(a, b);
|
||||
}
|
||||
|
||||
export function gte(a: JSBI, b: JSBI): boolean {
|
||||
return JSBI.greaterThanOrEqual(a, b);
|
||||
}
|
||||
|
||||
export function asUintN(a: number, b: JSBI): JSBI {
|
||||
return JSBI.asUintN(a, b);
|
||||
}
|
||||
|
||||
export function asIntN(a: number, b: JSBI): JSBI {
|
||||
return JSBI.asIntN(a, b);
|
||||
}
|
||||
|
||||
const _BigInt = JSBI.BigInt;
|
||||
type _bigint = JSBI;
|
||||
export { _BigInt as BigInt, _bigint as bigint };
|
||||
@@ -362,6 +362,10 @@ class VarSynth extends BoolExprSynth {
|
||||
exp = exp.slice();
|
||||
exp[0] = ">";
|
||||
this.negate = true;
|
||||
} else if (op === "NOT LIKE") {
|
||||
exp = exp.slice();
|
||||
exp[0] = "LIKE";
|
||||
this.negate = true;
|
||||
}
|
||||
}
|
||||
this.expStr = JSON.stringify(exp);
|
||||
@@ -809,6 +813,7 @@ function sopToExpression(
|
||||
if (Array.isArray(expr) && expr[0] === "NOT" && Array.isArray(expr[1])) {
|
||||
const e: Expression[] = expr[1];
|
||||
if (e[0] === "IS NULL") expr = ["IS NOT NULL", ...e.slice(1)];
|
||||
else if (e[0] === "LIKE") expr = ["NOT LIKE", ...e.slice(1)];
|
||||
else if (e[0] === "=") expr = ["<>", ...e.slice(1)];
|
||||
else if (e[0] === "<>") expr = ["=", ...e.slice(1)];
|
||||
else if (e[0] === ">") expr = ["<=", ...e.slice(1)];
|
||||
@@ -830,7 +835,6 @@ function sopToExpression(
|
||||
function generateDcSetAndIsNull(
|
||||
variables: Map<number, Expression>
|
||||
): { dcSet: number[][]; isNull: Map<number, number> } {
|
||||
// TODO support LIKE and NOT LIKE operators
|
||||
const relations: Map<
|
||||
string,
|
||||
{ op: string; rhs: boolean | number | string; var: number }[]
|
||||
@@ -843,6 +847,11 @@ function generateDcSetAndIsNull(
|
||||
let r = relations.get(lhs);
|
||||
if (!r) relations.set(lhs, (r = []));
|
||||
r.push({ op: op, rhs: e[2], var: n });
|
||||
} else if (op === "LIKE" && !Array.isArray(e[2]) && !Array.isArray(e[3])) {
|
||||
const lhs = JSON.stringify(e[1]);
|
||||
let r = relations.get(lhs);
|
||||
if (!r) relations.set(lhs, (r = []));
|
||||
r.push({ op: "", rhs: null, var: n });
|
||||
} else {
|
||||
const lhs = JSON.stringify(e);
|
||||
let r = relations.get(lhs);
|
||||
@@ -1019,8 +1028,7 @@ function boolExprSynthToExpression(boolExpr: BoolExprSynth): Expression {
|
||||
const { dcSet, isNull } = generateDcSetAndIsNull(variables);
|
||||
minterms = sanitizeMinterms(minterms, isNull);
|
||||
|
||||
const canRaise = (idx: number, set: Set<number>): boolean =>
|
||||
!isNull.has(idx >> 2) || !(idx & 1) || !set.has(idx ^ 3);
|
||||
const canRaise = getCanRaiseCallback(isNull);
|
||||
minterms = espresso(minterms, dcSet, { canRaise });
|
||||
return sopToExpression(minterms, variables);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function interpretEscapes(str): string {
|
||||
|
||||
export function map(
|
||||
exp: Expression,
|
||||
callback: (exp: Expression) => Expression
|
||||
callback: (e: Expression) => Expression
|
||||
): Expression {
|
||||
if (!Array.isArray(exp)) return callback(exp);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function map(
|
||||
|
||||
export async function mapAsync(
|
||||
exp: Expression,
|
||||
callback: (exp: Expression) => Promise<Expression>
|
||||
callback: (e: Expression) => Promise<Expression>
|
||||
): Promise<Expression> {
|
||||
if (!Array.isArray(exp)) return callback(exp);
|
||||
|
||||
|
||||
@@ -130,6 +130,15 @@ export function evaluateCallback(exp: Expression): Expression {
|
||||
} else if (exp[1] === "LOWER") {
|
||||
if (exp[2] == null) return null;
|
||||
if (!isArray(exp[2])) return toString(exp[2]).toLowerCase();
|
||||
} else if (exp[1] === "ROUND") {
|
||||
const n = exp[2];
|
||||
const p = exp.length > 3 ? exp[3] : 0;
|
||||
if (n == null || p == null) return null;
|
||||
if (!isArray(n) && !isArray(p)) {
|
||||
const d = 10 ** p;
|
||||
const m = n * d * (1 + Number.EPSILON);
|
||||
return Math.round(m) / d;
|
||||
}
|
||||
}
|
||||
} else if (exp[0] === "PARAM") {
|
||||
if (exp[1] == null) return null;
|
||||
@@ -264,21 +273,21 @@ export function evaluateCallback(exp: Expression): Expression {
|
||||
|
||||
export function evaluate(
|
||||
exp: Expression,
|
||||
obj: Record<string, unknown> | ((exp: string) => Expression),
|
||||
obj: Record<string, unknown> | ((e: string) => Expression),
|
||||
now: number,
|
||||
cb?: (exp: Expression) => Expression
|
||||
cb?: (e: Expression) => Expression
|
||||
): string | number | boolean | null;
|
||||
export function evaluate(
|
||||
exp: Expression,
|
||||
obj?: Record<string, unknown> | ((exp: string) => Expression),
|
||||
obj?: Record<string, unknown> | ((e: string) => Expression),
|
||||
now?: number,
|
||||
cb?: (exp: Expression) => Expression
|
||||
cb?: (e: Expression) => Expression
|
||||
): Expression;
|
||||
export function evaluate(
|
||||
exp: Expression,
|
||||
obj?: Record<string, unknown> | ((exp: string) => Expression),
|
||||
obj?: Record<string, unknown> | ((e: string) => Expression),
|
||||
now?: number,
|
||||
cb?: (exp: Expression) => Expression
|
||||
cb?: (e: Expression) => Expression
|
||||
): Expression {
|
||||
return map(exp, (e) => {
|
||||
if (cb) e = cb(e);
|
||||
@@ -306,19 +315,19 @@ export async function evaluateAsync(
|
||||
exp: Expression,
|
||||
obj: Record<string, unknown>,
|
||||
now: number,
|
||||
cb?: (exp: Expression) => Promise<Expression>
|
||||
cb?: (e: Expression) => Promise<Expression>
|
||||
): Promise<string | number | boolean | null>;
|
||||
export async function evaluateAsync(
|
||||
exp: Expression,
|
||||
obj?: Record<string, unknown>,
|
||||
now?: number,
|
||||
cb?: (exp: Expression) => Promise<Expression>
|
||||
cb?: (e: Expression) => Promise<Expression>
|
||||
): Promise<Expression>;
|
||||
export async function evaluateAsync(
|
||||
exp: Expression,
|
||||
obj?: Record<string, unknown>,
|
||||
now?: number,
|
||||
cb?: (exp: Expression) => Promise<Expression>
|
||||
cb?: (e: Expression) => Promise<Expression>
|
||||
): Promise<Expression> {
|
||||
return mapAsync(exp, async (e) => {
|
||||
if (cb) e = await cb(e);
|
||||
|
||||
+12
-3
@@ -47,9 +47,18 @@ export default function memoize<T extends (...args: any[]) => any>(func: T): T {
|
||||
if (cache1.has(key)) return cache1.get(key);
|
||||
|
||||
let r;
|
||||
if (cache2.has(key)) r = cache2.get(key);
|
||||
else r = func(...args);
|
||||
cache1.set(key, r);
|
||||
if (cache2.has(key)) {
|
||||
cache1.set(key, (r = cache2.get(key)));
|
||||
} else {
|
||||
cache1.set(key, (r = func(...args)));
|
||||
// Evict rejected promises
|
||||
if (r instanceof Promise) {
|
||||
r.catch(() => {
|
||||
cache1.delete(key);
|
||||
cache2.delete(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}) as any;
|
||||
}
|
||||
|
||||
+104
-49
@@ -23,6 +23,31 @@ type Alias = [Path, string][];
|
||||
let cache1 = new Map<string, Path>();
|
||||
let cache2 = new Map<string, Path>();
|
||||
|
||||
const CHAR_SPACE = 32;
|
||||
const CHAR_BACKSLASH = 92;
|
||||
const CHAR_COLON = 58;
|
||||
const CHAR_QUOTE = 34;
|
||||
const CHAR_OPEN_BRACKET = 91;
|
||||
const CHAR_CLOSE_BRACKET = 93;
|
||||
const CHAR_ASTERISK = 42;
|
||||
const CHAR_COMMA = 44;
|
||||
const CHAR_DOT = 46;
|
||||
|
||||
function charCodeAt(str: string, index: number): number {
|
||||
if (index >= str.length) return 0;
|
||||
return str.charCodeAt(index);
|
||||
}
|
||||
|
||||
function legalChar(c: number): boolean {
|
||||
return (
|
||||
(c >= 97 && c <= 122) ||
|
||||
(c >= 65 && c <= 90) ||
|
||||
(c >= 48 && c <= 57) ||
|
||||
c === 95 ||
|
||||
c === 45
|
||||
);
|
||||
}
|
||||
|
||||
export default class Path {
|
||||
public readonly segments: Segments;
|
||||
public readonly wildcard: number;
|
||||
@@ -30,37 +55,70 @@ export default class Path {
|
||||
protected _string: string;
|
||||
protected _stringIndex: number[];
|
||||
|
||||
protected static parseAliasValue(
|
||||
pattern: string,
|
||||
index: number
|
||||
): { index: number; value: string } {
|
||||
let i = index;
|
||||
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
|
||||
if (charCodeAt(pattern, i) === CHAR_QUOTE) {
|
||||
for (let j = i + 1; j < pattern.length; ++j) {
|
||||
if (
|
||||
pattern.charCodeAt(j) === CHAR_QUOTE &&
|
||||
pattern.charCodeAt(j - 1) !== CHAR_BACKSLASH
|
||||
) {
|
||||
try {
|
||||
++j;
|
||||
const v = JSON.parse(pattern.slice(i, j));
|
||||
return { index: j, value: v };
|
||||
} catch {
|
||||
return { index, value: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { index, value: null };
|
||||
}
|
||||
|
||||
for (; i < pattern.length; ++i) {
|
||||
const c = pattern.charCodeAt(i);
|
||||
if (c === CHAR_CLOSE_BRACKET || c === CHAR_COMMA) break;
|
||||
}
|
||||
|
||||
return { index: i, value: pattern.slice(index, i).trim() };
|
||||
}
|
||||
|
||||
protected static parseAliasPath(
|
||||
pattern: string,
|
||||
index: number
|
||||
): { index: number; path: Path } {
|
||||
let i = index;
|
||||
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
|
||||
const { index: idx, segments } = Path.parsePath(pattern, i);
|
||||
if (idx === i) return { index, path: null };
|
||||
i = idx;
|
||||
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
|
||||
if (charCodeAt(pattern, i) !== CHAR_COLON) return { index, path: null };
|
||||
return { index: i + 1, path: new Path(segments) };
|
||||
}
|
||||
|
||||
protected static parseAlias(
|
||||
pattern: string,
|
||||
index: number
|
||||
): { index: number; alias: Alias } {
|
||||
const alias: Alias = [];
|
||||
while (index < pattern.length && pattern[index] !== "]") {
|
||||
const { index: idx, segments } = Path.parsePath(pattern, index);
|
||||
let j = (index = idx + 1);
|
||||
while (pattern[j] !== "]" && pattern[j] !== ",") {
|
||||
if (pattern[j] === '"' && index === j) {
|
||||
++j;
|
||||
while (pattern[j] !== '"' || pattern[j - 1] === "\\") {
|
||||
if (++j >= pattern.length)
|
||||
throw new Error("Invalid alias expression");
|
||||
}
|
||||
}
|
||||
if (++j >= pattern.length) throw new Error("Invalid alias expression");
|
||||
}
|
||||
let i = index;
|
||||
for (;;) {
|
||||
let path: Path;
|
||||
let value: string;
|
||||
({ index: i, path } = Path.parseAliasPath(pattern, i));
|
||||
if (!path) break;
|
||||
({ index: i, value } = Path.parseAliasValue(pattern, i));
|
||||
if (value == null) break;
|
||||
alias.push([path, value]);
|
||||
index = i;
|
||||
|
||||
let value = pattern.slice(index, j).trim();
|
||||
index = j;
|
||||
if (value[0] === '"') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid alias expression");
|
||||
}
|
||||
}
|
||||
|
||||
alias.push([new Path(segments), value]);
|
||||
if (pattern[index] === ",") ++index;
|
||||
if (charCodeAt(pattern, index) !== CHAR_COMMA) break;
|
||||
++i;
|
||||
}
|
||||
|
||||
// Ensure identical expressions have idential string representation
|
||||
@@ -81,30 +139,26 @@ export default class Path {
|
||||
index: number
|
||||
): { index: number; segments: Segments } {
|
||||
const segments = [];
|
||||
// Colon separator is needed for parseAlias
|
||||
if (index < pattern.length && pattern[index] !== ":") {
|
||||
for (;;) {
|
||||
if (pattern[index] === "[") {
|
||||
const { index: idx, alias } = Path.parseAlias(pattern, index + 1);
|
||||
index = idx + 1;
|
||||
segments.push(alias);
|
||||
} else {
|
||||
const j = index;
|
||||
while (
|
||||
index < pattern.length &&
|
||||
pattern[index] !== ":" &&
|
||||
pattern[index] !== "."
|
||||
)
|
||||
++index;
|
||||
const s = pattern.slice(j, index).trim();
|
||||
if (!s) throw new Error("Invalid parameter path");
|
||||
segments.push(s);
|
||||
}
|
||||
|
||||
if (index >= pattern.length || pattern[index] === ":") break;
|
||||
else if (pattern[index] !== ".")
|
||||
throw new Error("Invalid alias expression");
|
||||
++index;
|
||||
let segStart = index;
|
||||
let wildcard = -1;
|
||||
for (let i = index; i <= pattern.length; ++i) {
|
||||
const c = charCodeAt(pattern, i);
|
||||
if (c === CHAR_OPEN_BRACKET && i === segStart) {
|
||||
const { index: idx, alias } = Path.parseAlias(pattern, i + 1);
|
||||
if (charCodeAt(pattern, idx) !== CHAR_CLOSE_BRACKET) break;
|
||||
segments.push(alias);
|
||||
index = i = idx + 1;
|
||||
segStart = i + 1;
|
||||
} else if (c === CHAR_ASTERISK && i === segStart) {
|
||||
wildcard = i + 1;
|
||||
} else if (!legalChar(c) || i === wildcard) {
|
||||
if (i === segStart) break;
|
||||
const s = pattern.slice(segStart, i);
|
||||
segments.push(s);
|
||||
index = i;
|
||||
if (c !== CHAR_DOT) break;
|
||||
segStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +197,8 @@ export default class Path {
|
||||
if (!path) {
|
||||
path = cache2.get(str);
|
||||
if (!path) {
|
||||
const { segments } = Path.parsePath(str, 0);
|
||||
const { index, segments } = Path.parsePath(str, 0);
|
||||
if (index < str.length) throw new Error("Invalid parameter path");
|
||||
path = new Path(segments);
|
||||
if (path.toString() !== str) cache1.set(path.toString(), path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
const LINE_WIDTH = 80;
|
||||
const INDENTATION = " ";
|
||||
|
||||
const STRING_RESERVED = new Set([
|
||||
"true",
|
||||
"True",
|
||||
"TRUE",
|
||||
"false",
|
||||
"False",
|
||||
"FALSE",
|
||||
"null",
|
||||
"Null",
|
||||
"NULL",
|
||||
]);
|
||||
|
||||
function isPrintable(str: string): boolean {
|
||||
return !/[^\t\n\x20-\x7e\x85\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u.test(
|
||||
str
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyKey(str: string): string {
|
||||
if (!str || !isPrintable(str)) return JSON.stringify(str);
|
||||
if (/^[\s-?:,[\]{}#&$!|>'"%@`]|: | #|[\n,[\]{}]|\s$/.test(str))
|
||||
return JSON.stringify(str);
|
||||
return str;
|
||||
}
|
||||
|
||||
function foldString(str: string): string[] {
|
||||
if (str.length <= LINE_WIDTH) return [str];
|
||||
if (str.startsWith(" ")) return [str];
|
||||
const lines: string[] = [];
|
||||
|
||||
let idx = 0;
|
||||
let cand = 0;
|
||||
for (let i = 1; i < str.length - 1; ++i) {
|
||||
if (str[i] !== " ") continue;
|
||||
|
||||
if (str[i + 1] === " ") {
|
||||
i += 2;
|
||||
while (str[i] === " ") ++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i <= idx + LINE_WIDTH) {
|
||||
cand = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
const c = cand > idx ? cand : i;
|
||||
lines.push(str.slice(idx, c));
|
||||
idx = c + 1;
|
||||
cand = i;
|
||||
}
|
||||
|
||||
if (cand > idx && str.length > idx + LINE_WIDTH) {
|
||||
lines.push(str.slice(idx, cand));
|
||||
idx = cand + 1;
|
||||
}
|
||||
|
||||
lines.push(str.slice(idx));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function stringifyString(str: string, res: string[], prefix1, prefix2): void {
|
||||
if (/^\s*$/.test(str) || STRING_RESERVED.has(str) || !isPrintable(str)) {
|
||||
res.push(prefix1 + JSON.stringify(str));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prefix2) prefix2 = INDENTATION;
|
||||
|
||||
const lines = str.split("\n");
|
||||
if (lines.length > 1) {
|
||||
let idt = "";
|
||||
let chmp = "-";
|
||||
if ((lines.find((l) => l) || "").startsWith(" "))
|
||||
idt = `${INDENTATION.length}`;
|
||||
|
||||
if (!lines[lines.length - 1]) {
|
||||
lines.pop();
|
||||
if (lines[lines.length - 1]) chmp = "";
|
||||
else chmp = "+";
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(lines[lines.length - 1])) {
|
||||
res.push(prefix1 + JSON.stringify(str));
|
||||
return;
|
||||
}
|
||||
|
||||
let isFolded = false;
|
||||
const folded = lines.map((l) => {
|
||||
const ls = foldString(l);
|
||||
if (ls.length > 1) isFolded = true;
|
||||
return ls;
|
||||
});
|
||||
|
||||
if (!isFolded) {
|
||||
res.push(
|
||||
`${prefix1}|${idt}${chmp}`,
|
||||
...lines.map((l) => (l ? prefix2 + l : l))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.push(`${prefix1}>${idt}${chmp}`);
|
||||
res.push(...folded[0].map((f) => prefix2 + f));
|
||||
for (let i = 1; i < folded.length; ++i) {
|
||||
const prevLine = folded[i - 1][0];
|
||||
if (prevLine && !folded[i - 1][0].startsWith(" ")) res.push("");
|
||||
res.push(...folded[i].map((f) => prefix2 + f));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
/^[\s-?:,[\]{}#&$!|>'"%@`]|: | #|\s$/.test(str) ||
|
||||
parseFloat(str) === +str
|
||||
) {
|
||||
res.push(prefix1 + JSON.stringify(str));
|
||||
return;
|
||||
}
|
||||
|
||||
res.push(prefix1 + str);
|
||||
}
|
||||
|
||||
function stringifyAny(
|
||||
obj: unknown,
|
||||
res: string[],
|
||||
prefix1 = "",
|
||||
prefix2 = ""
|
||||
): void {
|
||||
if (obj == null) {
|
||||
res.push(`${prefix1}null`);
|
||||
return;
|
||||
}
|
||||
if (typeof obj === "number" || typeof obj === "boolean") {
|
||||
res.push(`${prefix1}${JSON.stringify(obj)}`);
|
||||
return;
|
||||
}
|
||||
if (obj instanceof Date) {
|
||||
res.push(`${prefix1}${obj.toJSON()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj === "string") {
|
||||
stringifyString(obj, res, prefix1, prefix2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (!obj.length) {
|
||||
res.push(prefix1 + "[]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prefix1 || prefix1.endsWith("- ")) {
|
||||
stringifyAny(obj[0], res, prefix1 + "- ", prefix2 + INDENTATION);
|
||||
prefix1 = prefix2 + "- ";
|
||||
prefix2 = prefix2 + INDENTATION;
|
||||
for (let i = 1; i < obj.length; ++i)
|
||||
stringifyAny(obj[i], res, prefix1, prefix2);
|
||||
} else {
|
||||
res.push(prefix1);
|
||||
prefix1 = prefix2 + "- ";
|
||||
prefix2 = prefix2 + INDENTATION;
|
||||
for (let i = 0; i < obj.length; ++i)
|
||||
stringifyAny(obj[i], res, prefix1, prefix2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.entries(obj).filter((e) => e[1] !== undefined);
|
||||
|
||||
if (!entries.length) {
|
||||
res.push(prefix1 + "{}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prefix1 || prefix1.endsWith("- ")) {
|
||||
stringifyAny(
|
||||
entries[0][1],
|
||||
res,
|
||||
prefix1 + `${stringifyKey(entries[0][0])}: `,
|
||||
prefix2 + INDENTATION
|
||||
);
|
||||
prefix1 = prefix2;
|
||||
prefix2 = prefix2 + INDENTATION;
|
||||
for (let i = 1; i < entries.length; ++i) {
|
||||
stringifyAny(
|
||||
entries[i][1],
|
||||
res,
|
||||
prefix1 + `${stringifyKey(entries[i][0])}: `,
|
||||
prefix2
|
||||
);
|
||||
}
|
||||
} else {
|
||||
res.push(prefix1);
|
||||
prefix1 = prefix2;
|
||||
prefix2 = prefix2 + INDENTATION;
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
stringifyAny(
|
||||
entries[i][1],
|
||||
res,
|
||||
prefix1 + `${stringifyKey(entries[i][0])}: `,
|
||||
prefix2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stringify(obj: unknown): string {
|
||||
if (obj === undefined) return undefined;
|
||||
const lines: string[] = [];
|
||||
stringifyAny(obj, lines);
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
+47
-32
@@ -62,7 +62,6 @@ async function extractAuth(
|
||||
|
||||
function httpGet(
|
||||
options: http.RequestOptions,
|
||||
timeout,
|
||||
_debug: boolean,
|
||||
deviceId: string
|
||||
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> {
|
||||
@@ -77,15 +76,12 @@ function httpGet(
|
||||
}
|
||||
})
|
||||
.on("error", (err) => {
|
||||
req.abort();
|
||||
reject(new Error("Device is offline"));
|
||||
req.destroy();
|
||||
reject(err);
|
||||
if (_debug) debug.outgoingHttpRequestError(req, deviceId, options, err);
|
||||
})
|
||||
.on("socket", (socket) => {
|
||||
socket.setTimeout(timeout);
|
||||
socket.on("timeout", () => {
|
||||
req.abort();
|
||||
});
|
||||
.on("timeout", () => {
|
||||
req.destroy();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -97,14 +93,15 @@ export async function httpConnectionRequest(
|
||||
timeout: number,
|
||||
_debug: boolean,
|
||||
deviceId: string
|
||||
): Promise<void> {
|
||||
): Promise<string> {
|
||||
const options: http.RequestOptions = parse(address);
|
||||
if (options.protocol !== "http:")
|
||||
throw new Error("Invalid connection request URL or protocol");
|
||||
return "Invalid connection request URL or protocol";
|
||||
|
||||
options.agent = new http.Agent({
|
||||
maxSockets: 1,
|
||||
keepAlive: true,
|
||||
timeout: timeout,
|
||||
});
|
||||
|
||||
let authHeader: Record<string, string>;
|
||||
@@ -115,8 +112,7 @@ export async function httpConnectionRequest(
|
||||
let opts = options;
|
||||
if (authHeader) {
|
||||
if (authHeader["method"] === "Basic") {
|
||||
if (!allowBasicAuth)
|
||||
throw new Error("Basic HTTP authentication not allowed");
|
||||
if (!allowBasicAuth) return "Basic HTTP authentication not allowed";
|
||||
|
||||
opts = Object.assign(
|
||||
{
|
||||
@@ -143,41 +139,60 @@ export async function httpConnectionRequest(
|
||||
options
|
||||
);
|
||||
} else {
|
||||
throw new Error("Unrecognized auth method");
|
||||
return "Unrecognized auth method";
|
||||
}
|
||||
}
|
||||
|
||||
let res = await httpGet(opts, timeout, _debug, deviceId);
|
||||
let res: { statusCode: number; headers: http.IncomingHttpHeaders };
|
||||
try {
|
||||
res = await httpGet(opts, _debug, deviceId);
|
||||
} catch (err) {
|
||||
// Workaround for some devices unexpectedly closing the connection
|
||||
if (authHeader) {
|
||||
try {
|
||||
res = await httpGet(opts, _debug, deviceId);
|
||||
} catch (err) {
|
||||
return `Connection request error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for some devices unexpectedly closing the connection
|
||||
if (res.statusCode === 0 && authHeader)
|
||||
res = await httpGet(opts, timeout, _debug, deviceId);
|
||||
if (res.statusCode === 0) throw new Error("Device is offline");
|
||||
if (res.statusCode === 200 || res.statusCode === 204) return;
|
||||
if (err["code"] === "ECONNRESET" || err["code"] === "ECONNREFUSED")
|
||||
return "Device is offline";
|
||||
|
||||
return `Connection request error: ${err.message}`;
|
||||
}
|
||||
|
||||
if (res.statusCode === 200 || res.statusCode === 204) return "";
|
||||
|
||||
// When a Connection Request is received for the Virtual CWMP Device and the Proxied
|
||||
// Device is offline the CPE Proxier MUST respond with an HTTP 503 failure
|
||||
if (res.statusCode === 503) return "Device is offline";
|
||||
|
||||
if (res.statusCode === 401 && res.headers["www-authenticate"]) {
|
||||
authHeader = auth.parseWwwAuthenticateHeader(
|
||||
res.headers["www-authenticate"]
|
||||
);
|
||||
try {
|
||||
authHeader = auth.parseWwwAuthenticateHeader(
|
||||
res.headers["www-authenticate"]
|
||||
);
|
||||
} catch (err) {
|
||||
return "Connection request error: Error parsing www-authenticate header";
|
||||
}
|
||||
[username, password, authExp] = await extractAuth(authExp, false);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unexpected response code from device: ${res.statusCode}`
|
||||
);
|
||||
return `Connection request error: Unexpected status code ${res.statusCode}`;
|
||||
}
|
||||
}
|
||||
throw new Error("Incorrect connection request credentials");
|
||||
|
||||
return "Connection request error: Incorrect connection request credentials";
|
||||
}
|
||||
|
||||
export async function udpConnectionRequest(
|
||||
address: string,
|
||||
host: string,
|
||||
port: number,
|
||||
authExp: Expression,
|
||||
sourcePort = 0,
|
||||
_debug: boolean,
|
||||
deviceId: string
|
||||
): Promise<void> {
|
||||
const [host, portStr] = address.split(":", 2);
|
||||
const port = portStr ? parseInt(portStr) : 80;
|
||||
const now = Date.now();
|
||||
|
||||
const client = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
||||
@@ -204,12 +219,12 @@ export async function udpConnectionRequest(
|
||||
.createHmac("sha1", password)
|
||||
.update(`${ts}${id}${username}${cn}`)
|
||||
.digest("hex");
|
||||
const uri = `http://${address}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`;
|
||||
const msg = `GET ${uri} HTTP/1.1\r\nHost: ${address}\r\n\r\n`;
|
||||
const uri = `http://${host}:${port}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`;
|
||||
const msg = `GET ${uri} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`;
|
||||
const message = Buffer.from(msg);
|
||||
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.send(message, 0, message.length, port, host, (err: Error) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
|
||||
+192
-42
@@ -79,20 +79,22 @@ async function authenticate(
|
||||
sessionContext.cacheSnapshot,
|
||||
"cwmp.auth"
|
||||
);
|
||||
if (!authExpression) return true;
|
||||
if (authExpression == null) return true;
|
||||
|
||||
let authentication;
|
||||
|
||||
if (sessionContext.httpRequest.headers["authorization"]) {
|
||||
authentication = auth.parseAuthorizationHeader(
|
||||
sessionContext.httpRequest.headers["authorization"]
|
||||
);
|
||||
try {
|
||||
authentication = auth.parseAuthorizationHeader(
|
||||
sessionContext.httpRequest.headers["authorization"]
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (authentication && authentication.method === "Digest") {
|
||||
const sessionNonce = sessionsNonces.get(
|
||||
sessionContext.httpRequest.connection
|
||||
);
|
||||
const sessionNonce = sessionsNonces.get(sessionContext.httpRequest.socket);
|
||||
|
||||
if (
|
||||
!sessionNonce ||
|
||||
@@ -185,7 +187,7 @@ async function writeResponse(
|
||||
}
|
||||
|
||||
const httpResponse = sessionContext.httpResponse;
|
||||
const connection = httpResponse.connection;
|
||||
const connection = httpResponse.socket;
|
||||
|
||||
httpResponse.setHeader("Content-Length", Buffer.byteLength(data));
|
||||
httpResponse.writeHead(res.code, res.headers);
|
||||
@@ -198,17 +200,23 @@ async function writeResponse(
|
||||
sessionContext: sessionContext,
|
||||
message: "Connection dropped",
|
||||
});
|
||||
await endSession(sessionContext);
|
||||
} else if (close) {
|
||||
const isNew = await endSession(sessionContext);
|
||||
if (isNew) {
|
||||
logger.accessInfo({
|
||||
sessionContext: sessionContext,
|
||||
message: "New device registered",
|
||||
});
|
||||
}
|
||||
session.clearProvisions(sessionContext);
|
||||
await endSession(sessionContext);
|
||||
} else {
|
||||
sessionContext.lastActivity = Date.now();
|
||||
const now = Date.now();
|
||||
sessionContext.lastActivity = now;
|
||||
currentSessions.set(connection, sessionContext);
|
||||
if (now >= sessionContext.extendLock) {
|
||||
sessionContext.extendLock = now + 10000;
|
||||
await cache.acquireLock(
|
||||
`cwmp_session_${sessionContext.deviceId}`,
|
||||
sessionContext.timeout * 1000 + 15000,
|
||||
0,
|
||||
sessionContext.sessionId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +361,39 @@ async function transferComplete(sessionContext, rpc): Promise<void> {
|
||||
return writeResponse(sessionContext, res);
|
||||
}
|
||||
|
||||
async function duStateChangeComplete(sessionContext, rpc): Promise<void> {
|
||||
const { acsResponse, operation, fault } = await session.duStateChangeComplete(
|
||||
sessionContext,
|
||||
rpc.cpeRequest
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Unrecognized command key",
|
||||
rpc: rpc,
|
||||
});
|
||||
}
|
||||
|
||||
if (fault) {
|
||||
Object.assign(sessionContext.retries, operation.retries);
|
||||
recordFault(
|
||||
sessionContext,
|
||||
fault,
|
||||
operation.provisions,
|
||||
operation.channels
|
||||
);
|
||||
}
|
||||
|
||||
const res = soap.response({
|
||||
id: rpc.id,
|
||||
acsResponse: acsResponse,
|
||||
cwmpVersion: sessionContext.cwmpVersion,
|
||||
});
|
||||
|
||||
return writeResponse(sessionContext, res);
|
||||
}
|
||||
|
||||
// Append provisions and remove duplicates
|
||||
function appendProvisions(original, toAppend): boolean {
|
||||
let modified = false;
|
||||
@@ -722,15 +763,34 @@ async function nextRpc(sessionContext: SessionContext): Promise<void> {
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Task name not recognized");
|
||||
if (!sessionContext.doneTasks) sessionContext.doneTasks = [];
|
||||
sessionContext.doneTasks.push(task._id);
|
||||
sessionContext.tasks = sessionContext.tasks.filter((t) => t !== task);
|
||||
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Invalid task",
|
||||
taskId: task._id,
|
||||
});
|
||||
}
|
||||
|
||||
return nextRpc(sessionContext);
|
||||
}
|
||||
|
||||
async function endSession(sessionContext: SessionContext): Promise<boolean> {
|
||||
async function endSession(sessionContext: SessionContext): Promise<void> {
|
||||
let saveCache = sessionContext.cacheUntil != null;
|
||||
|
||||
if (sessionContext.provisions.length) {
|
||||
const fault = {
|
||||
code: "session_terminated",
|
||||
message: "The TR-069 session was unsuccessfully terminated",
|
||||
timestamp: sessionContext.timestamp,
|
||||
};
|
||||
recordFault(sessionContext, fault);
|
||||
// No need to save retryNow
|
||||
for (const f of Object.values(sessionContext.faults)) delete f.retryNow;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
|
||||
promises.push(
|
||||
@@ -793,7 +853,16 @@ async function endSession(sessionContext: SessionContext): Promise<boolean> {
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return sessionContext.new;
|
||||
await cache.releaseLock(
|
||||
`cwmp_session_${sessionContext.deviceId}`,
|
||||
sessionContext.sessionId
|
||||
);
|
||||
if (sessionContext.new) {
|
||||
logger.accessInfo({
|
||||
sessionContext: sessionContext,
|
||||
message: "New device registered",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAcsRequest(
|
||||
@@ -842,8 +911,11 @@ async function sendAcsRequest(
|
||||
return writeResponse(sessionContext, res);
|
||||
}
|
||||
|
||||
async function getSession(connection, sessionId): Promise<SessionContext> {
|
||||
const sessionContext = currentSessions.get(connection);
|
||||
async function getSession(
|
||||
connection: Socket,
|
||||
sessionId: string
|
||||
): Promise<SessionContext> {
|
||||
let sessionContext = currentSessions.get(connection);
|
||||
if (sessionContext) {
|
||||
currentSessions.delete(connection);
|
||||
return sessionContext;
|
||||
@@ -855,7 +927,9 @@ async function getSession(connection, sessionId): Promise<SessionContext> {
|
||||
|
||||
const sessionContextString = await cache.pop(`session_${sessionId}`);
|
||||
if (!sessionContextString) return null;
|
||||
return session.deserialize(sessionContextString);
|
||||
sessionContext = await session.deserialize(sessionContextString);
|
||||
connection.setTimeout(sessionContext.timeout * 1000);
|
||||
return sessionContext;
|
||||
}
|
||||
|
||||
// Only needed to prevent tree shaking from removing the remoteAddress
|
||||
@@ -872,18 +946,32 @@ export function onConnection(socket: Socket): void {
|
||||
const sessionContext = currentSessions.get(socket);
|
||||
if (!sessionContext) return;
|
||||
currentSessions.delete(socket);
|
||||
if (sessionContext.authState !== 2) {
|
||||
logger.accessError({
|
||||
message: "Authentication failure",
|
||||
sessionContext: sessionContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const lastActivity = sessionContext.lastActivity;
|
||||
const timeoutMsg = logger.flatten({
|
||||
const timeoutMsg = {
|
||||
sessionContext: sessionContext,
|
||||
message: "Session timeout",
|
||||
sessionTimestamp: sessionContext.timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
const timeout =
|
||||
sessionContext.lastActivity + sessionContext.timeout * 1000 - now;
|
||||
if (timeout <= 0) return void logger.accessError(timeoutMsg);
|
||||
|
||||
if (timeout <= 0) {
|
||||
logger.accessError(timeoutMsg);
|
||||
// TODO it's possible that lock would have already been expired
|
||||
await endSession(sessionContext);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const sessionContextString = await cache.get(
|
||||
@@ -891,8 +979,10 @@ export function onConnection(socket: Socket): void {
|
||||
);
|
||||
if (!sessionContextString) return;
|
||||
const _sessionContext = await session.deserialize(sessionContextString);
|
||||
if (_sessionContext.lastActivity === lastActivity)
|
||||
if (_sessionContext.lastActivity === lastActivity) {
|
||||
logger.accessError(timeoutMsg);
|
||||
await endSession(sessionContext);
|
||||
}
|
||||
}, timeout + 1000).unref();
|
||||
|
||||
if (sessionContext.state === 0) return;
|
||||
@@ -988,7 +1078,7 @@ async function reportBadState(sessionContext: SessionContext): Promise<void> {
|
||||
sessionContext: sessionContext,
|
||||
});
|
||||
const httpResponse = sessionContext.httpResponse;
|
||||
currentSessions.delete(httpResponse.connection);
|
||||
currentSessions.delete(httpResponse.socket);
|
||||
const body = "Bad session state";
|
||||
httpResponse.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
httpResponse.writeHead(400, { Connection: "close" });
|
||||
@@ -1015,14 +1105,14 @@ async function responseUnauthorized(
|
||||
resHeaders["WWW-Authenticate"] = `Basic realm="${REALM}"`;
|
||||
} else {
|
||||
const nonce = crypto.randomBytes(16).toString("hex");
|
||||
sessionsNonces.set(sessionContext.httpRequest.connection, nonce);
|
||||
sessionsNonces.set(sessionContext.httpRequest.socket, nonce);
|
||||
let d = `Digest realm="${REALM}"`;
|
||||
d += ',qop="auth,auth-int"';
|
||||
d += `,nonce="${nonce}"`;
|
||||
|
||||
resHeaders["WWW-Authenticate"] = d;
|
||||
}
|
||||
currentSessions.set(sessionContext.httpRequest.connection, sessionContext);
|
||||
currentSessions.set(sessionContext.httpRequest.socket, sessionContext);
|
||||
}
|
||||
|
||||
const httpResponse = sessionContext.httpResponse;
|
||||
@@ -1042,7 +1132,6 @@ async function processRequest(
|
||||
): Promise<void> {
|
||||
for (const w of parseWarnings) {
|
||||
w.sessionContext = sessionContext;
|
||||
w.rpc = rpc;
|
||||
logger.accessWarn(w);
|
||||
}
|
||||
|
||||
@@ -1070,6 +1159,8 @@ async function processRequest(
|
||||
);
|
||||
}
|
||||
|
||||
sessionContext.httpRequest.socket.setTimeout(sessionContext.timeout * 1000);
|
||||
|
||||
if (sessionContext.debug) {
|
||||
debug.incomingHttpRequest(
|
||||
sessionContext.httpRequest,
|
||||
@@ -1088,6 +1179,37 @@ async function processRequest(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionContext.extendLock = sessionContext.timestamp + 10000;
|
||||
await cache.acquireLock(
|
||||
`cwmp_session_${sessionContext.deviceId}`,
|
||||
sessionContext.timeout * 1000 + 15000,
|
||||
0,
|
||||
sessionContext.sessionId
|
||||
);
|
||||
} catch (err) {
|
||||
logger.accessError({
|
||||
message: "CPE already in session",
|
||||
sessionContext: sessionContext,
|
||||
});
|
||||
|
||||
const _body = "CPE already in session";
|
||||
sessionContext.httpResponse.setHeader(
|
||||
"Content-Length",
|
||||
Buffer.byteLength(_body)
|
||||
);
|
||||
sessionContext.httpResponse.writeHead(400, { Connection: "close" });
|
||||
if (sessionContext.debug) {
|
||||
debug.outgoingHttpResponse(
|
||||
sessionContext.httpResponse,
|
||||
sessionContext.deviceId,
|
||||
_body
|
||||
);
|
||||
}
|
||||
sessionContext.httpResponse.end(_body);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionContext.state = 1;
|
||||
sessionContext.authState = 2;
|
||||
|
||||
@@ -1116,6 +1238,7 @@ async function processRequest(
|
||||
sessionContext.authState = 1;
|
||||
return responseUnauthorized(sessionContext, false);
|
||||
} else {
|
||||
await endSession(sessionContext);
|
||||
return responseUnauthorized(sessionContext, true);
|
||||
}
|
||||
}
|
||||
@@ -1149,6 +1272,16 @@ async function processRequest(
|
||||
cwmpVersion: sessionContext.cwmpVersion,
|
||||
});
|
||||
return writeResponse(sessionContext, res);
|
||||
} else if (rpc.cpeRequest.name === "DUStateChangeComplete") {
|
||||
if (sessionContext.state !== 1) return reportBadState(sessionContext);
|
||||
|
||||
logger.accessInfo({
|
||||
sessionContext: sessionContext,
|
||||
message: "CPE request",
|
||||
rpc: rpc,
|
||||
});
|
||||
|
||||
return duStateChangeComplete(sessionContext, rpc);
|
||||
} else {
|
||||
if (sessionContext.state !== 1 || rpc.cpeRequest.name === "Inform")
|
||||
return void reportBadState(sessionContext);
|
||||
@@ -1158,7 +1291,15 @@ async function processRequest(
|
||||
} else if (rpc.cpeResponse) {
|
||||
if (sessionContext.state !== 2) return reportBadState(sessionContext);
|
||||
|
||||
await session.rpcResponse(sessionContext, rpc.id, rpc.cpeResponse);
|
||||
const fault = await session.rpcResponse(
|
||||
sessionContext,
|
||||
rpc.id,
|
||||
rpc.cpeResponse
|
||||
);
|
||||
if (fault) {
|
||||
recordFault(sessionContext, fault);
|
||||
session.clearProvisions(sessionContext);
|
||||
}
|
||||
return nextRpc(sessionContext);
|
||||
} else if (rpc.cpeFault) {
|
||||
if (sessionContext.state !== 2) return reportBadState(sessionContext);
|
||||
@@ -1210,19 +1351,24 @@ export function listener(
|
||||
stats.concurrentRequests -= 1;
|
||||
})
|
||||
.catch((err) => {
|
||||
currentSessions.delete(httpResponse.connection);
|
||||
httpResponse.writeHead(500, { Connection: "close" });
|
||||
httpResponse.end(`${err.name}: ${err.message}`);
|
||||
currentSessions.delete(httpResponse.socket);
|
||||
stats.concurrentRequests -= 1;
|
||||
setTimeout(() => {
|
||||
throw err;
|
||||
});
|
||||
try {
|
||||
httpResponse.socket.unref();
|
||||
httpResponse.writeHead(500, { Connection: "close" });
|
||||
httpResponse.end(`${err.name}: ${err.message}`);
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function decodeString(buffer: Buffer, charset: string): string {
|
||||
try {
|
||||
return buffer.toString(charset);
|
||||
return buffer.toString(charset as BufferEncoding);
|
||||
} catch (err) {
|
||||
if (encodingExists(charset)) return decode(buffer, charset);
|
||||
}
|
||||
@@ -1307,9 +1453,9 @@ async function listenerAsync(
|
||||
// Request aborted
|
||||
if (!body) return;
|
||||
|
||||
const newConnection = !currentSessions.has(httpRequest.connection);
|
||||
const newConnection = !currentSessions.has(httpRequest.socket);
|
||||
|
||||
const sessionContext = await getSession(httpRequest.connection, sessionId);
|
||||
const sessionContext = await getSession(httpRequest.socket, sessionId);
|
||||
|
||||
if (sessionContext) {
|
||||
sessionContext.httpRequest = httpRequest;
|
||||
@@ -1334,6 +1480,7 @@ async function listenerAsync(
|
||||
);
|
||||
}
|
||||
httpResponse.end(_body);
|
||||
await endSession(sessionContext);
|
||||
return;
|
||||
}
|
||||
if (newConnection && sessionContext.authState !== 1)
|
||||
@@ -1347,7 +1494,7 @@ async function listenerAsync(
|
||||
return;
|
||||
}
|
||||
|
||||
let charset;
|
||||
let charset: string;
|
||||
if (httpRequest.headers["content-type"]) {
|
||||
const m = httpRequest.headers["content-type"].match(
|
||||
/charset=['"]?([^'"\s]+)/i
|
||||
@@ -1384,6 +1531,7 @@ async function listenerAsync(
|
||||
);
|
||||
debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, msg);
|
||||
}
|
||||
await endSession(sessionContext);
|
||||
} else {
|
||||
const cacheSnapshot = await localCache.getCurrentSnapshot();
|
||||
const d = !!localCache.getConfig(
|
||||
@@ -1409,7 +1557,7 @@ async function listenerAsync(
|
||||
}
|
||||
|
||||
const parseWarnings = [];
|
||||
let rpc;
|
||||
let rpc: SoapMessage;
|
||||
try {
|
||||
rpc = soap.request(
|
||||
bodyStr,
|
||||
@@ -1440,6 +1588,7 @@ async function listenerAsync(
|
||||
error.message
|
||||
);
|
||||
}
|
||||
await endSession(sessionContext);
|
||||
} else {
|
||||
const cacheSnapshot = await localCache.getCurrentSnapshot();
|
||||
const d = !!localCache.getConfig(
|
||||
@@ -1492,8 +1641,10 @@ async function listenerAsync(
|
||||
return e;
|
||||
}
|
||||
);
|
||||
if (d)
|
||||
debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, _body);
|
||||
if (d) {
|
||||
debug.incomingHttpRequest(httpRequest, null, bodyStr);
|
||||
debug.outgoingHttpResponse(httpResponse, null, _body);
|
||||
}
|
||||
httpResponse.end(_body);
|
||||
return;
|
||||
}
|
||||
@@ -1514,7 +1665,6 @@ async function listenerAsync(
|
||||
_sessionContext.httpRequest = httpRequest;
|
||||
_sessionContext.httpResponse = httpResponse;
|
||||
_sessionContext.sessionId = crypto.randomBytes(8).toString("hex");
|
||||
httpRequest.connection.setTimeout(_sessionContext.timeout * 1000);
|
||||
|
||||
const {
|
||||
tasks: dueTasks,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MongoClient, ObjectID, Collection } from "mongodb";
|
||||
import { MongoClient, ObjectID, Collection, Db } from "mongodb";
|
||||
import { get } from "./config";
|
||||
import { escapeRegExp } from "./common";
|
||||
import { decodeTag, encodeTag, escapeRegExp } from "./common";
|
||||
import { parse } from "./common/expression-parser";
|
||||
import {
|
||||
DeviceData,
|
||||
@@ -45,7 +45,8 @@ export let tasksCollection: Collection,
|
||||
configCollection: Collection;
|
||||
|
||||
let clientPromise: Promise<MongoClient>;
|
||||
export let client: MongoClient;
|
||||
|
||||
const INVALID_PATH_SUFFIX = "__invalid";
|
||||
|
||||
function compareAccessLists(list1: string[], list2: string[]): boolean {
|
||||
if (list1.length !== list2.length) return false;
|
||||
@@ -53,15 +54,24 @@ function compareAccessLists(list1: string[], list2: string[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const onConnectCallbacks: ((db: Db) => Promise<void>)[] = [];
|
||||
|
||||
export function onConnect(callback: (db: Db) => Promise<void>): void {
|
||||
onConnectCallbacks.push(callback);
|
||||
}
|
||||
|
||||
export async function connect(): Promise<void> {
|
||||
clientPromise = MongoClient.connect("" + get("MONGODB_CONNECTION_URL"), {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
});
|
||||
|
||||
client = await clientPromise;
|
||||
const client = await clientPromise;
|
||||
const db = client.db();
|
||||
await Promise.all(onConnectCallbacks.map((c) => c(db)));
|
||||
}
|
||||
|
||||
onConnect(async (db) => {
|
||||
tasksCollection = db.collection("tasks");
|
||||
await tasksCollection.createIndex({ device: 1, timestamp: 1 });
|
||||
|
||||
@@ -76,7 +86,7 @@ export async function connect(): Promise<void> {
|
||||
permissionsCollection = db.collection("permissions");
|
||||
usersCollection = db.collection("users");
|
||||
configCollection = db.collection("config");
|
||||
}
|
||||
});
|
||||
|
||||
export async function disconnect(): Promise<void> {
|
||||
if (clientPromise) await (await clientPromise).close();
|
||||
@@ -123,7 +133,12 @@ export async function fetchDevice(
|
||||
const device = await devicesCollection.findOne({ _id: id });
|
||||
if (!device) return null;
|
||||
|
||||
function storeParams(obj, path: string, pathLength: number, ts): void {
|
||||
function storeParams(
|
||||
obj,
|
||||
path: string,
|
||||
pathLength: number,
|
||||
ts: number
|
||||
): void {
|
||||
if (obj["_timestamp"]) obj["_timestamp"] = +obj["_timestamp"];
|
||||
if (obj["_attributesTimestamp"])
|
||||
obj["_attributesTimestamp"] = +obj["_attributesTimestamp"];
|
||||
@@ -154,19 +169,34 @@ export async function fetchDevice(
|
||||
if (obj["_accessList"] != null)
|
||||
attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]];
|
||||
|
||||
res.push([Path.parse(path.slice(0, -1)), t, attrs]);
|
||||
try {
|
||||
res.push([Path.parse(path), t, attrs]);
|
||||
} catch (err) {
|
||||
// The path parser is now more strict so we might be in a situation where
|
||||
// the database contains invalid paths from before this change So here we
|
||||
// encode the invalid characters.
|
||||
const splits = path.split(".");
|
||||
splits[splits.length - 1] =
|
||||
encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX;
|
||||
path = splits.join(".");
|
||||
res.push([Path.parse(path), t, attrs]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (!k.startsWith("_")) {
|
||||
obj["_object"] = true;
|
||||
storeParams(v, path + k + ".", pathLength + 1, obj["_timestamp"]);
|
||||
storeParams(v, `${path}.${k}`, pathLength + 1, obj["_timestamp"]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((obj["_object"] || !pathLength) && obj["_timestamp"])
|
||||
res.push([Path.parse(path + "*"), obj["_timestamp"]]);
|
||||
if (obj["_object"] && obj["_timestamp"])
|
||||
res.push([Path.parse(path + ".*"), obj["_timestamp"]]);
|
||||
}
|
||||
|
||||
const ts: number = device["_timestamp"] || 0;
|
||||
if (ts) res.push([Path.parse("*"), ts]);
|
||||
|
||||
for (const [k, v] of Object.entries(device)) {
|
||||
switch (k) {
|
||||
case "_lastInform":
|
||||
@@ -234,10 +264,9 @@ export async function fetchDevice(
|
||||
]);
|
||||
}
|
||||
|
||||
for (let t of v as string[]) {
|
||||
t = t.replace(/[^a-zA-Z0-9-]+/g, "_");
|
||||
for (const t of v as string[]) {
|
||||
res.push([
|
||||
Path.parse("Tags." + t),
|
||||
Path.parse("Tags." + encodeTag(t)),
|
||||
timestamp,
|
||||
{
|
||||
object: [timestamp, 0],
|
||||
@@ -295,10 +324,11 @@ export async function fetchDevice(
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!k.startsWith("_")) storeParams(v, k, 1, ts);
|
||||
}
|
||||
}
|
||||
|
||||
storeParams(device, "", 0, 0);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -433,7 +463,16 @@ export async function saveDevice(
|
||||
break;
|
||||
default:
|
||||
if (!diff[2]) {
|
||||
update["$unset"][path.toString()] = 1;
|
||||
let pathStr = path.toString();
|
||||
// Paths with that suffix are encoded and need to be decoded
|
||||
if (pathStr.endsWith(INVALID_PATH_SUFFIX)) {
|
||||
const splits = pathStr.split(".");
|
||||
splits[splits.length - 1] = decodeTag(
|
||||
splits[splits.length - 1].slice(0, 0 - INVALID_PATH_SUFFIX.length)
|
||||
);
|
||||
pathStr = splits.join(".");
|
||||
}
|
||||
update["$unset"][pathStr] = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -691,6 +730,11 @@ export async function getOperations(
|
||||
for (const r of res) {
|
||||
const commandKey = r._id.slice(deviceId.length + 1);
|
||||
delete r._id;
|
||||
// Workaround for a bug in v1.2.1 where operation object is saved without deserialization
|
||||
if (typeof r.provisions !== "string") {
|
||||
operations[commandKey] = r;
|
||||
continue;
|
||||
}
|
||||
r.timestamp = +r.timestamp;
|
||||
if (r.args) r.args = JSON.parse(r.args);
|
||||
r.provisions = JSON.parse(r.provisions);
|
||||
@@ -712,7 +756,7 @@ export async function saveOperation(
|
||||
o["provisions"] = JSON.stringify(operation.provisions);
|
||||
o["retries"] = JSON.stringify(operation.retries);
|
||||
o["args"] = JSON.stringify(operation.args);
|
||||
await operationsCollection.replaceOne({ _id: id }, operation, {
|
||||
await operationsCollection.replaceOne({ _id: id }, o, {
|
||||
upsert: true,
|
||||
});
|
||||
}
|
||||
|
||||
+12
-12
@@ -25,7 +25,7 @@ import {
|
||||
} from "http";
|
||||
import { Socket } from "net";
|
||||
import { appendFileSync } from "fs";
|
||||
import * as yaml from "yaml";
|
||||
import { stringify } from "./common/yaml";
|
||||
import * as config from "./config";
|
||||
|
||||
const DEBUG_FILE = "" + config.get("DEBUG_FILE");
|
||||
@@ -49,7 +49,7 @@ export function incomingHttpRequest(
|
||||
): void {
|
||||
if (!DEBUG_FILE) return;
|
||||
const now = new Date();
|
||||
const con = httpRequest.connection;
|
||||
const con = httpRequest.socket;
|
||||
const msg = {
|
||||
event: "incoming HTTP request",
|
||||
timestamp: now,
|
||||
@@ -64,7 +64,7 @@ export function incomingHttpRequest(
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
@@ -77,7 +77,7 @@ export function outgoingHttpResponse(
|
||||
): void {
|
||||
if (!DEBUG_FILE) return;
|
||||
const now = new Date();
|
||||
const con = httpResponse.connection;
|
||||
const con = httpResponse.socket;
|
||||
const msg = {
|
||||
event: "outgoing HTTP response",
|
||||
timestamp: now,
|
||||
@@ -90,7 +90,7 @@ export function outgoingHttpResponse(
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
@@ -104,7 +104,7 @@ export function outgoingHttpRequest(
|
||||
): void {
|
||||
if (!DEBUG_FILE) return;
|
||||
const now = new Date();
|
||||
const con = httpRequest.connection;
|
||||
const con = httpRequest.socket;
|
||||
const msg = {
|
||||
event: "outgoing HTTP request",
|
||||
timestamp: now,
|
||||
@@ -119,7 +119,7 @@ export function outgoingHttpRequest(
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
@@ -147,7 +147,7 @@ export function outgoingHttpRequestError(
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
@@ -160,20 +160,20 @@ export function incomingHttpResponse(
|
||||
): void {
|
||||
if (!DEBUG_FILE) return;
|
||||
const now = new Date();
|
||||
const con = httpResponse.connection;
|
||||
const con = httpResponse.socket;
|
||||
const msg = {
|
||||
event: "incoming HTTP response",
|
||||
timestamp: now,
|
||||
remoteAddress: con.remoteAddress,
|
||||
deviceId: deviceId,
|
||||
connection: getConnectionTimestamp(httpResponse.connection),
|
||||
connection: getConnectionTimestamp(httpResponse.socket),
|
||||
statusCode: httpResponse.statusCode,
|
||||
headers: httpResponse.headers,
|
||||
body: body,
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
@@ -197,7 +197,7 @@ export function outgoingUdpMessage(
|
||||
};
|
||||
|
||||
if (DEBUG_FORMAT === "yaml")
|
||||
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
|
||||
appendFileSync(DEBUG_FILE, "---\n" + stringify(msg));
|
||||
else if (DEBUG_FORMAT === "json")
|
||||
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
|
||||
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
|
||||
|
||||
+23
-10
@@ -22,20 +22,14 @@ import * as crypto from "crypto";
|
||||
import * as config from "./config";
|
||||
import { Fault } from "./types";
|
||||
import { ROOT_DIR } from "./config";
|
||||
import * as logger from "./logger";
|
||||
import readline from "readline";
|
||||
|
||||
const TIMEOUT = +config.get("EXT_TIMEOUT");
|
||||
|
||||
const processes: { [script: string]: ChildProcess } = {};
|
||||
const jobs = new Map();
|
||||
|
||||
function messageHandler(message): void {
|
||||
const func = jobs.get(message[0]);
|
||||
if (func) {
|
||||
jobs.delete(message[0]);
|
||||
func({ fault: message[1], value: message[2] });
|
||||
}
|
||||
}
|
||||
|
||||
export function run(args: string[]): Promise<{ fault: Fault; value: any }> {
|
||||
return new Promise((resolve) => {
|
||||
const scriptName = args[0];
|
||||
@@ -45,7 +39,7 @@ export function run(args: string[]): Promise<{ fault: Fault; value: any }> {
|
||||
|
||||
if (!processes[scriptName]) {
|
||||
const p = spawn(ROOT_DIR + "/bin/genieacs-ext", [scriptName], {
|
||||
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
processes[scriptName] = p;
|
||||
|
||||
@@ -68,7 +62,26 @@ export function run(args: string[]): Promise<{ fault: Fault; value: any }> {
|
||||
if (processes[scriptName] === p) delete processes[scriptName];
|
||||
});
|
||||
|
||||
p.on("message", messageHandler);
|
||||
p.on("message", (message) => {
|
||||
const func = jobs.get(message[0]);
|
||||
if (func) {
|
||||
jobs.delete(message[0]);
|
||||
// Wait for any disconnect even to fire
|
||||
setTimeout(() => {
|
||||
func({ fault: message[1], value: message[2] });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const rlstdout = readline.createInterface(p.stdout);
|
||||
rlstdout.on("line", (line) => {
|
||||
logger.info({ message: `Ext ${scriptName}(${p.pid}): ${line}` });
|
||||
});
|
||||
|
||||
const rlstderr = readline.createInterface(p.stderr);
|
||||
rlstderr.on("line", (line) => {
|
||||
logger.warn({ message: `Ext ${scriptName}(${p.pid}): ${line}` });
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -20,49 +20,75 @@
|
||||
import * as url from "url";
|
||||
import * as querystring from "querystring";
|
||||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import { GridFSBucket } from "mongodb";
|
||||
import * as db from "./db";
|
||||
import { Collection, GridFSBucket } from "mongodb";
|
||||
import { onConnect } from "./db";
|
||||
import * as logger from "./logger";
|
||||
import { getRequestOrigin } from "./forwarded";
|
||||
import memoize from "./common/memoize";
|
||||
|
||||
export function listener(
|
||||
let filesCollection: Collection;
|
||||
let filesBucket: GridFSBucket;
|
||||
|
||||
onConnect(async (db) => {
|
||||
filesCollection = db.collection("fs.files");
|
||||
filesBucket = new GridFSBucket(db);
|
||||
});
|
||||
|
||||
const getFile = memoize(
|
||||
(md5: string, size: number, filename: string): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const buffer = Buffer.allocUnsafe(size);
|
||||
let i = 0;
|
||||
const downloadStream = filesBucket.openDownloadStreamByName(filename);
|
||||
downloadStream.on("error", reject);
|
||||
downloadStream.on("data", (data: Buffer) => {
|
||||
data.copy(buffer, i);
|
||||
i += data.length;
|
||||
});
|
||||
downloadStream.on("end", () => {
|
||||
if (i !== size) reject(new Error("File size mismatch"));
|
||||
else resolve(buffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export async function listener(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse
|
||||
): void {
|
||||
const urlParts = url.parse(request.url, true);
|
||||
if (request.method === "GET") {
|
||||
const filename = querystring.unescape(urlParts.pathname.substring(1));
|
||||
|
||||
const log = {
|
||||
message: "Fetch file",
|
||||
filename: filename,
|
||||
remoteAddress: getRequestOrigin(request).remoteAddress,
|
||||
};
|
||||
|
||||
db.filesCollection.findOne({ _id: filename }, (err, file) => {
|
||||
if (err) throw err;
|
||||
|
||||
if (!file) {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
log.message += " not found";
|
||||
logger.accessError(log);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"Content-Type": file.contentType || "application/octet-stream",
|
||||
"Content-Length": file.length,
|
||||
});
|
||||
|
||||
const bucket = new GridFSBucket(db.client.db());
|
||||
const downloadStream = bucket.openDownloadStreamByName(filename);
|
||||
downloadStream.pipe(response);
|
||||
|
||||
logger.accessInfo(log);
|
||||
});
|
||||
} else {
|
||||
): Promise<void> {
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { Allow: "GET" });
|
||||
response.end("405 Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParts = url.parse(request.url, true);
|
||||
const filename = querystring.unescape(urlParts.pathname.substring(1));
|
||||
|
||||
const log = {
|
||||
message: "Fetch file",
|
||||
filename: filename,
|
||||
remoteAddress: getRequestOrigin(request).remoteAddress,
|
||||
};
|
||||
|
||||
const file = await filesCollection.findOne({ _id: filename });
|
||||
|
||||
if (!file) {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
log.message += " not found";
|
||||
logger.accessError(log);
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await getFile(file.md5, file.length, filename);
|
||||
|
||||
response.writeHead(200, {
|
||||
"Content-Type": file.contentType || "application/octet-stream",
|
||||
"Content-Length": file.length,
|
||||
});
|
||||
|
||||
response.end(buffer);
|
||||
logger.accessInfo(log);
|
||||
}
|
||||
+6
-6
@@ -50,8 +50,8 @@ interface Snapshot {
|
||||
ui: UiConfig;
|
||||
}
|
||||
|
||||
const REFRESH = 3000;
|
||||
const EVICT_TIMEOUT = 60000;
|
||||
const REFRESH = 5000;
|
||||
const EVICT_TIMEOUT = 120000;
|
||||
|
||||
const snapshots = new Map<string, Snapshot>();
|
||||
let currentSnapshot: string = null;
|
||||
@@ -188,7 +188,7 @@ async function fetchPresets(): Promise<Preset[]> {
|
||||
for (const c of preset["configurations"]) {
|
||||
switch (c.type) {
|
||||
case "age":
|
||||
_provisions.push(["refresh", c.name, c.age]);
|
||||
_provisions.push(["refresh", c.name, +c.age]);
|
||||
break;
|
||||
|
||||
case "value":
|
||||
@@ -398,7 +398,7 @@ async function refresh(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const unlockOrExtend = await cache.lock("presets_hash_lock", 3);
|
||||
const lockToken = await cache.acquireLock("presets_hash_lock", 5000, 5000);
|
||||
|
||||
const res = await Promise.all([
|
||||
fetchPresets(),
|
||||
@@ -432,7 +432,7 @@ async function refresh(): Promise<void> {
|
||||
currentSnapshot = computeHash(snapshot);
|
||||
snapshots.set(currentSnapshot, snapshot);
|
||||
await cache.set("presets_hash", currentSnapshot, 300);
|
||||
await unlockOrExtend(0);
|
||||
await cache.releaseLock("presets_hash_lock", lockToken);
|
||||
|
||||
nextRefresh = now + (REFRESH - (now % REFRESH));
|
||||
}
|
||||
@@ -475,7 +475,7 @@ export function getConfig(
|
||||
key: string,
|
||||
context: Record<string, unknown>,
|
||||
now: number,
|
||||
cb?: (Expression) => Expression
|
||||
cb?: (e: Expression) => Expression
|
||||
): string | number | boolean | null {
|
||||
const snapshot = snapshots.get(snapshotKey);
|
||||
if (!snapshot) throw new Error("Cache snapshot does not exist");
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ObjectID } from "mongodb";
|
||||
import { map, parse, stringify, parseList } from "./common/expression-parser";
|
||||
import { likePatternToRegExp } from "./common/expression";
|
||||
import { Expression, Fault, Task } from "./types";
|
||||
import { decodeTag, encodeTag } from "./common";
|
||||
|
||||
const isArray = Array.isArray;
|
||||
|
||||
@@ -43,7 +44,7 @@ export function processDeviceFilter(filter: Expression): Expression {
|
||||
exp[1][0] === "PARAM" &&
|
||||
exp[1][1].startsWith("Tags.")
|
||||
) {
|
||||
const t = exp[1][1].slice(5);
|
||||
const t = decodeTag(exp[1][1].slice(5));
|
||||
if (exp[0] === "IS NULL") return ["_tags", t, false];
|
||||
else if (exp[0] === "IS NOT NULL") return ["_tags", t, true];
|
||||
else if (exp[0] === "=" && exp[2] === true) return ["_tags", t, true];
|
||||
@@ -406,7 +407,7 @@ export function flattenDevice(device: Record<string, unknown>): FlatDevice {
|
||||
};
|
||||
|
||||
for (const t of tree as string[]) {
|
||||
output[`Tags.${t}`] = {
|
||||
output[`Tags.${encodeTag(t)}`] = {
|
||||
value: [true, "xsd:boolean"],
|
||||
valueTimestamp: timestamp,
|
||||
writable: true,
|
||||
@@ -510,11 +511,11 @@ export function convertOldPrecondition(
|
||||
if (op === "$ne") {
|
||||
if (typeof v["$ne"] !== "string")
|
||||
throw new Error("Only string values are allowed for _tags");
|
||||
conjs.push(["IS NULL", ["PARAM", `Tags.${val}`]]);
|
||||
conjs.push(["IS NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
|
||||
} else if (op === "$eq") {
|
||||
if (typeof v["$eq"] !== "string")
|
||||
throw new Error("Only string values are allowed for _tags");
|
||||
conjs.push(["IS NOT NULL", ["PARAM", `Tags.${val}`]]);
|
||||
conjs.push(["IS NOT NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
|
||||
} else {
|
||||
throw new Error(`Invalid tag query`);
|
||||
}
|
||||
@@ -522,7 +523,10 @@ export function convertOldPrecondition(
|
||||
if (conjs.length === 1) expressions.push(conjs[0]);
|
||||
else if (conjs.length > 1) expressions.push(["AND", ...conjs]);
|
||||
} else {
|
||||
expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]);
|
||||
expressions.push([
|
||||
"IS NOT NULL",
|
||||
["PARAM", `Tags.${encodeTag(v as string)}`],
|
||||
]);
|
||||
}
|
||||
} else if (k.startsWith("Tags.")) {
|
||||
let exists: boolean;
|
||||
|
||||
+160
-99
@@ -18,17 +18,18 @@
|
||||
*/
|
||||
|
||||
import * as url from "url";
|
||||
import * as mongodb from "mongodb";
|
||||
import { Collection, GridFSBucket, ObjectId } from "mongodb";
|
||||
import * as querystring from "querystring";
|
||||
import * as vm from "vm";
|
||||
import * as config from "./config";
|
||||
import * as db from "./db";
|
||||
import { onConnect } from "./db";
|
||||
import * as query from "./query";
|
||||
import * as apiFunctions from "./api-functions";
|
||||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import * as cache from "./cache";
|
||||
import { version as VERSION } from "../package.json";
|
||||
import { ping } from "./ping";
|
||||
import * as logger from "./logger";
|
||||
|
||||
const DEVICE_TASKS_REGEX = /^\/devices\/([a-zA-Z0-9\-_%]+)\/tasks\/?$/;
|
||||
const TASKS_REGEX = /^\/tasks\/([a-zA-Z0-9\-_%]+)(\/[a-zA-Z_]*)?$/;
|
||||
@@ -43,6 +44,26 @@ const PROVISIONS_REGEX = /^\/provisions\/([a-zA-Z0-9\-_%]+)\/?$/;
|
||||
const VIRTUAL_PARAMETERS_REGEX = /^\/virtual_parameters\/([a-zA-Z0-9\-_%]+)\/?$/;
|
||||
const FAULTS_REGEX = /^\/faults\/([a-zA-Z0-9\-_%:]+)\/?$/;
|
||||
|
||||
const collections = {
|
||||
tasks: null as Collection,
|
||||
devices: null as Collection,
|
||||
presets: null as Collection,
|
||||
objects: null as Collection,
|
||||
files: null as Collection,
|
||||
provisions: null as Collection,
|
||||
virtualParameters: null as Collection,
|
||||
faults: null as Collection,
|
||||
};
|
||||
let filesBucket: GridFSBucket;
|
||||
|
||||
onConnect(async (db) => {
|
||||
for (const k of Object.keys(collections)) {
|
||||
if (k === "files") collections[k] = db.collection("fs.files");
|
||||
else collections[k] = db.collection(k);
|
||||
}
|
||||
filesBucket = new GridFSBucket(db);
|
||||
});
|
||||
|
||||
function throwError(err: Error, httpResponse?: ServerResponse): never {
|
||||
if (httpResponse) {
|
||||
httpResponse.writeHead(500, { Connection: "close" });
|
||||
@@ -78,6 +99,14 @@ export function listener(
|
||||
request.addListener("end", () => {
|
||||
const body = getBody();
|
||||
const urlParts = url.parse(request.url, true);
|
||||
|
||||
logger.accessInfo(
|
||||
Object.assign({}, urlParts.query, {
|
||||
remoteAddress: request.connection.remoteAddress,
|
||||
message: `${request.method} ${urlParts.pathname}`,
|
||||
})
|
||||
);
|
||||
|
||||
if (PRESETS_REGEX.test(urlParts.pathname)) {
|
||||
const presetName = querystring.unescape(
|
||||
PRESETS_REGEX.exec(urlParts.pathname)[1]
|
||||
@@ -85,7 +114,7 @@ export function listener(
|
||||
if (request.method === "PUT") {
|
||||
const preset = JSON.parse(body.toString());
|
||||
preset._id = presetName;
|
||||
db.presetsCollection.replaceOne(
|
||||
collections.presets.replaceOne(
|
||||
{ _id: presetName },
|
||||
preset,
|
||||
{ upsert: true },
|
||||
@@ -106,7 +135,7 @@ export function listener(
|
||||
}
|
||||
);
|
||||
} else if (request.method === "DELETE") {
|
||||
db.presetsCollection.deleteOne({ _id: presetName }, (err) => {
|
||||
collections.presets.deleteOne({ _id: presetName }, (err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
cache
|
||||
@@ -132,7 +161,7 @@ export function listener(
|
||||
if (request.method === "PUT") {
|
||||
const object = JSON.parse(body.toString());
|
||||
object._id = objectName;
|
||||
db.objectsCollection.replaceOne(
|
||||
collections.objects.replaceOne(
|
||||
{ _id: objectName },
|
||||
object,
|
||||
{ upsert: true },
|
||||
@@ -153,7 +182,7 @@ export function listener(
|
||||
}
|
||||
);
|
||||
} else if (request.method === "DELETE") {
|
||||
db.objectsCollection.deleteOne({ _id: objectName }, (err) => {
|
||||
collections.objects.deleteOne({ _id: objectName }, (err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
cache
|
||||
@@ -190,7 +219,7 @@ export function listener(
|
||||
return;
|
||||
}
|
||||
|
||||
db.provisionsCollection.replaceOne(
|
||||
collections.provisions.replaceOne(
|
||||
{ _id: provisionName },
|
||||
object,
|
||||
{ upsert: true },
|
||||
@@ -211,7 +240,7 @@ export function listener(
|
||||
}
|
||||
);
|
||||
} else if (request.method === "DELETE") {
|
||||
db.provisionsCollection.deleteOne({ _id: provisionName }, (err) => {
|
||||
collections.provisions.deleteOne({ _id: provisionName }, (err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
cache
|
||||
@@ -248,7 +277,7 @@ export function listener(
|
||||
return;
|
||||
}
|
||||
|
||||
db.virtualParametersCollection.replaceOne(
|
||||
collections.virtualParameters.replaceOne(
|
||||
{ _id: virtualParameterName },
|
||||
object,
|
||||
{ upsert: true },
|
||||
@@ -269,7 +298,7 @@ export function listener(
|
||||
}
|
||||
);
|
||||
} else if (request.method === "DELETE") {
|
||||
db.virtualParametersCollection.deleteOne(
|
||||
collections.virtualParameters.deleteOne(
|
||||
{ _id: virtualParameterName },
|
||||
(err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
@@ -296,7 +325,7 @@ export function listener(
|
||||
const deviceId = querystring.unescape(r[1]);
|
||||
const tag = querystring.unescape(r[2]);
|
||||
if (request.method === "POST") {
|
||||
db.devicesCollection.updateOne(
|
||||
collections.devices.updateOne(
|
||||
{ _id: deviceId },
|
||||
{ $addToSet: { _tags: tag } },
|
||||
(err) => {
|
||||
@@ -306,7 +335,7 @@ export function listener(
|
||||
}
|
||||
);
|
||||
} else if (request.method === "DELETE") {
|
||||
db.devicesCollection.updateOne(
|
||||
collections.devices.updateOne(
|
||||
{ _id: deviceId },
|
||||
{ $pull: { _tags: tag } },
|
||||
(err) => {
|
||||
@@ -327,12 +356,12 @@ export function listener(
|
||||
);
|
||||
const deviceId = faultId.split(":", 1)[0];
|
||||
const channel = faultId.slice(deviceId.length + 1);
|
||||
db.faultsCollection.deleteOne({ _id: faultId }, (err) => {
|
||||
collections.faults.deleteOne({ _id: faultId }, (err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
if (channel.startsWith("task_")) {
|
||||
const objId = new mongodb.ObjectID(channel.slice(5));
|
||||
return void db.tasksCollection.deleteOne({ _id: objId }, (err) => {
|
||||
const objId = new ObjectId(channel.slice(5));
|
||||
return void collections.tasks.deleteOne({ _id: objId }, (err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
cache
|
||||
@@ -375,78 +404,112 @@ export function listener(
|
||||
task.device = deviceId;
|
||||
apiFunctions
|
||||
.insertTasks(task)
|
||||
.then(() => {
|
||||
cache
|
||||
.del(`${deviceId}_tasks_faults_operations`)
|
||||
.then(() => {
|
||||
if (urlParts.query.connection_request != null) {
|
||||
apiFunctions
|
||||
.connectionRequest(deviceId)
|
||||
.then(() => {
|
||||
const taskTimeout =
|
||||
(urlParts.query.timeout &&
|
||||
parseInt(urlParts.query.timeout as string)) ||
|
||||
(config.get(
|
||||
"DEVICE_ONLINE_THRESHOLD",
|
||||
deviceId
|
||||
) as number);
|
||||
.then(async () => {
|
||||
const lastInform = Date.now();
|
||||
|
||||
apiFunctions
|
||||
.watchTask(deviceId, task._id, taskTimeout)
|
||||
.then((status) => {
|
||||
if (status === "timeout") {
|
||||
response.writeHead(
|
||||
202,
|
||||
"Task queued but not processed",
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
);
|
||||
response.end(JSON.stringify(task));
|
||||
} else if (status === "fault") {
|
||||
db.tasksCollection.findOne(
|
||||
{ _id: task._id },
|
||||
(err, task2) => {
|
||||
if (err)
|
||||
return void throwError(err, response);
|
||||
const socketTimeout: number = request.socket["timeout"];
|
||||
|
||||
response.writeHead(202, "Task faulted", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task2));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
throwError(err, response);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
response.writeHead(202, err.message, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
});
|
||||
} else {
|
||||
response.writeHead(202, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
throwError(err, response);
|
||||
});
|
||||
// Disable socket timeout while waiting for session
|
||||
if (socketTimeout) request.socket.setTimeout(0);
|
||||
|
||||
const notInSession = await apiFunctions.awaitSessionEnd(
|
||||
deviceId,
|
||||
30000
|
||||
);
|
||||
await cache.del(`${deviceId}_tasks_faults_operations`);
|
||||
if (urlParts.query.connection_request == null) {
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
response.writeHead(202, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notInSession) {
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
response.writeHead(202, "Task queued but not processed", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await apiFunctions.connectionRequest(deviceId);
|
||||
|
||||
if (status) {
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
response.writeHead(202, status, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
return;
|
||||
}
|
||||
|
||||
const onlineThreshold =
|
||||
(urlParts.query.timeout &&
|
||||
parseInt(urlParts.query.timeout as string)) ||
|
||||
(config.get("DEVICE_ONLINE_THRESHOLD", deviceId) as number);
|
||||
|
||||
const sessionStarted = await apiFunctions.awaitSessionStart(
|
||||
deviceId,
|
||||
lastInform,
|
||||
onlineThreshold
|
||||
);
|
||||
if (!sessionStarted) {
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
response.writeHead(202, "Task queued but not processed", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionEnded = await apiFunctions.awaitSessionEnd(
|
||||
deviceId,
|
||||
120000
|
||||
);
|
||||
if (!sessionEnded) {
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
response.writeHead(202, "Task queued but not processed", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
return;
|
||||
}
|
||||
|
||||
const prom1 = collections.tasks.findOne(
|
||||
{ _id: task._id },
|
||||
{ projection: { _id: 1 } }
|
||||
);
|
||||
const prom2 = collections.faults.findOne(
|
||||
{ _id: `${deviceId}:task_${task._id}` },
|
||||
{
|
||||
projection: { _id: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
const [t, f] = await Promise.all([prom1, prom2]);
|
||||
|
||||
// Restore socket timeout
|
||||
if (socketTimeout) request.socket.setTimeout(socketTimeout);
|
||||
|
||||
if (f) {
|
||||
response.writeHead(202, "Task faulted", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(t || task));
|
||||
} else if (t) {
|
||||
response.writeHead(202, "Task queued but not processed", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(t));
|
||||
} else {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response.end(JSON.stringify(task));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setTimeout(() => {
|
||||
@@ -479,8 +542,8 @@ export function listener(
|
||||
const action = r[2];
|
||||
if (!action || action === "/") {
|
||||
if (request.method === "DELETE") {
|
||||
db.tasksCollection.findOne(
|
||||
{ _id: new mongodb.ObjectID(taskId) },
|
||||
collections.tasks.findOne(
|
||||
{ _id: new ObjectId(taskId) },
|
||||
{ projection: { device: 1 } },
|
||||
(err, task) => {
|
||||
if (err) return void throwError(err, response);
|
||||
@@ -492,12 +555,12 @@ export function listener(
|
||||
}
|
||||
|
||||
const deviceId = task.device;
|
||||
db.tasksCollection.deleteOne(
|
||||
{ _id: new mongodb.ObjectID(taskId) },
|
||||
collections.tasks.deleteOne(
|
||||
{ _id: new ObjectId(taskId) },
|
||||
(err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
db.faultsCollection.deleteOne(
|
||||
collections.faults.deleteOne(
|
||||
{ _id: `${deviceId}:task_${taskId}` },
|
||||
(err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
@@ -525,14 +588,14 @@ export function listener(
|
||||
}
|
||||
} else if (action === "/retry") {
|
||||
if (request.method === "POST") {
|
||||
db.tasksCollection.findOne(
|
||||
{ _id: new mongodb.ObjectID(taskId) },
|
||||
collections.tasks.findOne(
|
||||
{ _id: new ObjectId(taskId) },
|
||||
{ projection: { device: 1 } },
|
||||
(err, task) => {
|
||||
if (err) return void throwError(err, response);
|
||||
|
||||
const deviceId = task.device;
|
||||
db.faultsCollection.deleteOne(
|
||||
collections.faults.deleteOne(
|
||||
{ _id: `${deviceId}:task_${taskId}` },
|
||||
(err) => {
|
||||
if (err) return void throwError(err, response);
|
||||
@@ -571,9 +634,8 @@ export function listener(
|
||||
productClass: request.headers.productclass,
|
||||
version: request.headers.version,
|
||||
};
|
||||
const bucket = new mongodb.GridFSBucket(db.client.db());
|
||||
bucket.delete((filename as unknown) as mongodb.ObjectId, () => {
|
||||
const uploadStream = bucket.openUploadStreamWithId(
|
||||
filesBucket.delete((filename as unknown) as ObjectId, () => {
|
||||
const uploadStream = filesBucket.openUploadStreamWithId(
|
||||
filename,
|
||||
filename,
|
||||
{
|
||||
@@ -591,8 +653,7 @@ export function listener(
|
||||
});
|
||||
});
|
||||
} else if (request.method === "DELETE") {
|
||||
const bucket = new mongodb.GridFSBucket(db.client.db());
|
||||
bucket.delete((filename as unknown) as mongodb.ObjectId, (err) => {
|
||||
filesBucket.delete((filename as unknown) as ObjectId, (err) => {
|
||||
if (err) {
|
||||
if (err.message.startsWith("FileNotFound")) {
|
||||
response.writeHead(404);
|
||||
@@ -669,7 +730,7 @@ export function listener(
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = db[`${collectionName}Collection`];
|
||||
const collection = collections[collectionName];
|
||||
if (!collection) {
|
||||
response.writeHead(404);
|
||||
response.end("404 Not Found");
|
||||
@@ -693,7 +754,7 @@ export function listener(
|
||||
break;
|
||||
case "tasks":
|
||||
q = query.sanitizeQueryTypes(q, {
|
||||
_id: (v) => new mongodb.ObjectID(v as string),
|
||||
_id: (v) => new ObjectId(v as string),
|
||||
timestamp: (v) => new Date(v as number),
|
||||
retries: Number,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ping(
|
||||
callback: (err: Error, res?: PingResult, stdout?: string) => void
|
||||
): void {
|
||||
let cmd: string, parseRegExp1: RegExp, parseRegExp2: RegExp;
|
||||
host = host.replace("[", "").replace("]", "");
|
||||
switch (platform()) {
|
||||
case "linux":
|
||||
cmd = `ping -w 1 -i 0.2 -c 3 ${host}`;
|
||||
|
||||
+23
-15
@@ -52,35 +52,43 @@ function closeServer(timeout, callback): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface ServerOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
ssl?: { key: string; cert: string };
|
||||
timeout?: number;
|
||||
keepAliveTimeout?: number;
|
||||
onConnection?: (socket: Socket) => void;
|
||||
}
|
||||
|
||||
export function start(
|
||||
port: number,
|
||||
networkInterface: string,
|
||||
ssl: { key: string; cert: string },
|
||||
_listener: http.RequestListener,
|
||||
onConnection?: (socket: Socket) => void,
|
||||
keepAliveTimeout = -1
|
||||
options: ServerOptions,
|
||||
_listener: http.RequestListener
|
||||
): void {
|
||||
listener = _listener;
|
||||
|
||||
if (ssl && ssl.key && ssl.cert) {
|
||||
const options = {
|
||||
key: ssl.key
|
||||
if (options.ssl) {
|
||||
const opts = {
|
||||
key: options.ssl.key
|
||||
.split(":")
|
||||
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
|
||||
cert: ssl.cert
|
||||
cert: options.ssl.cert
|
||||
.split(":")
|
||||
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
|
||||
};
|
||||
|
||||
server = https.createServer(options, listener);
|
||||
if (onConnection != null) server.on("secureConnection", onConnection);
|
||||
server = https.createServer(opts, listener);
|
||||
if (options.onConnection)
|
||||
server.on("secureConnection", options.onConnection);
|
||||
} else {
|
||||
server = http.createServer(listener);
|
||||
if (onConnection != null) server.on("connection", onConnection);
|
||||
if (options.onConnection) server.on("connection", options.onConnection);
|
||||
}
|
||||
|
||||
if (keepAliveTimeout >= 0) server.keepAliveTimeout = keepAliveTimeout;
|
||||
server.listen(port, networkInterface);
|
||||
server.timeout = options.timeout || 0;
|
||||
if (options.keepAliveTimeout != null)
|
||||
server.keepAliveTimeout = options.keepAliveTimeout;
|
||||
server.listen({ port: options.port, host: options.host });
|
||||
}
|
||||
|
||||
export function stop(): Promise<void> {
|
||||
|
||||
+560
-39
@@ -57,8 +57,13 @@ import {
|
||||
FactoryReset,
|
||||
AddObjectResponse,
|
||||
GetParameterValuesResponse,
|
||||
ChangeDUStateOperation,
|
||||
DownloadOperation,
|
||||
DUStateChangeCompleteRequest,
|
||||
ChangeDUState,
|
||||
} from "./types";
|
||||
import { getRequestOrigin } from "./forwarded";
|
||||
import * as logger from "./logger";
|
||||
|
||||
const VALID_PARAM_TYPES = new Set([
|
||||
"xsd:int",
|
||||
@@ -283,6 +288,8 @@ export async function transferComplete(
|
||||
sessionContext.deviceData.attributes.revision = revision;
|
||||
const commandKey = rpcReq.commandKey;
|
||||
const operation = sessionContext.operations[commandKey];
|
||||
if (operation.name !== "Download")
|
||||
throw new Error("Unexpected operation name");
|
||||
|
||||
if (!operation) {
|
||||
return {
|
||||
@@ -432,6 +439,142 @@ function revertDownloadParameters(
|
||||
}
|
||||
}
|
||||
|
||||
export async function duStateChangeComplete(
|
||||
sessionContext: SessionContext,
|
||||
rpcReq: DUStateChangeCompleteRequest
|
||||
): Promise<{
|
||||
acsResponse: AcsResponse;
|
||||
operation: ChangeDUStateOperation;
|
||||
fault: Fault;
|
||||
}> {
|
||||
const revision =
|
||||
(sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;
|
||||
sessionContext.deviceData.timestamps.revision = revision;
|
||||
sessionContext.deviceData.attributes.revision = revision;
|
||||
const commandKey = rpcReq.commandKey;
|
||||
const operation = sessionContext.operations[commandKey];
|
||||
|
||||
if (!operation) {
|
||||
return {
|
||||
acsResponse: { name: "DUStateChangeCompleteResponse" },
|
||||
operation: null,
|
||||
fault: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.name !== "ChangeDUState")
|
||||
throw new Error("Unexpected operation name");
|
||||
|
||||
if (operation.args.operations.length !== rpcReq.results.length) {
|
||||
return {
|
||||
acsResponse: { name: "DUStateChangeCompleteResponse" },
|
||||
operation: operation,
|
||||
fault: {
|
||||
code: "invalid_response",
|
||||
message: "Unexpected number of results in DUStateChangeComplete",
|
||||
timestamp: operation.timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
delete sessionContext.operations[commandKey];
|
||||
if (!sessionContext.operationsTouched) sessionContext.operationsTouched = {};
|
||||
sessionContext.operationsTouched[commandKey] = 1;
|
||||
|
||||
let toClear = null;
|
||||
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
|
||||
|
||||
let fault: Fault;
|
||||
for (const [i, res] of rpcReq.results.entries()) {
|
||||
const op = operation.args.operations[i];
|
||||
if (res.fault && res.fault.faultCode !== "0") {
|
||||
revertChangeDuStateParameters(sessionContext, op.instance);
|
||||
fault = {
|
||||
code: `cwmp.${res.fault.faultCode}`,
|
||||
message: res.fault.faultString,
|
||||
timestamp: operation.timestamp,
|
||||
detail: res.fault,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const toSet: Record<string, [string | number | boolean, string]> = {
|
||||
LastChangeDUState: [operation.timestamp, "xsd:dateTime"],
|
||||
LastOperationType: [op.operationType, "xsd:string"],
|
||||
LastURL: [op.url, "xsd:string"],
|
||||
LastUUID: [op.uuid, "xsd:string"],
|
||||
LastExecutionEnvRef: [op.executionEnvRef, "xsd:string"],
|
||||
StartTime: [+res.startTime, "xsd:dateTime"],
|
||||
CompleteTime: [+res.completeTime, "xsd:dateTime"],
|
||||
CurrentState: [res.currentState, "xsd:string"],
|
||||
DeploymentUnitRef: [res.deploymentUnitRef, "xsd:string"],
|
||||
Version: [res.version, "xsd:string"],
|
||||
Resolved: [res.resolved, "xsd:string"],
|
||||
ExecutionUnitRefList: [res.executionUnitRefList, "xsd:string"],
|
||||
};
|
||||
|
||||
for (const [k, v] of Object.entries(toSet)) {
|
||||
const p = sessionContext.deviceData.paths.add(
|
||||
Path.parse(`DUStates.${op.instance}.${k}`)
|
||||
);
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
p,
|
||||
timestamp,
|
||||
{ value: [timestamp, v] },
|
||||
toClear
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (toClear) {
|
||||
for (const c of toClear)
|
||||
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
|
||||
}
|
||||
|
||||
return {
|
||||
acsResponse: { name: "DUStateChangeCompleteResponse" },
|
||||
operation: operation,
|
||||
fault: fault,
|
||||
};
|
||||
}
|
||||
|
||||
function revertChangeDuStateParameters(
|
||||
sessionContext: SessionContext,
|
||||
instance
|
||||
): void {
|
||||
const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;
|
||||
|
||||
let p;
|
||||
|
||||
p = sessionContext.deviceData.paths.add(
|
||||
Path.parse(`DUStates.${instance}.LastChangeDUState`)
|
||||
);
|
||||
|
||||
const lastChangeDuState = sessionContext.deviceData.attributes.get(p);
|
||||
|
||||
p = sessionContext.deviceData.paths.add(
|
||||
Path.parse(`DUStates.${instance}.ChangeDUState`)
|
||||
);
|
||||
|
||||
const toClear = device.set(sessionContext.deviceData, p, timestamp, {
|
||||
value: [
|
||||
timestamp,
|
||||
[
|
||||
lastChangeDuState && lastChangeDuState.value[1]
|
||||
? lastChangeDuState.value[1][0]
|
||||
: 0,
|
||||
"xsd:dateTime",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
if (toClear) {
|
||||
for (const c of toClear)
|
||||
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function timeoutOperations(
|
||||
sessionContext: SessionContext
|
||||
): Promise<{ faults: Fault[]; operations: Operation[] }> {
|
||||
@@ -793,6 +936,10 @@ function runDeclarations(
|
||||
downloadsToCreate: new InstanceSet(),
|
||||
downloadsValues: new Map(),
|
||||
downloadsDownload: new Map(),
|
||||
duStatesToDelete: new Set(),
|
||||
duStatesToCreate: new InstanceSet(),
|
||||
duStatesValues: new Map(),
|
||||
duStatesChangeDUState: new Map(),
|
||||
reboot: 0,
|
||||
factoryReset: 0,
|
||||
};
|
||||
@@ -910,10 +1057,15 @@ function runDeclarations(
|
||||
if (!unpacked)
|
||||
unpacked = device.unpack(sessionContext.deviceData, path);
|
||||
|
||||
for (const u of unpacked)
|
||||
for (const u of unpacked) {
|
||||
mergeAttributeValues(u, declaration.attrSet, declaration.defer);
|
||||
// Ensure writable attr is available
|
||||
mergeAttributeTimestamps(u, { writable: 1 });
|
||||
}
|
||||
} else {
|
||||
mergeAttributeValues(path, declaration.attrSet, declaration.defer);
|
||||
// Ensure writable attr is available
|
||||
mergeAttributeTimestamps(path, { writable: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,14 +1089,15 @@ function runDeclarations(
|
||||
keys = {};
|
||||
}
|
||||
|
||||
if (
|
||||
((path.wildcard | path.alias) & ((1 << (path.length - 1)) - 1)) ===
|
||||
0
|
||||
) {
|
||||
if (!parent.wildcard && !parent.alias) {
|
||||
parent = sessionContext.deviceData.paths.add(parent);
|
||||
if (!unpacked)
|
||||
unpacked = device.unpack(sessionContext.deviceData, path);
|
||||
|
||||
// Ensure writable attr is available
|
||||
mergeAttributeTimestamps(parent, { writable: 1 });
|
||||
for (const u of unpacked) mergeAttributeTimestamps(u, { writable: 1 });
|
||||
|
||||
processInstances(
|
||||
sessionContext,
|
||||
parent,
|
||||
@@ -960,13 +1113,19 @@ function runDeclarations(
|
||||
parent
|
||||
);
|
||||
for (const par of parentsUnpacked) {
|
||||
const up = device.unpack(
|
||||
sessionContext.deviceData,
|
||||
par.concat(path.slice(-1))
|
||||
);
|
||||
|
||||
// Ensure writable attr is available
|
||||
mergeAttributeTimestamps(par, { writable: 1 });
|
||||
for (const u of up) mergeAttributeTimestamps(u, { writable: 1 });
|
||||
|
||||
processInstances(
|
||||
sessionContext,
|
||||
par,
|
||||
device.unpack(
|
||||
sessionContext.deviceData,
|
||||
par.concat(path.slice(-1))
|
||||
),
|
||||
up,
|
||||
keys,
|
||||
minInstances,
|
||||
maxInstances,
|
||||
@@ -1426,6 +1585,147 @@ export async function rpcRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// DUState
|
||||
index = null;
|
||||
for (const instance of sessionContext.syncState.duStatesToCreate) {
|
||||
if (index == null) {
|
||||
index = 0;
|
||||
for (const p of sessionContext.deviceData.paths.find(
|
||||
Path.parse("DUStates.*"),
|
||||
false,
|
||||
true
|
||||
)) {
|
||||
if (
|
||||
+p.segments[1] > index &&
|
||||
sessionContext.deviceData.attributes.has(p)
|
||||
)
|
||||
index = +p.segments[1];
|
||||
}
|
||||
}
|
||||
|
||||
++index;
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse("DUStates"),
|
||||
timestamp,
|
||||
{ object: [timestamp, 1], writable: [timestamp, 1] },
|
||||
toClear
|
||||
);
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(`DUStates.${index}`),
|
||||
timestamp,
|
||||
{ object: [timestamp, 1], writable: [timestamp, 1] },
|
||||
toClear
|
||||
);
|
||||
|
||||
const params = {
|
||||
URL: {
|
||||
writable: 1,
|
||||
value: [instance.URL || "", "xsd:string"],
|
||||
},
|
||||
UUID: {
|
||||
writable: 1,
|
||||
value: [instance.UUID || "", "xsd:string"],
|
||||
},
|
||||
ExecutionEnvRef: {
|
||||
writable: 1,
|
||||
value: [instance.ExecutionEnvRef || "", "xsd:string"],
|
||||
},
|
||||
Username: {
|
||||
writable: 1,
|
||||
value: [instance.Username || "", "xsd:string"],
|
||||
},
|
||||
Password: {
|
||||
writable: 1,
|
||||
value: [instance.Password || "", "xsd:string"],
|
||||
},
|
||||
ChangeDUState: {
|
||||
writable: 1,
|
||||
value: [instance.ChangeDUState || "", "xsd:dateTime"],
|
||||
},
|
||||
OperationType: {
|
||||
writable: 1,
|
||||
value: [instance.OperationType || "", "xsd:string"],
|
||||
},
|
||||
LastURL: { writable: 0, value: ["", "xsd:string"] },
|
||||
LastUUID: { writable: 0, value: ["", "xsd:string"] },
|
||||
LastExecutionEnvRef: { writable: 0, value: ["", "xsd:string"] },
|
||||
LastChangeDUState: { writable: 0, value: [0, "xsd:dateTime"] },
|
||||
LastOperationType: { writable: 0, value: [0, "xsd:string"] },
|
||||
Version: { writable: 0, value: [0, "xsd:string"] },
|
||||
CurrentState: { writable: 0, value: [0, "xsd:string"] },
|
||||
ExecutionUnitRefList: { writable: 0, value: [0, "xsd:string"] },
|
||||
DeploymentUnitRef: { writable: 0, value: [0, "xsd:string"] },
|
||||
Resolved: { writable: 0, value: [0, "xsd:boolean"] },
|
||||
StartTime: { writable: 0, value: [0, "xsd:dateTime"] },
|
||||
CompleteTime: { writable: 0, value: [0, "xsd:dateTime"] },
|
||||
};
|
||||
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(`DUStates.${index}.${k}`),
|
||||
timestamp,
|
||||
{
|
||||
object: [timestamp, 0],
|
||||
writable: [timestamp, v.writable as 0 | 1],
|
||||
value: [
|
||||
timestamp,
|
||||
v.value as [string | number | boolean, string],
|
||||
],
|
||||
},
|
||||
toClear
|
||||
);
|
||||
}
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(`DUStates.${index}.*`),
|
||||
timestamp,
|
||||
null,
|
||||
toClear
|
||||
);
|
||||
}
|
||||
|
||||
sessionContext.syncState.duStatesToCreate.clear();
|
||||
|
||||
for (const instance of sessionContext.syncState.duStatesToDelete) {
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
instance,
|
||||
timestamp,
|
||||
null,
|
||||
toClear
|
||||
);
|
||||
for (const p of sessionContext.syncState.duStatesValues.keys()) {
|
||||
if (p.segments[1] === instance.segments[1])
|
||||
sessionContext.syncState.duStatesValues.delete(p);
|
||||
}
|
||||
}
|
||||
|
||||
sessionContext.syncState.duStatesToDelete.clear();
|
||||
|
||||
for (const [p, v] of sessionContext.syncState.duStatesValues) {
|
||||
const attrs = sessionContext.deviceData.attributes.get(p);
|
||||
if (attrs) {
|
||||
if (attrs.writable && attrs.writable[1] && attrs.value) {
|
||||
const val = device.sanitizeParameterValue([v, attrs.value[1][1]]);
|
||||
if (val[0] !== attrs.value[1][0]) {
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
p,
|
||||
timestamp,
|
||||
{ value: [timestamp, val] },
|
||||
toClear
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toClear || sessionContext.deviceData.changes.has("prerequisite")) {
|
||||
if (toClear) {
|
||||
for (const c of toClear)
|
||||
@@ -1728,26 +2028,42 @@ function generateSetRpcRequest(
|
||||
| FactoryReset
|
||||
| Reboot
|
||||
| Download
|
||||
| ChangeDUState
|
||||
) & { next?: string } {
|
||||
const syncState = sessionContext.syncState;
|
||||
if (!syncState) return null;
|
||||
|
||||
const deviceData = sessionContext.deviceData;
|
||||
|
||||
const SKIP_WRITABLE_CHECK = !!localCache.getConfig(
|
||||
sessionContext.cacheSnapshot,
|
||||
"cwmp.skipWritableCheck",
|
||||
{},
|
||||
sessionContext.timestamp,
|
||||
(e) => configContextCallback(sessionContext, e)
|
||||
);
|
||||
|
||||
const canWrite = (attrs: Attributes): boolean =>
|
||||
SKIP_WRITABLE_CHECK || (attrs.writable && !!attrs.writable[1]);
|
||||
|
||||
// Delete instance
|
||||
for (const instances of syncState.instancesToDelete.values()) {
|
||||
const instance = instances.values().next().value;
|
||||
if (instance && sessionContext.deviceData.attributes.has(instance)) {
|
||||
return {
|
||||
name: "DeleteObject",
|
||||
objectName: instance.toString() + ".",
|
||||
};
|
||||
for (const instance of instances) {
|
||||
const attrs = sessionContext.deviceData.attributes.get(instance);
|
||||
if (attrs && canWrite(attrs)) {
|
||||
instances.delete(instance);
|
||||
return {
|
||||
name: "DeleteObject",
|
||||
objectName: instance.toString() + ".",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create instance
|
||||
for (const [param, instances] of syncState.instancesToCreate) {
|
||||
if (sessionContext.deviceData.attributes.has(param)) {
|
||||
const attrs = sessionContext.deviceData.attributes.get(param);
|
||||
if (attrs && canWrite(attrs)) {
|
||||
const instance = instances.values().next().value;
|
||||
if (instance) {
|
||||
instances.delete(instance);
|
||||
@@ -1791,7 +2107,7 @@ function generateSetRpcRequest(
|
||||
syncState.spv.delete(k);
|
||||
const attrs = sessionContext.deviceData.attributes.get(k);
|
||||
const curVal = attrs.value ? attrs.value[1] : null;
|
||||
if (curVal && attrs.writable && attrs.writable[1]) {
|
||||
if (curVal && canWrite(attrs)) {
|
||||
const val = v.slice() as [string | number | boolean, string];
|
||||
if (!val[1]) val[1] = curVal[1];
|
||||
device.sanitizeParameterValue(val);
|
||||
@@ -1891,6 +2207,76 @@ function generateSetRpcRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeDUState
|
||||
const duStateOps: ChangeDUState["operations"] = [];
|
||||
for (const [p, t] of syncState.duStatesChangeDUState) {
|
||||
const attrs = deviceData.attributes.get(p);
|
||||
if (attrs && attrs.value && t <= attrs.value[1][0]) continue;
|
||||
const urlAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("URL")))
|
||||
);
|
||||
const operationTypeAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("OperationType")))
|
||||
);
|
||||
const uuidAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("UUID")))
|
||||
);
|
||||
const executionEnvRefAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("ExecutionEnvRef")))
|
||||
);
|
||||
const versionAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Version")))
|
||||
);
|
||||
const usernameAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Username")))
|
||||
);
|
||||
const passwordAttrs = deviceData.attributes.get(
|
||||
deviceData.paths.get(p.slice(0, -1).concat(Path.parse("Password")))
|
||||
);
|
||||
|
||||
const operationType = operationTypeAttrs
|
||||
? (operationTypeAttrs.value[1][0] as string)
|
||||
: null;
|
||||
const url = urlAttrs ? (urlAttrs.value[1][0] as string) : null;
|
||||
const uuid = uuidAttrs ? (uuidAttrs.value[1][0] as string) : null;
|
||||
const executionEnvRef = executionEnvRefAttrs
|
||||
? (executionEnvRefAttrs.value[1][0] as string)
|
||||
: null;
|
||||
const version = versionAttrs ? (versionAttrs.value[1][0] as string) : null;
|
||||
const username = usernameAttrs
|
||||
? (usernameAttrs.value[1][0] as string)
|
||||
: null;
|
||||
const password = passwordAttrs
|
||||
? (passwordAttrs.value[1][0] as string)
|
||||
: null;
|
||||
|
||||
if (
|
||||
operationType !== "install" &&
|
||||
operationType !== "update" &&
|
||||
operationType !== "uninstall"
|
||||
)
|
||||
continue;
|
||||
|
||||
duStateOps.push({
|
||||
operationType: operationType,
|
||||
instance: p.segments[1] as string,
|
||||
url: url,
|
||||
uuid: uuid,
|
||||
executionEnvRef: executionEnvRef,
|
||||
version: version,
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
}
|
||||
|
||||
if (duStateOps.length) {
|
||||
return {
|
||||
name: "ChangeDUState",
|
||||
commandKey: generateRpcId(sessionContext),
|
||||
operations: duStateOps,
|
||||
};
|
||||
}
|
||||
|
||||
// Reboot
|
||||
if (syncState.reboot) {
|
||||
const p = sessionContext.deviceData.paths.get(Path.parse("Reboot"));
|
||||
@@ -2172,6 +2558,26 @@ function processDeclarations(
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "DUStates":
|
||||
if (
|
||||
currentPath.length === 3 &&
|
||||
currentPath.wildcard === 0 &&
|
||||
declareAttributeValues &&
|
||||
declareAttributeValues.value
|
||||
) {
|
||||
if (currentPath.segments[2] === "ChangeDUState") {
|
||||
syncState.duStatesChangeDUState.set(
|
||||
currentPath,
|
||||
declareAttributeValues.value[0]
|
||||
);
|
||||
} else {
|
||||
syncState.duStatesValues.set(
|
||||
currentPath,
|
||||
declareAttributeValues.value[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "VirtualParameters":
|
||||
if (currentPath.length <= 2) {
|
||||
let d;
|
||||
@@ -2263,7 +2669,7 @@ function processDeclarations(
|
||||
syncState.refreshAttributes.object.add(currentPath);
|
||||
else if (currentAttributes.object[1] === 0)
|
||||
syncState.refreshAttributes.value.add(currentPath);
|
||||
} else {
|
||||
} else if (attrName in syncState.refreshAttributes) {
|
||||
syncState.refreshAttributes[attrName].add(currentPath);
|
||||
}
|
||||
}
|
||||
@@ -2342,6 +2748,10 @@ function processInstances(
|
||||
if (parent.length !== 1) return;
|
||||
instancesToDelete = sessionContext.syncState.downloadsToDelete;
|
||||
instancesToCreate = sessionContext.syncState.downloadsToCreate;
|
||||
} else if (parent.segments[0] === "DUStates") {
|
||||
if (parent.length !== 1) return;
|
||||
instancesToDelete = sessionContext.syncState.duStatesToDelete;
|
||||
instancesToCreate = sessionContext.syncState.duStatesToCreate;
|
||||
} else {
|
||||
instancesToDelete = sessionContext.syncState.instancesToDelete.get(parent);
|
||||
if (instancesToDelete == null) {
|
||||
@@ -2392,9 +2802,16 @@ export async function rpcResponse(
|
||||
sessionContext: SessionContext,
|
||||
id: string,
|
||||
_rpcRes: CpeResponse
|
||||
): Promise<void> {
|
||||
): Promise<Fault> {
|
||||
function invalidResponse(message: string): Fault {
|
||||
return {
|
||||
code: "invalid_response",
|
||||
message: message,
|
||||
};
|
||||
}
|
||||
|
||||
if (id !== generateRpcId(sessionContext))
|
||||
throw new Error("Request ID not recognized");
|
||||
return invalidResponse("Request ID not recognized");
|
||||
|
||||
++sessionContext.rpcCount;
|
||||
|
||||
@@ -2485,39 +2902,99 @@ export async function rpcResponse(
|
||||
|
||||
if (rpcRes.name === "GetParameterValuesResponse") {
|
||||
if (rpcReq.name !== "GetParameterValues")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
for (const p of rpcRes.parameterList) {
|
||||
const requested = new Set(rpcReq.parameterNames);
|
||||
|
||||
for (const [path, value, type] of rpcRes.parameterList) {
|
||||
if (!requested.delete(path.toString())) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Unexpected parameter in response",
|
||||
parameter: path.toString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
p[0],
|
||||
path,
|
||||
timestamp,
|
||||
{
|
||||
object: [timestamp, 0],
|
||||
value: [timestamp, p.slice(1) as [string | number | boolean, string]],
|
||||
value: [timestamp, [value, type]],
|
||||
},
|
||||
toClear
|
||||
);
|
||||
}
|
||||
|
||||
if (requested.size) {
|
||||
for (const p of requested) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Missing parameter in response",
|
||||
parameter: p,
|
||||
});
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(p),
|
||||
timestamp,
|
||||
{
|
||||
object: [timestamp, 0],
|
||||
value: [timestamp, ["", "xsd:string"]],
|
||||
},
|
||||
toClear
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (rpcRes.name === "GetParameterAttributesResponse") {
|
||||
if (rpcReq.name !== "GetParameterAttributes")
|
||||
throw new Error("Response name does not match request name");
|
||||
|
||||
for (const p of rpcRes.parameterList) {
|
||||
const requested = new Set(rpcReq.parameterNames);
|
||||
|
||||
for (const [path, notification, accessList] of rpcRes.parameterList) {
|
||||
if (!requested.delete(path.toString())) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Unexpected parameter in response",
|
||||
parameter: path.toString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
p[0],
|
||||
path,
|
||||
timestamp,
|
||||
{
|
||||
notification: [timestamp, p[1] as number],
|
||||
accessList: [timestamp, p[2] as string[]],
|
||||
notification: [timestamp, notification],
|
||||
accessList: [timestamp, accessList],
|
||||
},
|
||||
toClear
|
||||
);
|
||||
}
|
||||
|
||||
if (requested.size) {
|
||||
for (const p of requested) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Missing parameter in response",
|
||||
parameter: p,
|
||||
});
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(p),
|
||||
timestamp,
|
||||
{
|
||||
notification: [timestamp, 0],
|
||||
accessList: [timestamp, []],
|
||||
},
|
||||
toClear
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (rpcRes.name === "GetParameterNamesResponse") {
|
||||
if (rpcReq.name !== "GetParameterNames")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
let root: Path;
|
||||
if (rpcReq.parameterPath.endsWith("."))
|
||||
@@ -2563,6 +3040,7 @@ export async function rpcResponse(
|
||||
"FactoryReset",
|
||||
"VirtualParameters",
|
||||
"Downloads",
|
||||
"DUStates",
|
||||
]) {
|
||||
const p = sessionContext.deviceData.paths.get(Path.parse(n));
|
||||
if (p && sessionContext.deviceData.attributes.has(p))
|
||||
@@ -2574,6 +3052,14 @@ export async function rpcResponse(
|
||||
const wildcardParams: Path[] = [root.concat(wildcardPath)];
|
||||
|
||||
for (const [path, object, writable] of rpcRes.parameterList) {
|
||||
if (!path.toString().startsWith(rpcReq.parameterPath)) {
|
||||
logger.accessWarn({
|
||||
sessionContext: sessionContext,
|
||||
message: "Unexpected parameter in response",
|
||||
parameter: path.toString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (object && !rpcReq.nextLevel)
|
||||
wildcardParams.push(path.concat(wildcardPath));
|
||||
|
||||
@@ -2600,7 +3086,7 @@ export async function rpcResponse(
|
||||
}
|
||||
} else if (rpcRes.name === "SetParameterValuesResponse") {
|
||||
if (rpcReq.name !== "SetParameterValues")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
for (const p of rpcReq.parameterList) {
|
||||
toClear = device.set(
|
||||
@@ -2609,7 +3095,6 @@ export async function rpcResponse(
|
||||
timestamp + 1,
|
||||
{
|
||||
object: [timestamp + 1, 0],
|
||||
writable: [timestamp + 1, 1],
|
||||
value: [
|
||||
timestamp + 1,
|
||||
p.slice(1) as [string | number | boolean, string],
|
||||
@@ -2620,7 +3105,7 @@ export async function rpcResponse(
|
||||
}
|
||||
} else if (rpcRes.name === "SetParameterAttributesResponse") {
|
||||
if (rpcReq.name !== "SetParameterAttributes")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
for (const p of rpcReq.parameterList) {
|
||||
let attrs;
|
||||
@@ -2650,7 +3135,7 @@ export async function rpcResponse(
|
||||
}
|
||||
} else if (rpcRes.name === "AddObjectResponse") {
|
||||
if (rpcReq.name !== "AddObject")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
@@ -2661,7 +3146,7 @@ export async function rpcResponse(
|
||||
);
|
||||
} else if (rpcRes.name === "DeleteObjectResponse") {
|
||||
if (rpcReq.name !== "DeleteObject")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
@@ -2672,7 +3157,7 @@ export async function rpcResponse(
|
||||
);
|
||||
} else if (rpcRes.name === "RebootResponse") {
|
||||
if (rpcReq.name !== "Reboot")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
@@ -2683,7 +3168,7 @@ export async function rpcResponse(
|
||||
);
|
||||
} else if (rpcRes.name === "FactoryResetResponse") {
|
||||
if (rpcReq.name !== "FactoryReset")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
@@ -2694,7 +3179,7 @@ export async function rpcResponse(
|
||||
);
|
||||
} else if (rpcRes.name === "DownloadResponse") {
|
||||
if (rpcReq.name !== "Download")
|
||||
throw new Error("Response name does not match request name");
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
@@ -2755,7 +3240,7 @@ export async function rpcResponse(
|
||||
toClear
|
||||
);
|
||||
} else {
|
||||
const operation = {
|
||||
const operation: DownloadOperation = {
|
||||
name: "Download",
|
||||
timestamp: sessionContext.timestamp,
|
||||
provisions: sessionContext.provisions,
|
||||
@@ -2779,14 +3264,50 @@ export async function rpcResponse(
|
||||
sessionContext.operationsTouched = {};
|
||||
sessionContext.operationsTouched[rpcReq.commandKey] = 1;
|
||||
}
|
||||
} else if (rpcRes.name === "ChangeDUStateResponse") {
|
||||
if (rpcReq.name !== "ChangeDUState")
|
||||
return invalidResponse("Response name does not match request name");
|
||||
|
||||
for (const op of rpcReq.operations) {
|
||||
toClear = device.set(
|
||||
sessionContext.deviceData,
|
||||
Path.parse(`DUStates.${op.instance}.ChangeDUState`),
|
||||
timestamp + 1,
|
||||
{ value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] },
|
||||
toClear
|
||||
);
|
||||
}
|
||||
|
||||
const operation = {
|
||||
name: "ChangeDUState",
|
||||
timestamp: sessionContext.timestamp,
|
||||
provisions: sessionContext.provisions,
|
||||
channels: sessionContext.channels,
|
||||
retries: {},
|
||||
args: {
|
||||
operations: rpcReq.operations,
|
||||
},
|
||||
};
|
||||
|
||||
for (const channel of Object.keys(sessionContext.channels)) {
|
||||
if (sessionContext.retries[channel] != null)
|
||||
operation.retries[channel] = sessionContext.retries[channel];
|
||||
}
|
||||
|
||||
sessionContext.operations[rpcReq.commandKey] = operation as Operation;
|
||||
if (!sessionContext.operationsTouched)
|
||||
sessionContext.operationsTouched = {};
|
||||
sessionContext.operationsTouched[rpcReq.commandKey] = 1;
|
||||
} else {
|
||||
throw new Error("Response name not recognized");
|
||||
return invalidResponse("Response name not recognized");
|
||||
}
|
||||
|
||||
if (toClear) {
|
||||
for (const c of toClear)
|
||||
device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function rpcFault(
|
||||
|
||||
+320
-26
@@ -48,6 +48,9 @@ import {
|
||||
GetRPCMethodsRequest,
|
||||
RequestDownloadRequest,
|
||||
AcsResponse,
|
||||
ChangeDUState,
|
||||
ChangeDUStateResponse,
|
||||
DUStateChangeCompleteRequest,
|
||||
} from "./types";
|
||||
import Path from "./common/path";
|
||||
|
||||
@@ -109,8 +112,8 @@ function event(xml: Element): string[] {
|
||||
|
||||
function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
|
||||
return xml.children
|
||||
.filter((e) => e.localName === "ParameterInfoStruct")
|
||||
.map<[Path, boolean, boolean]>((e) => {
|
||||
if (e.localName !== "ParameterInfoStruct") return null;
|
||||
let param: string, value: string;
|
||||
for (const c of e.children) {
|
||||
switch (c.localName) {
|
||||
@@ -127,16 +130,27 @@ function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
|
||||
|
||||
if (parsed == null) {
|
||||
warnings.push({
|
||||
message: "Invalid writable attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Writable",
|
||||
parameter: param,
|
||||
});
|
||||
parsed = false;
|
||||
}
|
||||
|
||||
if (param && !param.endsWith("."))
|
||||
return [Path.parse(param), false, parsed];
|
||||
else return [Path.parse(param.slice(0, -1)), true, parsed];
|
||||
});
|
||||
try {
|
||||
if (param && !param.endsWith("."))
|
||||
return [Path.parse(param), false, parsed];
|
||||
else return [Path.parse(param.slice(0, -1)), true, parsed];
|
||||
} catch (err) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Name",
|
||||
parameter: param,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e) => e != null);
|
||||
}
|
||||
|
||||
const getValueType = memoize((str: string) => {
|
||||
@@ -150,8 +164,8 @@ function parameterValueList(
|
||||
xml: Element
|
||||
): [Path, string | number | boolean, string][] {
|
||||
return xml.children
|
||||
.filter((e) => e.localName === "ParameterValueStruct")
|
||||
.map<[Path, string | number | boolean, string]>((e) => {
|
||||
if (e.localName !== "ParameterValueStruct") return null;
|
||||
let valueElement: Element, param: string;
|
||||
for (const c of e.children) {
|
||||
switch (c.localName) {
|
||||
@@ -167,7 +181,8 @@ function parameterValueList(
|
||||
let valueType = getValueType(valueElement.attrs);
|
||||
if (!valueType) {
|
||||
warnings.push({
|
||||
message: "Invalid value type attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
attribute: "type",
|
||||
parameter: param,
|
||||
});
|
||||
valueType = "xsd:string";
|
||||
@@ -179,7 +194,8 @@ function parameterValueList(
|
||||
parsed = parseBool(value);
|
||||
if (parsed == null) {
|
||||
warnings.push({
|
||||
message: "Invalid value attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Value",
|
||||
parameter: param,
|
||||
});
|
||||
parsed = value;
|
||||
@@ -188,7 +204,8 @@ function parameterValueList(
|
||||
parsed = parseInt(value);
|
||||
if (isNaN(parsed)) {
|
||||
warnings.push({
|
||||
message: "Invalid value attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Value",
|
||||
parameter: param,
|
||||
});
|
||||
parsed = value;
|
||||
@@ -197,21 +214,31 @@ function parameterValueList(
|
||||
parsed = Date.parse(value);
|
||||
if (isNaN(parsed)) {
|
||||
warnings.push({
|
||||
message: "Invalid value attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Value",
|
||||
parameter: param,
|
||||
});
|
||||
parsed = value;
|
||||
}
|
||||
}
|
||||
|
||||
return [Path.parse(param), parsed, valueType];
|
||||
});
|
||||
try {
|
||||
return [Path.parse(param), parsed, valueType];
|
||||
} catch (err) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Name",
|
||||
parameter: param,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e) => e != null);
|
||||
}
|
||||
|
||||
function parameterAttributeList(xml: Element): [Path, number, string[]][] {
|
||||
return xml.children
|
||||
.filter((e) => e.localName === "ParameterAttributeStruct")
|
||||
.map<[Path, number, string[]]>((e) => {
|
||||
if (e.localName !== "ParameterAttributeStruct") return null;
|
||||
let notificationElement: Element,
|
||||
accessListElement: Element,
|
||||
param: string;
|
||||
@@ -232,7 +259,8 @@ function parameterAttributeList(xml: Element): [Path, number, string[]][] {
|
||||
let notification = parseInt(notificationElement.text);
|
||||
if (isNaN(notification)) {
|
||||
warnings.push({
|
||||
message: "Invalid notification attribute",
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Notification",
|
||||
parameter: param,
|
||||
});
|
||||
notification = 0;
|
||||
@@ -242,8 +270,18 @@ function parameterAttributeList(xml: Element): [Path, number, string[]][] {
|
||||
.filter((c) => c.localName === "string")
|
||||
.map((c) => decodeEntities(c.text));
|
||||
|
||||
return [Path.parse(param), notification, accessList];
|
||||
});
|
||||
try {
|
||||
return [Path.parse(param), notification, accessList];
|
||||
} catch (err) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Name",
|
||||
parameter: param,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e) => e != null);
|
||||
}
|
||||
|
||||
function GetParameterNames(methodRequest): string {
|
||||
@@ -331,7 +369,10 @@ function SetParameterValuesResponse(xml: Element): SetParameterValuesResponse {
|
||||
}
|
||||
|
||||
if (!(status >= 0)) {
|
||||
warnings.push({ message: "Invalid SetParameterValuesResponse status" });
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Status",
|
||||
});
|
||||
status = 0;
|
||||
}
|
||||
|
||||
@@ -392,8 +433,14 @@ function AddObjectResponse(xml: Element): AddObjectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^[0-9]+$/.test(instanceNumber))
|
||||
throw new Error("Missing or invalid instance number");
|
||||
|
||||
if (!(status >= 0)) {
|
||||
warnings.push({ message: "Invalid AddObjectResponse status" });
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Status",
|
||||
});
|
||||
status = 0;
|
||||
}
|
||||
|
||||
@@ -424,7 +471,10 @@ function DeleteObjectResponse(xml: Element): DeleteObjectResponse {
|
||||
}
|
||||
|
||||
if (!(status >= 0)) {
|
||||
warnings.push({ message: "Invalid DeleteObjectResponse status" });
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Status",
|
||||
});
|
||||
status = 0;
|
||||
}
|
||||
|
||||
@@ -495,10 +545,29 @@ function DownloadResponse(xml: Element): DownloadResponse {
|
||||
}
|
||||
|
||||
if (!(status >= 0)) {
|
||||
warnings.push({ message: "Invalid DownloadResponse status" });
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Status",
|
||||
});
|
||||
status = 0;
|
||||
}
|
||||
|
||||
if (startTime == null || isNaN(startTime)) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "StartTime",
|
||||
});
|
||||
startTime = Date.parse("0001-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
if (completeTime == null || isNaN(completeTime)) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "CompleteTime",
|
||||
});
|
||||
completeTime = Date.parse("0001-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
return {
|
||||
name: "DownloadResponse",
|
||||
status: status,
|
||||
@@ -507,6 +576,29 @@ function DownloadResponse(xml: Element): DownloadResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function ChangeDUState(methodRequest: ChangeDUState): string {
|
||||
const operations = methodRequest.operations.map((op) => {
|
||||
if (op.operationType === "install")
|
||||
return `<Operations xsi:type="cwmp:InstallOpStruct"><UUID>${op.uuid}</UUID><URL>${op.url}</URL><ExecutionEnvRef>${op.executionEnvRef}</ExecutionEnvRef><Username>${op.username}</Username><Password>${op.password}</Password></Operations>`;
|
||||
else if (op.operationType === "update")
|
||||
return `<Operations xsi:type="cwmp:UpdateOpStruct"><UUID>${op.uuid}</UUID><URL>${op.url}</URL><Version>${op.version}</Version><Username>${op.username}</Username><Password>${op.password}</Password></Operations>`;
|
||||
else if (op.operationType === "uninstall")
|
||||
return `<Operations xsi:type="cwmp:UninstallOpStruct"><UUID>${op.uuid}</UUID><Version>${op.version}</Version><ExecutionEnvRef>${op.executionEnvRef}</ExecutionEnvRef></Operations>`;
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
return `<cwmp:ChangeDUState><CommandKey>${
|
||||
methodRequest.commandKey || ""
|
||||
}</CommandKey>${operations.join("")}</cwmp:ChangeDUState>`;
|
||||
}
|
||||
|
||||
function ChangeDUStateResponse(): ChangeDUStateResponse {
|
||||
return {
|
||||
name: "ChangeDUStateResponse",
|
||||
};
|
||||
}
|
||||
|
||||
function Inform(xml: Element): InformRequest {
|
||||
let retryCount: number, evnt: string[];
|
||||
let parameterList: [Path, string | number | boolean, string][];
|
||||
@@ -537,6 +629,30 @@ function Inform(xml: Element): InformRequest {
|
||||
}
|
||||
}
|
||||
|
||||
if (!deviceId || !deviceId.SerialNumber || !deviceId.OUI)
|
||||
throw new Error("Missing or invalid DeviceId element");
|
||||
|
||||
if (!parameterList) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "ParameterList",
|
||||
});
|
||||
parameterList = [];
|
||||
}
|
||||
|
||||
if (!evnt) {
|
||||
warnings.push({ message: "Missing or invalid XML node", element: "Event" });
|
||||
evnt = [];
|
||||
}
|
||||
|
||||
if (retryCount == null || isNaN(retryCount)) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "RetryCount",
|
||||
});
|
||||
retryCount = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Inform",
|
||||
parameterList: parameterList,
|
||||
@@ -563,7 +679,10 @@ function GetRPCMethodsResponse(methodResponse): string {
|
||||
}
|
||||
|
||||
function TransferComplete(xml: Element): TransferCompleteRequest {
|
||||
let commandKey, _faultStruct, startTime, completeTime;
|
||||
let commandKey: string,
|
||||
_faultStruct: FaultStruct,
|
||||
startTime: number,
|
||||
completeTime: number;
|
||||
for (const c of xml.children) {
|
||||
switch (c.localName) {
|
||||
case "CommandKey":
|
||||
@@ -581,6 +700,38 @@ function TransferComplete(xml: Element): TransferCompleteRequest {
|
||||
}
|
||||
}
|
||||
|
||||
if (commandKey == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "CommandKey",
|
||||
});
|
||||
commandKey = "";
|
||||
}
|
||||
|
||||
if (!_faultStruct) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "FaultStruct",
|
||||
});
|
||||
_faultStruct = { faultCode: "0", faultString: "" };
|
||||
}
|
||||
|
||||
if (startTime == null || isNaN(startTime)) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "StartTime",
|
||||
});
|
||||
startTime = Date.parse("0001-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
if (completeTime == null || isNaN(completeTime)) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "CompleteTime",
|
||||
});
|
||||
completeTime = Date.parse("0001-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
return {
|
||||
name: "TransferComplete",
|
||||
commandKey: commandKey,
|
||||
@@ -594,6 +745,97 @@ function TransferCompleteResponse(): string {
|
||||
return "<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>";
|
||||
}
|
||||
|
||||
function DUStateChangeComplete(xml: Element): DUStateChangeCompleteRequest {
|
||||
let commandKey: string,
|
||||
results: DUStateChangeCompleteRequest["results"],
|
||||
res: DUStateChangeCompleteRequest["results"][0];
|
||||
for (const c of xml.children) {
|
||||
switch (c.localName) {
|
||||
case "CommandKey":
|
||||
commandKey = c.text;
|
||||
break;
|
||||
case "Results":
|
||||
if (!results) results = [];
|
||||
res = {
|
||||
uuid: null,
|
||||
deploymentUnitRef: null,
|
||||
version: null,
|
||||
currentState: null,
|
||||
resolved: true,
|
||||
executionUnitRefList: null,
|
||||
startTime: null,
|
||||
completeTime: null,
|
||||
fault: null,
|
||||
};
|
||||
for (const cc of c.children) {
|
||||
switch (cc.localName) {
|
||||
case "UUID":
|
||||
res.uuid = cc.text;
|
||||
break;
|
||||
case "DeploymentUnitRef":
|
||||
res.deploymentUnitRef = cc.text;
|
||||
break;
|
||||
case "Version":
|
||||
res.version = cc.text;
|
||||
break;
|
||||
case "CurrentState":
|
||||
res.currentState = cc.text;
|
||||
break;
|
||||
case "Resolved":
|
||||
res.resolved = parseBool(cc.text);
|
||||
break;
|
||||
case "ExecutionUnitRefList":
|
||||
res.executionUnitRefList = cc.text;
|
||||
break;
|
||||
case "StartTime":
|
||||
if (c.text) res.startTime = Date.parse(c.text);
|
||||
break;
|
||||
case "CompleteTime":
|
||||
if (c.text) res.completeTime = Date.parse(c.text);
|
||||
break;
|
||||
case "Fault":
|
||||
res.fault = {
|
||||
faultCode: cc.children.find((n) => n.localName === "FaultCode")
|
||||
.text,
|
||||
faultString: cc.children.find(
|
||||
(n) => n.localName === "FaultString"
|
||||
).text,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
results.push(res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (commandKey == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "CommandKey",
|
||||
});
|
||||
commandKey = "";
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "Results",
|
||||
});
|
||||
results = [];
|
||||
}
|
||||
|
||||
return {
|
||||
name: "DUStateChangeComplete",
|
||||
commandKey: commandKey,
|
||||
results: results,
|
||||
};
|
||||
}
|
||||
|
||||
function DUStateChangeCompleteResponse(): string {
|
||||
return "<cwmp:DUStateChangeCompleteResponse></cwmp:DUStateChangeCompleteResponse>";
|
||||
}
|
||||
|
||||
function RequestDownload(xml: Element): RequestDownloadRequest {
|
||||
return {
|
||||
name: "RequestDownload",
|
||||
@@ -606,7 +848,12 @@ function RequestDownloadResponse(): string {
|
||||
}
|
||||
|
||||
function faultStruct(xml: Element): FaultStruct {
|
||||
let faultCode, faultString, setParameterValuesFault: SpvFault[], pn, fc, fs;
|
||||
let faultCode: string,
|
||||
faultString: string,
|
||||
setParameterValuesFault: SpvFault[],
|
||||
pn: string,
|
||||
fc: string,
|
||||
fs: string;
|
||||
for (const c of xml.children) {
|
||||
switch (c.localName) {
|
||||
case "FaultCode":
|
||||
@@ -617,6 +864,7 @@ function faultStruct(xml: Element): FaultStruct {
|
||||
break;
|
||||
case "SetParameterValuesFault":
|
||||
setParameterValuesFault = setParameterValuesFault || [];
|
||||
pn = fc = fs = null;
|
||||
for (const cc of c.children) {
|
||||
switch (cc.localName) {
|
||||
case "ParameterName":
|
||||
@@ -638,11 +886,27 @@ function faultStruct(xml: Element): FaultStruct {
|
||||
}
|
||||
}
|
||||
|
||||
if (faultCode == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "FaultCode",
|
||||
});
|
||||
faultCode = "";
|
||||
}
|
||||
|
||||
if (faultString == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "FaultString",
|
||||
});
|
||||
faultString = "";
|
||||
}
|
||||
|
||||
return { faultCode, faultString, setParameterValuesFault };
|
||||
}
|
||||
|
||||
function fault(xml: Element): CpeFault {
|
||||
let faultCode, faultString, detail;
|
||||
let faultCode: string, faultString: string, detail: FaultStruct;
|
||||
for (const c of xml.children) {
|
||||
switch (c.localName) {
|
||||
case "faultcode":
|
||||
@@ -657,7 +921,25 @@ function fault(xml: Element): CpeFault {
|
||||
}
|
||||
}
|
||||
|
||||
return { faultCode, faultString, detail };
|
||||
if (!detail) throw new Error("Missing detail element");
|
||||
|
||||
if (faultCode == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "faultcode",
|
||||
});
|
||||
faultCode = "Client";
|
||||
}
|
||||
|
||||
if (faultString == null) {
|
||||
warnings.push({
|
||||
message: "Missing or invalid XML node",
|
||||
element: "faultstring",
|
||||
});
|
||||
faultString = "CWMP fault";
|
||||
}
|
||||
|
||||
return { faultCode, faultString, detail } as CpeFault;
|
||||
}
|
||||
|
||||
export function request(
|
||||
@@ -758,6 +1040,9 @@ export function request(
|
||||
case "TransferComplete":
|
||||
rpc.cpeRequest = TransferComplete(methodElement);
|
||||
break;
|
||||
case "DUStateChangeComplete":
|
||||
rpc.cpeRequest = DUStateChangeComplete(methodElement);
|
||||
break;
|
||||
case "RequestDownload":
|
||||
rpc.cpeRequest = RequestDownload(methodElement);
|
||||
break;
|
||||
@@ -791,6 +1076,9 @@ export function request(
|
||||
case "DownloadResponse":
|
||||
rpc.cpeResponse = DownloadResponse(methodElement);
|
||||
break;
|
||||
case "ChangeDUStateResponse":
|
||||
rpc.cpeResponse = ChangeDUStateResponse();
|
||||
break;
|
||||
case "Fault":
|
||||
rpc.cpeFault = fault(methodElement);
|
||||
break;
|
||||
@@ -844,6 +1132,9 @@ export function response(rpc: {
|
||||
case "TransferCompleteResponse":
|
||||
body = TransferCompleteResponse();
|
||||
break;
|
||||
case "DUStateChangeCompleteResponse":
|
||||
body = DUStateChangeCompleteResponse();
|
||||
break;
|
||||
case "RequestDownloadResponse":
|
||||
body = RequestDownloadResponse();
|
||||
break;
|
||||
@@ -886,6 +1177,9 @@ export function response(rpc: {
|
||||
case "Download":
|
||||
body = Download(rpc.acsRequest);
|
||||
break;
|
||||
case "ChangeDUState":
|
||||
body = ChangeDUState(rpc.acsRequest);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown method request ${(rpc.acsRequest as AcsRequest).name}`
|
||||
|
||||
+68
-7
@@ -121,6 +121,10 @@ export interface SyncState {
|
||||
downloadsToCreate: InstanceSet;
|
||||
downloadsValues: Map<Path, string | number>;
|
||||
downloadsDownload: Map<Path, number>;
|
||||
duStatesToDelete: Set<Path>;
|
||||
duStatesToCreate: InstanceSet;
|
||||
duStatesValues: Map<Path, string | number>;
|
||||
duStatesChangeDUState: Map<Path, number>;
|
||||
reboot: number;
|
||||
factoryReset: number;
|
||||
}
|
||||
@@ -163,6 +167,7 @@ export interface SessionContext {
|
||||
cacheUntil?: number;
|
||||
syncState?: SyncState;
|
||||
lastActivity?: number;
|
||||
extendLock?: number;
|
||||
rpcRequest?: AcsRequest;
|
||||
operationsTouched?: { [commandKey: string]: 1 | 0 };
|
||||
provisionsRet?: any[];
|
||||
@@ -182,12 +187,17 @@ export interface Task {
|
||||
provisions?: (string | number | boolean)[][];
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
interface OperationBase {
|
||||
name: string;
|
||||
timestamp: number;
|
||||
provisions: string[][];
|
||||
channels: { [channel: string]: number };
|
||||
retries: { [channel: string]: number };
|
||||
args: any;
|
||||
}
|
||||
|
||||
export interface DownloadOperation extends OperationBase {
|
||||
name: "Download";
|
||||
args: {
|
||||
instance: string;
|
||||
fileType: string;
|
||||
@@ -196,6 +206,15 @@ export interface Operation {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChangeDUStateOperation extends OperationBase {
|
||||
name: "ChangeDUState";
|
||||
args: {
|
||||
operations: ChangeDUState["operations"];
|
||||
};
|
||||
}
|
||||
|
||||
export type Operation = DownloadOperation | ChangeDUStateOperation;
|
||||
|
||||
export type AcsRequest =
|
||||
| GetParameterNames
|
||||
| GetParameterValues
|
||||
@@ -206,7 +225,8 @@ export type AcsRequest =
|
||||
| DeleteObject
|
||||
| FactoryReset
|
||||
| Reboot
|
||||
| Download;
|
||||
| Download
|
||||
| ChangeDUState;
|
||||
|
||||
export interface GetParameterNames {
|
||||
name: "GetParameterNames";
|
||||
@@ -276,6 +296,21 @@ export interface Download {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ChangeDUState {
|
||||
name: "ChangeDUState";
|
||||
commandKey: string;
|
||||
operations: {
|
||||
operationType: "install" | "update" | "uninstall";
|
||||
instance: string;
|
||||
url: string;
|
||||
uuid: string;
|
||||
version: string;
|
||||
executionEnvRef: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SpvFault {
|
||||
parameterName: string;
|
||||
faultCode: string;
|
||||
@@ -289,8 +324,8 @@ export interface FaultStruct {
|
||||
}
|
||||
|
||||
export interface CpeFault {
|
||||
faultCode: string;
|
||||
faultString: string;
|
||||
faultCode: "Client" | "Server";
|
||||
faultString: "CWMP fault";
|
||||
detail?: FaultStruct;
|
||||
}
|
||||
|
||||
@@ -304,7 +339,8 @@ export type CpeResponse =
|
||||
| DeleteObjectResponse
|
||||
| RebootResponse
|
||||
| FactoryResetResponse
|
||||
| DownloadResponse;
|
||||
| DownloadResponse
|
||||
| ChangeDUStateResponse;
|
||||
|
||||
export interface GetParameterNamesResponse {
|
||||
name: "GetParameterNamesResponse";
|
||||
@@ -341,6 +377,10 @@ export interface DeleteObjectResponse {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface ChangeDUStateResponse {
|
||||
name: "ChangeDUStateResponse";
|
||||
}
|
||||
|
||||
export interface RebootResponse {
|
||||
name: "RebootResponse";
|
||||
}
|
||||
@@ -360,7 +400,8 @@ export type CpeRequest =
|
||||
| InformRequest
|
||||
| TransferCompleteRequest
|
||||
| GetRPCMethodsRequest
|
||||
| RequestDownloadRequest;
|
||||
| RequestDownloadRequest
|
||||
| DUStateChangeCompleteRequest;
|
||||
|
||||
export interface InformRequest {
|
||||
name: "Inform";
|
||||
@@ -383,6 +424,22 @@ export interface TransferCompleteRequest {
|
||||
completeTime?: number;
|
||||
}
|
||||
|
||||
export interface DUStateChangeCompleteRequest {
|
||||
name: "DUStateChangeComplete";
|
||||
commandKey: string;
|
||||
results: {
|
||||
uuid: string;
|
||||
deploymentUnitRef: string;
|
||||
version: string;
|
||||
currentState: string;
|
||||
resolved: boolean;
|
||||
executionUnitRefList: string;
|
||||
fault?: { faultCode: string; faultString: string };
|
||||
startTime?: number;
|
||||
completeTime?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GetRPCMethodsRequest {
|
||||
name: "GetRPCMethods";
|
||||
}
|
||||
@@ -396,7 +453,8 @@ export type AcsResponse =
|
||||
| InformResponse
|
||||
| GetRPCMethodsResponse
|
||||
| TransferCompleteResponse
|
||||
| RequestDownloadResponse;
|
||||
| RequestDownloadResponse
|
||||
| DUStateChangeCompleteResponse;
|
||||
|
||||
export interface InformResponse {
|
||||
name: "InformResponse";
|
||||
@@ -414,6 +472,9 @@ export interface TransferCompleteResponse {
|
||||
export interface RequestDownloadResponse {
|
||||
name: "RequestDownloadResponse";
|
||||
}
|
||||
export interface DUStateChangeCompleteResponse {
|
||||
name: "DUStateChangeCompleteResponse";
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
projection?: any;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Z_SYNC_FLUSH } from "zlib";
|
||||
import { constants } from "zlib";
|
||||
import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
@@ -59,8 +59,12 @@ const getAuthorizer = memoize(
|
||||
}
|
||||
);
|
||||
|
||||
koa.on("error", async (err) => {
|
||||
throw err;
|
||||
koa.on("error", async (err, ctx) => {
|
||||
setTimeout(() => {
|
||||
// Ignored errors resulting from aborted requests
|
||||
if (ctx && ctx.req.aborted) return;
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
koa.use(async (ctx, next) => {
|
||||
@@ -243,7 +247,7 @@ router.get("/", async (ctx) => {
|
||||
)};
|
||||
window.permissionSets = ${JSON.stringify(permissionSets)};
|
||||
</script>
|
||||
<script src="app.js"></script>${wizard}
|
||||
<script type="module" src="app.js"></script>${wizard}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -252,10 +256,15 @@ router.get("/", async (ctx) => {
|
||||
koa.use(
|
||||
koaCompress({
|
||||
gzip: {
|
||||
flush: Z_SYNC_FLUSH,
|
||||
flush: constants.Z_SYNC_FLUSH,
|
||||
},
|
||||
deflate: {
|
||||
flush: Z_SYNC_FLUSH,
|
||||
flush: constants.Z_SYNC_FLUSH,
|
||||
},
|
||||
br: {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
+34
-21
@@ -24,7 +24,8 @@ import { getUsers } from "../local-cache";
|
||||
import { hashPassword } from "../auth";
|
||||
import {
|
||||
insertTasks,
|
||||
watchTask,
|
||||
awaitSessionEnd,
|
||||
awaitSessionStart,
|
||||
connectionRequest,
|
||||
deleteDevice,
|
||||
} from "../api-functions";
|
||||
@@ -104,23 +105,45 @@ export async function postTasks(
|
||||
const statuses = tasks.map((t) => {
|
||||
return { _id: t._id, status: "pending" };
|
||||
});
|
||||
|
||||
await del(`${deviceId}_tasks_faults_operations`);
|
||||
|
||||
try {
|
||||
await connectionRequest(deviceId, device);
|
||||
} catch (err) {
|
||||
const lastInform = Date.now();
|
||||
const notInSession = await awaitSessionEnd(deviceId, 30000);
|
||||
if (!notInSession) {
|
||||
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
|
||||
return {
|
||||
connectionRequest: err.message,
|
||||
connectionRequest: "Session took too long to complete",
|
||||
tasks: statuses,
|
||||
};
|
||||
}
|
||||
|
||||
const sample = tasks[tasks.length - 1];
|
||||
await del(`${deviceId}_tasks_faults_operations`);
|
||||
|
||||
// Waiting for session to finish or timeout
|
||||
await watchTask(deviceId, sample._id, timeout);
|
||||
const status = await connectionRequest(deviceId, device);
|
||||
|
||||
if (status) {
|
||||
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
|
||||
return {
|
||||
connectionRequest: status,
|
||||
tasks: statuses,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionStarted = await awaitSessionStart(deviceId, lastInform, timeout);
|
||||
if (!sessionStarted) {
|
||||
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
|
||||
return {
|
||||
connectionRequest: "No contact from CPE",
|
||||
tasks: statuses,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionEnded = await awaitSessionEnd(deviceId, 120000);
|
||||
if (!sessionEnded) {
|
||||
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
|
||||
return {
|
||||
connectionRequest: "Session took too long to complete",
|
||||
tasks: statuses,
|
||||
};
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const s of statuses) {
|
||||
@@ -145,16 +168,6 @@ export async function postTasks(
|
||||
return { connectionRequest: "OK", tasks: statuses };
|
||||
}
|
||||
|
||||
interface PingResponse {
|
||||
packetsTransmitted: number;
|
||||
packetsReceived: number;
|
||||
packetLoss: string;
|
||||
min: number;
|
||||
avg: number;
|
||||
max: number;
|
||||
mdev: number;
|
||||
}
|
||||
|
||||
export async function putResource(
|
||||
resource: string,
|
||||
id: string,
|
||||
|
||||
+117
-51
@@ -31,6 +31,8 @@ import { del } from "../cache";
|
||||
import Authorizer from "../common/authorizer";
|
||||
import { ping } from "../ping";
|
||||
import * as url from "url";
|
||||
import { decodeTag } from "../common";
|
||||
import { stringify as yamlStringify } from "../common/yaml";
|
||||
|
||||
const router = new Router();
|
||||
export default router;
|
||||
@@ -69,6 +71,10 @@ const resources = {
|
||||
tasks: 0,
|
||||
};
|
||||
|
||||
function singleParam(p: string | string[]): string {
|
||||
return Array.isArray(p) ? p[p.length - 1] : p;
|
||||
}
|
||||
|
||||
router.get(`/devices/:id.csv`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
@@ -85,7 +91,7 @@ router.get(`/devices/:id.csv`, async (ctx) => {
|
||||
|
||||
if (!authorizer.hasAccess("devices", 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void (ctx.status = 404);
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const res = await db.query("devices", filter);
|
||||
@@ -105,19 +111,28 @@ router.get(`/devices/:id.csv`, async (ctx) => {
|
||||
|
||||
for (const k of Object.keys(res[0]).sort()) {
|
||||
const p = res[0][k];
|
||||
let value = "";
|
||||
let type = "";
|
||||
if (p.value) {
|
||||
value = p.value[0];
|
||||
type = p.value[1];
|
||||
if (type === "xsd:dateTime" && typeof value === "number")
|
||||
value = new Date(value).toJSON();
|
||||
}
|
||||
|
||||
const row = [
|
||||
k,
|
||||
p.object,
|
||||
p.objectTimestamp,
|
||||
new Date(p.objectTimestamp).toJSON(),
|
||||
p.writable,
|
||||
p.writableTimestamp,
|
||||
p.value != null ? `"${p.value[0].toString().replace(/"/g, '""')}"` : "",
|
||||
p.value != null ? p.value[1] : "",
|
||||
p.valueTimestamp,
|
||||
new Date(p.writableTimestamp).toJSON(),
|
||||
`"${value.toString().replace(/"/g, '""')}"`,
|
||||
type,
|
||||
new Date(p.valueTimestamp).toJSON(),
|
||||
p.notification,
|
||||
p.notificationTimestamp,
|
||||
new Date(p.notificationTimestamp).toJSON(),
|
||||
p.accessList ? p.accessList.join(", ") : "",
|
||||
p.accessListTimestamp,
|
||||
new Date(p.accessListTimestamp).toJSON(),
|
||||
];
|
||||
ctx.body.write(row.map((r) => (r != null ? r : "")).join(",") + "\n");
|
||||
}
|
||||
@@ -126,11 +141,11 @@ router.get(`/devices/:id.csv`, async (ctx) => {
|
||||
});
|
||||
|
||||
for (const [resource, flags] of Object.entries(resources)) {
|
||||
router.head(`/${resource}`, async (ctx, next) => {
|
||||
router.head(`/${resource}`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
let filter: Expression = authorizer.getFilter(resource, 1);
|
||||
if (ctx.request.query.filter)
|
||||
filter = and(filter, parse(ctx.request.query.filter));
|
||||
filter = and(filter, parse(singleParam(ctx.request.query.filter)));
|
||||
|
||||
const log = {
|
||||
message: `Count ${resource}`,
|
||||
@@ -141,7 +156,7 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
|
||||
if (!authorizer.hasAccess(resource, 1)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
// Exclude temporary tasks and faults
|
||||
@@ -162,18 +177,18 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
logger.accessInfo(log);
|
||||
});
|
||||
|
||||
router.get(`/${resource}`, async (ctx, next) => {
|
||||
router.get(`/${resource}`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const options: QueryOptions = {};
|
||||
let filter: Expression = authorizer.getFilter(resource, 2);
|
||||
if (ctx.request.query.filter)
|
||||
filter = and(filter, parse(ctx.request.query.filter));
|
||||
filter = and(filter, parse(singleParam(ctx.request.query.filter)));
|
||||
if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;
|
||||
if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;
|
||||
if (ctx.request.query.sort)
|
||||
options.sort = JSON.parse(ctx.request.query.sort);
|
||||
options.sort = JSON.parse(singleParam(ctx.request.query.sort));
|
||||
if (ctx.request.query.projection) {
|
||||
options.projection = ctx.request.query.projection
|
||||
options.projection = singleParam(ctx.request.query.projection)
|
||||
.split(",")
|
||||
.reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {});
|
||||
}
|
||||
@@ -190,7 +205,7 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
|
||||
if (!authorizer.hasAccess(resource, 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
// Exclude temporary tasks and faults
|
||||
@@ -216,16 +231,16 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
});
|
||||
|
||||
// CSV download
|
||||
router.get(`/${resource}.csv`, async (ctx, next) => {
|
||||
router.get(`/${resource}.csv`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const options: QueryOptions = { projection: {} };
|
||||
let filter: Expression = authorizer.getFilter(resource, 2);
|
||||
if (ctx.request.query.filter)
|
||||
filter = and(filter, parse(ctx.request.query.filter));
|
||||
filter = and(filter, parse(singleParam(ctx.request.query.filter)));
|
||||
if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;
|
||||
if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;
|
||||
if (ctx.request.query.sort)
|
||||
options.sort = JSON.parse(ctx.request.query.sort);
|
||||
options.sort = JSON.parse(singleParam(ctx.request.query.sort));
|
||||
|
||||
const log = {
|
||||
message: `Query ${resource} (CSV)`,
|
||||
@@ -238,11 +253,11 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
|
||||
if (!authorizer.hasAccess(resource, 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const columns: Record<string, Expression> = JSON.parse(
|
||||
ctx.request.query.columns
|
||||
singleParam(ctx.request.query.columns)
|
||||
);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -280,15 +295,27 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
if (e[1] === "Tags") {
|
||||
const tags = [];
|
||||
for (const p in obj)
|
||||
if (p.startsWith("Tags.")) tags.push(p.slice(5));
|
||||
if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
|
||||
|
||||
return tags.join(", ");
|
||||
}
|
||||
if (e === exp) {
|
||||
const p = obj[e[1]];
|
||||
if (
|
||||
p &&
|
||||
p.value &&
|
||||
p.value[1] === "xsd:dateTime" &&
|
||||
typeof p.value[0] === "number"
|
||||
)
|
||||
return new Date(p.value[0]).toJSON();
|
||||
}
|
||||
} else if (resource === "faults") {
|
||||
if (e[1] === "detail") return yamlStringify(obj["detail"]);
|
||||
}
|
||||
} else if (e[0] === "FUNC") {
|
||||
if (e[1] === "DATE_STRING") {
|
||||
if (e[2] && !Array.isArray(e[2]))
|
||||
return new Date(e[2]).toISOString();
|
||||
return new Date(e[2]).toJSON();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +334,7 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
logger.accessInfo(log);
|
||||
});
|
||||
|
||||
router.head(`/${resource}/:id`, async (ctx, next) => {
|
||||
router.head(`/${resource}/:id`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
message: `Count ${resource}`,
|
||||
@@ -322,18 +349,18 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
]);
|
||||
if (!authorizer.hasAccess(resource, 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const res = await db.query(resource, filter);
|
||||
|
||||
if (!res.length) return void next();
|
||||
if (!res.length) return void (ctx.status = 404);
|
||||
|
||||
logger.accessInfo(log);
|
||||
ctx.body = "";
|
||||
});
|
||||
|
||||
router.get(`/${resource}/:id`, async (ctx, next) => {
|
||||
router.get(`/${resource}/:id`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
message: `Query ${resource}`,
|
||||
@@ -348,19 +375,19 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
]);
|
||||
if (!authorizer.hasAccess(resource, 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const res = await db.query(resource, filter);
|
||||
|
||||
if (!res.length) return void next();
|
||||
if (!res.length) return void (ctx.status = 404);
|
||||
|
||||
logger.accessInfo(log);
|
||||
ctx.body = res[0];
|
||||
});
|
||||
|
||||
if (flags & RESOURCE_DELETE) {
|
||||
router.delete(`/${resource}/:id`, async (ctx, next) => {
|
||||
router.delete(`/${resource}/:id`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
message: `Delete ${resource}`,
|
||||
@@ -375,10 +402,10 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
]);
|
||||
if (!authorizer.hasAccess(resource, 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
const res = await db.query(resource, filter);
|
||||
if (!res.length) return void next();
|
||||
if (!res.length) return void (ctx.status = 404);
|
||||
|
||||
const validate = authorizer.getValidator(resource, res[0]);
|
||||
if (!validate("delete")) {
|
||||
@@ -395,7 +422,7 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
}
|
||||
|
||||
if (flags & RESOURCE_PUT) {
|
||||
router.put(`/${resource}/:id`, async (ctx, next) => {
|
||||
router.put(`/${resource}/:id`, async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const id = ctx.params.id;
|
||||
|
||||
@@ -407,7 +434,7 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
|
||||
if (!authorizer.hasAccess(resource, 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const obj = ctx.request.body;
|
||||
@@ -434,7 +461,37 @@ for (const [resource, flags] of Object.entries(resources)) {
|
||||
}
|
||||
}
|
||||
|
||||
router.put("/files/:id", async (ctx, next) => {
|
||||
router.get("/blob/files/:id", async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const resource = "files";
|
||||
const id = ctx.params.id;
|
||||
|
||||
const log = {
|
||||
message: `Download ${resource}`,
|
||||
context: ctx,
|
||||
id: id,
|
||||
};
|
||||
|
||||
const filter = and(authorizer.getFilter(resource, 2), [
|
||||
"=",
|
||||
["PARAM", RESOURCE_IDS[resource]],
|
||||
ctx.params.id,
|
||||
]);
|
||||
|
||||
if (!authorizer.hasAccess(resource, 2)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const count = await db.count(resource, filter);
|
||||
if (!count) return void (ctx.status = 404);
|
||||
|
||||
logger.accessInfo(log);
|
||||
ctx.body = db.downloadFile(id);
|
||||
ctx.attachment(id);
|
||||
});
|
||||
|
||||
router.put("/files/:id", async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const resource = "files";
|
||||
const id = ctx.params.id;
|
||||
@@ -448,14 +505,15 @@ router.put("/files/:id", async (ctx, next) => {
|
||||
|
||||
if (!authorizer.hasAccess(resource, 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
fileType: ctx.request.headers["metadata.filetype"] || "",
|
||||
oui: ctx.request.headers["metadata.oui"] || "",
|
||||
productClass: ctx.request.headers["metadata.productclass"] || "",
|
||||
version: ctx.request.headers["metadata.version"] || "",
|
||||
fileType: singleParam(ctx.request.headers["metadata.filetype"]) || "",
|
||||
oui: singleParam(ctx.request.headers["metadata.oui"]) || "",
|
||||
productClass:
|
||||
singleParam(ctx.request.headers["metadata.productclass"]) || "",
|
||||
version: singleParam(ctx.request.headers["metadata.version"]) || "",
|
||||
};
|
||||
|
||||
const validate = authorizer.getValidator(resource, metadata);
|
||||
@@ -477,7 +535,7 @@ router.put("/files/:id", async (ctx, next) => {
|
||||
ctx.body = "";
|
||||
});
|
||||
|
||||
router.post("/devices/:id/tasks", async (ctx, next) => {
|
||||
router.post("/devices/:id/tasks", async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
message: "Commit tasks",
|
||||
@@ -493,10 +551,10 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
|
||||
]);
|
||||
if (!authorizer.hasAccess("devices", 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
const devices = await db.query("devices", filter);
|
||||
if (!devices.length) return void next();
|
||||
if (!devices.length) return void (ctx.status = 404);
|
||||
const device = devices[0];
|
||||
|
||||
const validate = authorizer.getValidator("devices", device);
|
||||
@@ -530,6 +588,11 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
|
||||
}
|
||||
) as number;
|
||||
|
||||
const socketTimeout: number = ctx.socket["timeout"];
|
||||
|
||||
// Disable socket timeout while waiting for postTasks()
|
||||
if (socketTimeout) ctx.socket.setTimeout(0);
|
||||
|
||||
const res = await apiFunctions.postTasks(
|
||||
ctx.params.id,
|
||||
ctx.request.body,
|
||||
@@ -537,6 +600,9 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
|
||||
device
|
||||
);
|
||||
|
||||
// Restore socket timeout
|
||||
if (socketTimeout) ctx.socket.setTimeout(socketTimeout);
|
||||
|
||||
log.tasks = res.tasks.map((t) => t._id).join(",");
|
||||
|
||||
logger.accessInfo(log);
|
||||
@@ -545,7 +611,7 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
|
||||
ctx.body = res.tasks;
|
||||
});
|
||||
|
||||
router.post("/devices/:id/tags", async (ctx, next) => {
|
||||
router.post("/devices/:id/tags", async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const log = {
|
||||
message: "Update tags",
|
||||
@@ -561,10 +627,10 @@ router.post("/devices/:id/tags", async (ctx, next) => {
|
||||
]);
|
||||
if (!authorizer.hasAccess("devices", 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
const res = await db.query("devices", filter);
|
||||
if (!res.length) return void next();
|
||||
if (!res.length) return void (ctx.status = 404);
|
||||
|
||||
const validate = authorizer.getValidator("devices", res[0]);
|
||||
if (!validate("tags", ctx.request.body)) {
|
||||
@@ -587,20 +653,20 @@ router.post("/devices/:id/tags", async (ctx, next) => {
|
||||
});
|
||||
|
||||
router.get("/ping/:host", async (ctx) => {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
ping(ctx.params.host, (err, parsed) => {
|
||||
if (parsed) {
|
||||
ctx.body = parsed;
|
||||
} else {
|
||||
ctx.status = 500;
|
||||
ctx.body = `${err.name}: ${err.message}`;
|
||||
ctx.body = err ? `${err.name}: ${err.message}` : "Unknown error";
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.put("/users/:id/password", async (ctx, next) => {
|
||||
router.put("/users/:id/password", async (ctx) => {
|
||||
const authorizer: Authorizer = ctx.state.authorizer;
|
||||
const username = ctx.params.id;
|
||||
const log = {
|
||||
@@ -625,7 +691,7 @@ router.put("/users/:id/password", async (ctx, next) => {
|
||||
}
|
||||
} else if (!authorizer.hasAccess("users", 3)) {
|
||||
logUnauthorizedWarning(log);
|
||||
return void next();
|
||||
return void (ctx.status = 403);
|
||||
}
|
||||
|
||||
const filter = and(authorizer.getFilter("users", 3), [
|
||||
@@ -634,7 +700,7 @@ router.put("/users/:id/password", async (ctx, next) => {
|
||||
username,
|
||||
]);
|
||||
const res = await db.query("users", filter);
|
||||
if (!res.length) return void next();
|
||||
if (!res.length) return void (ctx.status = 404);
|
||||
|
||||
const newPassword = ctx.request.body.newPassword;
|
||||
if (ctx.state.user) {
|
||||
|
||||
+166
-249
@@ -17,92 +17,42 @@
|
||||
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MongoClient, GridFSBucket, ObjectID } from "mongodb";
|
||||
import { Db, GridFSBucket, ObjectID } from "mongodb";
|
||||
import { Script } from "vm";
|
||||
import * as config from "../config";
|
||||
import { onConnect } from "../db";
|
||||
import * as mongodbFunctions from "../mongodb-functions";
|
||||
import * as expression from "../common/expression";
|
||||
import { QueryOptions, Expression } from "../types";
|
||||
import { Readable } from "stream";
|
||||
import { minimize } from "../common/boolean-expression";
|
||||
|
||||
const CACHE_TTL = 300000;
|
||||
|
||||
let clientPromise: Promise<MongoClient>;
|
||||
|
||||
const RESOURCE_COLLECTION = {
|
||||
files: "fs.files",
|
||||
};
|
||||
|
||||
function ensureIndexes(client): void {
|
||||
client
|
||||
.db()
|
||||
.collection("cache")
|
||||
.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
|
||||
}
|
||||
let db: Db;
|
||||
|
||||
function getClient(): Promise<MongoClient> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = new Promise((resolve, reject) => {
|
||||
const CONNECTION_URL = "" + config.get("MONGODB_CONNECTION_URL");
|
||||
MongoClient.connect(
|
||||
CONNECTION_URL,
|
||||
{ useNewUrlParser: true, useUnifiedTopology: true },
|
||||
(err, client) => {
|
||||
if (err) return void reject(err);
|
||||
ensureIndexes(client);
|
||||
resolve(client);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
export function cache<T>(
|
||||
key: string,
|
||||
valueGetter: () => Promise<T>,
|
||||
ttl: number
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client.db().collection("cache");
|
||||
collection.findOne({ _id: key }, (err, doc) => {
|
||||
if (err) return void reject(err);
|
||||
if (doc != null) return void resolve(JSON.parse(doc.value) as T);
|
||||
valueGetter()
|
||||
.then((res) => {
|
||||
const expire = Date.now() + (ttl || CACHE_TTL);
|
||||
const cacheDoc = {
|
||||
_id: key,
|
||||
value: JSON.stringify(res),
|
||||
expire: new Date(expire),
|
||||
};
|
||||
collection.updateOne(
|
||||
{ _id: key },
|
||||
{ $set: cacheDoc },
|
||||
{ upsert: true },
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(res);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
onConnect(async (_db) => {
|
||||
db = _db;
|
||||
});
|
||||
|
||||
export function query(
|
||||
resource: string,
|
||||
filter: Expression,
|
||||
options?: QueryOptions
|
||||
): Promise<any[]>;
|
||||
export function query(
|
||||
resource: string,
|
||||
filter: Expression,
|
||||
options: QueryOptions,
|
||||
callback: (doc: any) => void
|
||||
): Promise<void>;
|
||||
export function query(
|
||||
resource: string,
|
||||
filter: Expression,
|
||||
options?: QueryOptions,
|
||||
callback?: (doc: any) => void
|
||||
): Promise<any[]> {
|
||||
): Promise<void | any[]> {
|
||||
options = options || {};
|
||||
let q;
|
||||
filter = expression.evaluate(filter, null, Date.now());
|
||||
@@ -123,85 +73,78 @@ export function query(
|
||||
}
|
||||
|
||||
q = mongodbFunctions.filterToMongoQuery(filter);
|
||||
} else if (filter != null && !filter) {
|
||||
} else if (!filter) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client
|
||||
.db()
|
||||
.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
const cursor = collection.find(q);
|
||||
if (options.projection) {
|
||||
let projection = options.projection;
|
||||
if (resource === "devices") {
|
||||
projection = mongodbFunctions.processDeviceProjection(
|
||||
options.projection
|
||||
);
|
||||
}
|
||||
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
const cursor = collection.find(q);
|
||||
if (options.projection) {
|
||||
let projection = options.projection;
|
||||
if (resource === "devices") {
|
||||
projection = mongodbFunctions.processDeviceProjection(
|
||||
options.projection
|
||||
);
|
||||
}
|
||||
|
||||
if (resource === "presets") projection.configurations = 1;
|
||||
cursor.project(projection);
|
||||
if (resource === "presets") projection.configurations = 1;
|
||||
cursor.project(projection);
|
||||
}
|
||||
|
||||
if (resource === "users") cursor.project({ password: 0, salt: 0 });
|
||||
|
||||
if (options.skip) cursor.skip(options.skip);
|
||||
if (options.limit) cursor.limit(options.limit);
|
||||
|
||||
if (options.sort) {
|
||||
let s = Object.entries(options.sort)
|
||||
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||
.reduce(
|
||||
(obj, [k, v]) =>
|
||||
Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }),
|
||||
{}
|
||||
);
|
||||
|
||||
if (resource === "devices") s = mongodbFunctions.processDeviceSort(s);
|
||||
cursor.sort(s);
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
cursor.toArray((err, docs) => {
|
||||
if (err) return reject(err);
|
||||
if (resource === "devices")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenDevice(d));
|
||||
else if (resource === "faults")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenFault(d));
|
||||
else if (resource === "tasks")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenTask(d));
|
||||
else if (resource === "presets")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenPreset(d));
|
||||
else if (resource === "files")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenFile(d));
|
||||
return resolve(docs);
|
||||
});
|
||||
} else {
|
||||
cursor.forEach(
|
||||
(doc) => {
|
||||
if (resource === "devices") doc = mongodbFunctions.flattenDevice(doc);
|
||||
else if (resource === "faults")
|
||||
doc = mongodbFunctions.flattenFault(doc);
|
||||
else if (resource === "tasks")
|
||||
doc = mongodbFunctions.flattenTask(doc);
|
||||
else if (resource === "presets")
|
||||
doc = mongodbFunctions.flattenPreset(doc);
|
||||
else if (resource === "files")
|
||||
doc = mongodbFunctions.flattenFile(doc);
|
||||
callback(doc);
|
||||
},
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
|
||||
if (resource === "users") cursor.project({ password: 0, salt: 0 });
|
||||
|
||||
if (options.skip) cursor.skip(options.skip);
|
||||
if (options.limit) cursor.limit(options.limit);
|
||||
|
||||
if (options.sort) {
|
||||
let s = Object.entries(options.sort)
|
||||
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||
.reduce(
|
||||
(obj, [k, v]) =>
|
||||
Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }),
|
||||
{}
|
||||
);
|
||||
|
||||
if (resource === "devices") s = mongodbFunctions.processDeviceSort(s);
|
||||
cursor.sort(s);
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
cursor.toArray((err, docs) => {
|
||||
if (err) return reject(err);
|
||||
if (resource === "devices")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenDevice(d));
|
||||
else if (resource === "faults")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenFault(d));
|
||||
else if (resource === "tasks")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenTask(d));
|
||||
else if (resource === "presets")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenPreset(d));
|
||||
else if (resource === "files")
|
||||
docs = docs.map((d) => mongodbFunctions.flattenFile(d));
|
||||
return resolve(docs);
|
||||
});
|
||||
} else {
|
||||
cursor.forEach(
|
||||
(doc) => {
|
||||
if (resource === "devices")
|
||||
doc = mongodbFunctions.flattenDevice(doc);
|
||||
else if (resource === "faults")
|
||||
doc = mongodbFunctions.flattenFault(doc);
|
||||
else if (resource === "tasks")
|
||||
doc = mongodbFunctions.flattenTask(doc);
|
||||
else if (resource === "presets")
|
||||
doc = mongodbFunctions.flattenPreset(doc);
|
||||
else if (resource === "files")
|
||||
doc = mongodbFunctions.flattenFile(doc);
|
||||
callback(doc);
|
||||
},
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,73 +161,48 @@ export function count(resource: string, filter: Expression): Promise<number> {
|
||||
else if (resource === "faults")
|
||||
filter = mongodbFunctions.processFaultsFilter(filter);
|
||||
q = mongodbFunctions.filterToMongoQuery(filter);
|
||||
} else if (filter != null && !filter) {
|
||||
} else if (!filter) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client
|
||||
.db()
|
||||
.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.find(q).count((err, c) => {
|
||||
if (err) reject(err);
|
||||
else resolve(c);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.find(q).count((err, c) => {
|
||||
if (err) reject(err);
|
||||
else resolve(c);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDeviceTags(
|
||||
export async function updateDeviceTags(
|
||||
deviceId: string,
|
||||
tags: Record<string, boolean>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const add = [];
|
||||
const pull = [];
|
||||
const add = [];
|
||||
const pull = [];
|
||||
|
||||
const regex = /^[0-9a-zA-Z_]+$/;
|
||||
for (let [tag, onOff] of Object.entries(tags)) {
|
||||
tag = tag.trim();
|
||||
if (onOff) {
|
||||
if (!tag.match(regex))
|
||||
return void reject(new Error(`Invalid tag '${tag}'`));
|
||||
add.push(tag);
|
||||
} else {
|
||||
pull.push(tag);
|
||||
}
|
||||
}
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client.db().collection("devices");
|
||||
for (let [tag, onOff] of Object.entries(tags)) {
|
||||
tag = tag.trim();
|
||||
if (onOff) add.push(tag);
|
||||
else pull.push(tag);
|
||||
}
|
||||
|
||||
const object = {};
|
||||
if (add && add.length) object["$addToSet"] = { _tags: { $each: add } };
|
||||
if (pull && pull.length) object["$pullAll"] = { _tags: pull };
|
||||
collection.updateOne({ _id: deviceId }, object, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
const collection = db.collection("devices");
|
||||
const object = {};
|
||||
|
||||
if (add && add.length) object["$addToSet"] = { _tags: { $each: add } };
|
||||
if (pull && pull.length) object["$pullAll"] = { _tags: pull };
|
||||
|
||||
await collection.updateOne({ _id: deviceId }, object);
|
||||
}
|
||||
|
||||
function putResource(resource, id, object): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client
|
||||
.db()
|
||||
.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.replaceOne({ _id: id }, object, { upsert: true }, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.replaceOne({ _id: id }, object, { upsert: true }, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,17 +211,11 @@ function deleteResource(
|
||||
id: string | ObjectID
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client
|
||||
.db()
|
||||
.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.deleteOne({ _id: id }, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
|
||||
collection.deleteOne({ _id: id }, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,9 +237,17 @@ export function putProvision(
|
||||
): Promise<void> {
|
||||
if (!object.script) object.script = "";
|
||||
try {
|
||||
new Script(`"use strict";(function(){\n${object.script}\n})();`);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
new Script(`"use strict";(function(){\n${object.script}\n})();`, {
|
||||
filename: id,
|
||||
lineOffset: -1,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.stack && err.stack.startsWith(`${id}:`)) {
|
||||
return Promise.reject(
|
||||
new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`)
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return putResource("provisions", id, object);
|
||||
}
|
||||
@@ -342,9 +262,17 @@ export function putVirtualParameter(
|
||||
): Promise<void> {
|
||||
if (!object.script) object.script = "";
|
||||
try {
|
||||
new Script(`"use strict";(function(){\n${object.script}\n})();`);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
new Script(`"use strict";(function(){\n${object.script}\n})();`, {
|
||||
filename: id,
|
||||
lineOffset: -1,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.stack && err.stack.startsWith(`${id}:`)) {
|
||||
return Promise.reject(
|
||||
new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`)
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return putResource("virtualParameters", id, object);
|
||||
}
|
||||
@@ -380,21 +308,17 @@ export function putUser(
|
||||
object: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const collection = client.db().collection("users");
|
||||
// update instead of replace to keep the password if not set by user
|
||||
collection.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: object },
|
||||
{ upsert: true },
|
||||
(err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
const collection = db.collection("users");
|
||||
// update instead of replace to keep the password if not set by user
|
||||
collection.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: object },
|
||||
{ upsert: true },
|
||||
(err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,38 +326,35 @@ export function deleteUser(id: string): Promise<void> {
|
||||
return deleteResource("users", id);
|
||||
}
|
||||
|
||||
export function downloadFile(filename: string): Readable {
|
||||
const bucket = new GridFSBucket(db);
|
||||
return bucket.openDownloadStreamByName(filename);
|
||||
}
|
||||
|
||||
export function putFile(
|
||||
filename: string,
|
||||
metadata: Record<string, string>,
|
||||
contentStream: Readable
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const bucket = new GridFSBucket(client.db());
|
||||
const uploadStream = bucket.openUploadStreamWithId(filename, filename, {
|
||||
metadata: metadata,
|
||||
});
|
||||
uploadStream.on("error", reject);
|
||||
contentStream.on("error", reject);
|
||||
uploadStream.on("finish", resolve);
|
||||
contentStream.pipe(uploadStream);
|
||||
})
|
||||
.catch(reject);
|
||||
const bucket = new GridFSBucket(db);
|
||||
const uploadStream = bucket.openUploadStreamWithId(filename, filename, {
|
||||
metadata: metadata,
|
||||
});
|
||||
uploadStream.on("error", reject);
|
||||
contentStream.on("error", reject);
|
||||
uploadStream.on("finish", resolve);
|
||||
contentStream.pipe(uploadStream);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFile(filename: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const bucket = new GridFSBucket(client.db());
|
||||
bucket.delete(filename as any, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
const bucket = new GridFSBucket(db);
|
||||
bucket.delete(filename as any, (err) => {
|
||||
if (err) return void reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -444,7 +365,3 @@ export function deleteFault(id: string): Promise<void> {
|
||||
export function deleteTask(id: ObjectID): Promise<void> {
|
||||
return deleteResource("tasks", id);
|
||||
}
|
||||
|
||||
export async function disconnect(): Promise<void> {
|
||||
if (clientPromise) await (await clientPromise).close();
|
||||
}
|
||||
+6
-3
@@ -53,12 +53,13 @@ export interface Element {
|
||||
}
|
||||
|
||||
export function parseXmlDeclaration(buffer: Buffer): Attribute[] {
|
||||
for (const enc of ["utf16le", "utf8", "latin1", "ascii"]) {
|
||||
const encodings: BufferEncoding[] = ["utf16le", "utf8", "latin1", "ascii"];
|
||||
for (const enc of encodings) {
|
||||
let str = buffer.toString(enc, 0, 150);
|
||||
if (str.startsWith("<?xml")) {
|
||||
str = str.split("\n")[0].trim();
|
||||
str = str.slice(0, str.indexOf("?>"));
|
||||
try {
|
||||
return parseAttrs(str.slice(5, -2));
|
||||
return parseAttrs(str.slice(5));
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
@@ -103,10 +104,12 @@ export function parseAttrs(string: string): Attribute[] {
|
||||
continue;
|
||||
|
||||
case CHAR_COLON:
|
||||
if (state) continue;
|
||||
if (idx >= colonIdx) colonIdx = i;
|
||||
continue;
|
||||
|
||||
case CHAR_EQUAL:
|
||||
if (state) continue;
|
||||
if (name) throw new Error(`Unexpected character at ${i}`);
|
||||
name = string.slice(idx, i).trim();
|
||||
// TODO validate name
|
||||
|
||||
Generated
+6596
-4625
File diff suppressed because it is too large.
Load diff
+35
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "genieacs",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.5",
|
||||
"description": "A TR-069 Auto Configuration Server (ACS)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -14,72 +14,69 @@
|
||||
],
|
||||
"author": "Zaid Abdulla <zaid@genieacs.com>",
|
||||
"license": "AGPL-3.0",
|
||||
"preferGlobal": true,
|
||||
"private": true,
|
||||
"directories": {
|
||||
"bin": "bin",
|
||||
"tools": "tools"
|
||||
"bin": "bin"
|
||||
},
|
||||
"dependencies": {
|
||||
"espresso-iisojs": "^1.0.3",
|
||||
"iconv-lite": "^0.6.2",
|
||||
"ipaddr.js": "^2.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"koa": "^2.13.0",
|
||||
"koa": "^2.13.1",
|
||||
"koa-bodyparser": "^4.3.0",
|
||||
"koa-compress": "^5.0.1",
|
||||
"koa-jwt": "^4.0.0",
|
||||
"koa-router": "^9.4.0",
|
||||
"koa-router": "^10.0.0",
|
||||
"koa-static": "^5.0.0",
|
||||
"later": "^1.2.0",
|
||||
"mongodb": "^3.6.1",
|
||||
"mongodb": "^3.6.3",
|
||||
"parsimmon": "^1.16.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"yaml": "^1.10.0"
|
||||
"seedrandom": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^15.0.0",
|
||||
"@rollup/plugin-commonjs": "^17.1.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
"@rollup/plugin-typescript": "^5.0.2",
|
||||
"@rollup/plugin-node-resolve": "^11.2.0",
|
||||
"@rollup/plugin-typescript": "^8.2.0",
|
||||
"@types/codemirror": "0.0.108",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/koa": "^2.11.4",
|
||||
"@types/koa-compress": "^4.0.0",
|
||||
"@types/koa": "^2.13.1",
|
||||
"@types/koa-compress": "^4.0.1",
|
||||
"@types/koa-router": "^7.4.1",
|
||||
"@types/koa-static": "^4.0.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"@types/mongodb": "^3.5.27",
|
||||
"@types/parsimmon": "^1.10.2",
|
||||
"@types/seedrandom": "^2.4.28",
|
||||
"@types/svgo": "^1.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^3.10.1",
|
||||
"@typescript-eslint/parser": "^3.10.1",
|
||||
"ava": "^3.12.1",
|
||||
"codemirror": "^5.57.0",
|
||||
"@types/mithril": "^2.0.6",
|
||||
"@types/mongodb": "^3.6.9",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/parsimmon": "^1.10.6",
|
||||
"@types/seedrandom": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"ava": "^3.15.0",
|
||||
"codemirror": "^5.59.4",
|
||||
"cssnano": "^4.1.10",
|
||||
"eslint": "^7.8.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint": "^7.22.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"esm": "^3.2.25",
|
||||
"jsbi": "^3.1.3",
|
||||
"mithril": "^2.0.4",
|
||||
"postcss": "^7.0.32",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-import": "^14.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.1.1",
|
||||
"rollup": "^2.26.9",
|
||||
"rollup-plugin-terser": "^7.0.1",
|
||||
"source-map-loader": "^1.1.0",
|
||||
"svgo": "^1.3.2",
|
||||
"ts-node": "^9.0.0",
|
||||
"typescript": "^4.0.2",
|
||||
"webpack": "^4.44.1"
|
||||
"prettier": "^2.2.1",
|
||||
"rollup": "^2.41.2",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"svgo": "^2.2.2",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.3",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "ava --verbose --color",
|
||||
"lint-eslint": "eslint --fix \"bin/*.ts\" \"lib/**/*.ts\" \"ui/**/*.ts\" \"test/**/*.ts\" \"build/**/*.ts\" \"tools/*.ts\"",
|
||||
"lint-prettier": "prettier --parser typescript --write \"bin/**/*.ts\" \"lib/**/*.ts\" \"ui/**/*.ts\" \"test/**/*.ts\" \"build/**/*.ts\" \"tools/**/*.ts\"",
|
||||
"lint-eslint": "eslint --fix \"bin/*.ts\" \"lib/**/*.ts\" \"ui/**/*.ts\" \"test/**/*.ts\" \"build/**/*.ts\"",
|
||||
"lint-prettier": "prettier --prose-wrap always --write .",
|
||||
"lint-tsc": "tsc --noEmit",
|
||||
"lint": "npm run lint-eslint && npm run lint-prettier && npm run lint-tsc",
|
||||
"build": "node -r esm -r ts-node/register/transpile-only build/build"
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import ava from "ava";
|
||||
import Path from "../lib/common/path";
|
||||
|
||||
ava("parse", (t) => {
|
||||
t.throws(() => Path.parse("."));
|
||||
t.throws(() => Path.parse("a "));
|
||||
t.throws(() => Path.parse(".a"));
|
||||
t.throws(() => Path.parse("a."));
|
||||
t.throws(() => Path.parse("a..b"));
|
||||
t.throws(() => Path.parse("b*"));
|
||||
t.throws(() => Path.parse("*b"));
|
||||
t.throws(() => Path.parse("a.b c.d"));
|
||||
t.throws(() => Path.parse("a["));
|
||||
t.throws(() => Path.parse("a[b"));
|
||||
t.throws(() => Path.parse("a[b:"));
|
||||
t.throws(() => Path.parse('a[b:"waef]'));
|
||||
t.notThrows(() => Path.parse("[]"));
|
||||
t.notThrows(() => Path.parse("[a:]"));
|
||||
t.notThrows(() => Path.parse("*"));
|
||||
t.notThrows(() => Path.parse(""));
|
||||
});
|
||||
|
||||
ava("toString", (t) => {
|
||||
const path1 = Path.parse('abc.[ abc : 123 , 123 : abc , 123: " abc "].123');
|
||||
const path2 = Path.parse('abc.[123: " abc ",abc:123,123:abc].123');
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"name": "Mark McGwire",
|
||||
"hr": 65,
|
||||
"avg": 0.278
|
||||
},
|
||||
{
|
||||
"name": "Sammy Sosa",
|
||||
"hr": 63,
|
||||
"avg": 0.288
|
||||
}
|
||||
],
|
||||
{
|
||||
"top1": {
|
||||
"key1": "scalar1"
|
||||
},
|
||||
"top2": {
|
||||
"key2": "scalar2"
|
||||
},
|
||||
"top3": {
|
||||
"scalar1": "scalar3"
|
||||
},
|
||||
"top4": {
|
||||
"scalar2": "scalar4"
|
||||
},
|
||||
"top5": "scalar5",
|
||||
"top6": {
|
||||
"key6": "scalar6"
|
||||
}
|
||||
},
|
||||
"text",
|
||||
["a", "b", 42, "d"],
|
||||
{
|
||||
"a!\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~": "safe",
|
||||
"?foo": "safe question mark",
|
||||
":foo": "safe colon",
|
||||
"-foo": "safe dash",
|
||||
"this is#not": "a comment"
|
||||
},
|
||||
{
|
||||
"": "a"
|
||||
},
|
||||
"foo",
|
||||
{
|
||||
"key": "value",
|
||||
"foo": "key"
|
||||
},
|
||||
[1, -2, 33],
|
||||
{
|
||||
"plain": "a b\nc"
|
||||
},
|
||||
[["s1_i1", "s1_i2"], "s2"],
|
||||
{
|
||||
"First occurrence": "Foo",
|
||||
"Second occurrence": "Foo",
|
||||
"Override anchor": "Bar",
|
||||
"Reuse anchor": "Bar"
|
||||
},
|
||||
"k:#foo &a !t s",
|
||||
["a"],
|
||||
{
|
||||
"escaped slash": "a/b"
|
||||
},
|
||||
{
|
||||
"plain": "This unquoted scalar spans many lines.",
|
||||
"quoted": "So does this quoted scalar.\n"
|
||||
},
|
||||
"here's to \"quotes\"",
|
||||
{
|
||||
"foo": "bar"
|
||||
},
|
||||
"ab cd\nef\n\ngh\n",
|
||||
["detected\n", "\n\n# detected\n", " explicit\n", "detected\n"],
|
||||
"foo: bar\": baz",
|
||||
"plain\\value\\with\\backslashes",
|
||||
{
|
||||
"plain": "text lines",
|
||||
"quoted": "text lines",
|
||||
"block": "text\n \tlines\n"
|
||||
},
|
||||
"a",
|
||||
{
|
||||
"foo": "you",
|
||||
"bar": "far"
|
||||
},
|
||||
{
|
||||
"sequence": ["entry", ["nested"]],
|
||||
"mapping": {
|
||||
"foo": "bar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"literal": "some\ntext\n",
|
||||
"folded": "some text\n"
|
||||
},
|
||||
[
|
||||
{
|
||||
"one": "two",
|
||||
"three": "four"
|
||||
},
|
||||
{
|
||||
"five": "six",
|
||||
"seven": "eight"
|
||||
}
|
||||
],
|
||||
{
|
||||
"Folding": "Empty line\nas a line feed",
|
||||
"Chomping": "Clipped empty lines\n"
|
||||
},
|
||||
[
|
||||
["one", "two"],
|
||||
["three", "four"]
|
||||
],
|
||||
{
|
||||
"foo": "bar"
|
||||
},
|
||||
{
|
||||
"key": "value"
|
||||
},
|
||||
{
|
||||
"explicit key": null,
|
||||
"block key\n": ["one", "two"]
|
||||
},
|
||||
["foo"],
|
||||
[
|
||||
{
|
||||
"foo": "bar"
|
||||
},
|
||||
["baz", "baz"]
|
||||
],
|
||||
"ab\n\n \n",
|
||||
{
|
||||
"foo: bar\\": "baz'"
|
||||
},
|
||||
{
|
||||
"Not indented": {
|
||||
"By one space": "By four\n spaces\n",
|
||||
"Flow style": ["By two", "Also by two", "Still by two"]
|
||||
}
|
||||
},
|
||||
"\\//||\\/||\n// || ||__\n",
|
||||
{
|
||||
"foo": [
|
||||
"a",
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"a": null,
|
||||
"b": null
|
||||
},
|
||||
"foo",
|
||||
{
|
||||
"a": "b",
|
||||
"": "a"
|
||||
},
|
||||
{
|
||||
"foo\nbar:baz\tx \\$%^&*()x": 23,
|
||||
"x\\ny:z\\tx $%^&*()x": 24
|
||||
},
|
||||
"Sammy Sosa completed another fine season with great stats.\n\n 63 Home Runs\n 0.288 Batting Average\n\nWhat a year!\n",
|
||||
" foo\nbar\nbaz ",
|
||||
{ "matches %": 20 },
|
||||
[
|
||||
"flow in block",
|
||||
"Block scalar\n",
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
],
|
||||
{
|
||||
"a": "b",
|
||||
"c": 42,
|
||||
"e": "f",
|
||||
"g": "h",
|
||||
"23": false
|
||||
},
|
||||
"ab",
|
||||
" 1st non-empty\n2nd non-empty 3rd non-empty ",
|
||||
{
|
||||
"top1": {
|
||||
"key1": "one"
|
||||
},
|
||||
"top2": {
|
||||
"key2": "two"
|
||||
},
|
||||
"top3": {
|
||||
"key3": "three"
|
||||
},
|
||||
"top4": {
|
||||
"key4": "four"
|
||||
},
|
||||
"top5": {
|
||||
"key5": "five"
|
||||
},
|
||||
"top6": "six",
|
||||
"top7": "seven"
|
||||
},
|
||||
{
|
||||
"hr": ["Mark McGwire", "Sammy Sosa"],
|
||||
"rbi": ["Sammy Sosa", "Ken Griffey"]
|
||||
},
|
||||
"\nfolded line\nnext line\n * bullet\n\n * list\n * lines\n\nlast line\n",
|
||||
["word1", "word2"],
|
||||
{
|
||||
"a": null,
|
||||
"b": null,
|
||||
"c": null
|
||||
},
|
||||
{
|
||||
"nested sequences": [[[[]]], [[{}]]],
|
||||
"key1": [],
|
||||
"key2": {}
|
||||
},
|
||||
"---word1 word2",
|
||||
{
|
||||
"implicit block key": [
|
||||
{
|
||||
"implicit flow key": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key ends with two colons::": "value"
|
||||
},
|
||||
[
|
||||
{
|
||||
"single line": null,
|
||||
"a": "b"
|
||||
},
|
||||
{
|
||||
"multi line": null,
|
||||
"a": "b"
|
||||
}
|
||||
],
|
||||
"a",
|
||||
{
|
||||
"key": ["item1", "item2"]
|
||||
},
|
||||
[
|
||||
"double quoted",
|
||||
"single quoted",
|
||||
"plain text",
|
||||
["nested"],
|
||||
{
|
||||
"single": "pair"
|
||||
}
|
||||
],
|
||||
["unicode anchor"],
|
||||
[
|
||||
{
|
||||
"key": "value",
|
||||
"key2": "value2"
|
||||
},
|
||||
{
|
||||
"key3": "value3"
|
||||
}
|
||||
],
|
||||
"trimmed\n\n\nas space",
|
||||
"Mark McGwire's year was crippled by a knee injury.\n",
|
||||
[
|
||||
{
|
||||
"single line": null,
|
||||
"a": "b"
|
||||
},
|
||||
{
|
||||
"multi line": null,
|
||||
"a": "b"
|
||||
}
|
||||
],
|
||||
{
|
||||
"a": {
|
||||
"b": {
|
||||
"c": "d"
|
||||
},
|
||||
"e": {
|
||||
"f": "g"
|
||||
}
|
||||
},
|
||||
"h": "i"
|
||||
},
|
||||
{
|
||||
"foo": {
|
||||
"bar": "baz"
|
||||
}
|
||||
},
|
||||
[
|
||||
{
|
||||
"single line": "value"
|
||||
},
|
||||
{
|
||||
"multi line": "value"
|
||||
}
|
||||
],
|
||||
{
|
||||
"single": "text",
|
||||
"double": "text"
|
||||
},
|
||||
" 1st non-empty\n2nd non-empty 3rd non-empty ",
|
||||
[
|
||||
{
|
||||
"item": "Super Hoop",
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"item": "Basketball",
|
||||
"quantity": 4
|
||||
},
|
||||
{
|
||||
"item": "Big Shoes",
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"a b c d\ne",
|
||||
{
|
||||
"a": ["b", ["c", "d"]]
|
||||
},
|
||||
{
|
||||
"strip": "text",
|
||||
"clip": "text\n",
|
||||
"keep": "text\n"
|
||||
},
|
||||
{
|
||||
"a": "b c",
|
||||
"d": "e f"
|
||||
},
|
||||
["single multiline - sequence entry"],
|
||||
{
|
||||
"1": [2, 3],
|
||||
"4": 5
|
||||
},
|
||||
[
|
||||
{
|
||||
"bla\"keks": "foo"
|
||||
},
|
||||
{
|
||||
"bla]keks": "foo"
|
||||
}
|
||||
],
|
||||
"folded text\n",
|
||||
"foo",
|
||||
{
|
||||
"key": {
|
||||
"a": "b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"adjacent": "value",
|
||||
"readable": "value",
|
||||
"empty": null
|
||||
},
|
||||
[
|
||||
{
|
||||
"a": "b"
|
||||
},
|
||||
{
|
||||
"c": "d"
|
||||
},
|
||||
{
|
||||
"e": "f"
|
||||
},
|
||||
{
|
||||
"g": "h"
|
||||
}
|
||||
],
|
||||
{
|
||||
"tab": "\tstring"
|
||||
},
|
||||
[
|
||||
{
|
||||
"foo bar": "baz"
|
||||
}
|
||||
],
|
||||
{
|
||||
"anchored": "value",
|
||||
"alias": "value"
|
||||
},
|
||||
["explicit indent and chomp", "chomp and explicit indent"],
|
||||
{
|
||||
"a": ["b", "c"]
|
||||
},
|
||||
{
|
||||
"foo": "bar"
|
||||
},
|
||||
[
|
||||
"::vector",
|
||||
": - ()",
|
||||
"Up, up, and away!",
|
||||
-123,
|
||||
"http://example.com/foo#bar",
|
||||
[
|
||||
"::vector",
|
||||
": - ()",
|
||||
"Up, up and away!",
|
||||
-123,
|
||||
"http://example.com/foo#bar"
|
||||
]
|
||||
],
|
||||
{
|
||||
"a": "b",
|
||||
"seq": ["a"],
|
||||
"c": "d"
|
||||
},
|
||||
["foo", "bar", 42],
|
||||
"line1 # no comment line3\n",
|
||||
"\n\nliteral\n \n\ntext\n",
|
||||
{
|
||||
"a": "b"
|
||||
},
|
||||
{
|
||||
"k": ["a", "b"]
|
||||
},
|
||||
"a b c d\ne",
|
||||
"---word1 word2",
|
||||
["a", 2, 4, "d"],
|
||||
{
|
||||
"a": [
|
||||
"b",
|
||||
"c",
|
||||
{
|
||||
"d": ["e", "f"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"a": " more indented\nregular\n",
|
||||
"b": "\n\n more indented\nregular\n"
|
||||
},
|
||||
{
|
||||
"strip": "# text",
|
||||
"clip": "# text\n",
|
||||
"keep": "# text\n\n"
|
||||
},
|
||||
{
|
||||
"safe": "a!\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~ !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~",
|
||||
"safe question mark": "?foo",
|
||||
"safe colon": ":foo",
|
||||
"safe dash": "-foo"
|
||||
},
|
||||
"line1 line2 line3\n",
|
||||
["Mark McGwire", "Sammy Sosa", "Ken Griffey"],
|
||||
["a"],
|
||||
["a", ["b", "c"]],
|
||||
{
|
||||
"unicode": "Sosa did fine.☺",
|
||||
"control": "\b1998\t1999\t2000\n",
|
||||
"hex esc": "\r\n is \r\n",
|
||||
"single": "\"Howdy!\" he cried.",
|
||||
"quoted": " # Not a 'comment'.",
|
||||
"tie-fighter": "|\\-*-/|"
|
||||
},
|
||||
[["-", "-"]],
|
||||
"folded text\n",
|
||||
{
|
||||
"a": 13,
|
||||
"1.5": "d"
|
||||
},
|
||||
{
|
||||
"foo": 1,
|
||||
"bar": 2,
|
||||
"text": "a\n \nb\n\nc\n\nd\n"
|
||||
},
|
||||
{
|
||||
"wanted": "love ♥ and peace ☮"
|
||||
},
|
||||
{
|
||||
"name": "Mark McGwire",
|
||||
"accomplishment": "Mark set a major league home run record in 1998.\n",
|
||||
"stats": "65 Home Runs\n0.278 Batting Average\n"
|
||||
},
|
||||
{
|
||||
"foo": "bar",
|
||||
"baz": "foo"
|
||||
},
|
||||
"1st non-empty\n2nd non-empty 3rd non-empty",
|
||||
{
|
||||
"quoted": "Quoted \t",
|
||||
"block": "void main() {\n\tprintf(\"Hello, world!\\n\");\n}\n"
|
||||
},
|
||||
{
|
||||
"foo": "blue",
|
||||
"bar": "arrr",
|
||||
"baz": "jazz"
|
||||
},
|
||||
[
|
||||
{
|
||||
"Mark McGwire": 65
|
||||
},
|
||||
{
|
||||
"Sammy Sosa": 63
|
||||
},
|
||||
{
|
||||
"Ken Griffy": 58
|
||||
}
|
||||
],
|
||||
{
|
||||
"1": 2,
|
||||
"3": 4
|
||||
},
|
||||
{
|
||||
"hr": ["Mark McGwire", "Sammy Sosa"],
|
||||
"rbi": ["Sammy Sosa", "Ken Griffey"]
|
||||
},
|
||||
"k:#foo &a !t s",
|
||||
{
|
||||
"block sequence": [
|
||||
"one",
|
||||
{
|
||||
"two": "three"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"First occurrence": "Value",
|
||||
"Second occurrence": "Value"
|
||||
},
|
||||
{
|
||||
"a true": "null d",
|
||||
"e 42": null
|
||||
},
|
||||
{
|
||||
"foo": "bar"
|
||||
},
|
||||
["foo", "bar", 42],
|
||||
"trimmed\n\n\nas space",
|
||||
"scalar",
|
||||
{
|
||||
"strip": "",
|
||||
"clip": "",
|
||||
"keep": "\n"
|
||||
},
|
||||
{
|
||||
"foo": {
|
||||
"bar": 1
|
||||
},
|
||||
"baz": 2
|
||||
},
|
||||
{
|
||||
"a": 47,
|
||||
"c": "d"
|
||||
},
|
||||
{
|
||||
"implicit block key": [
|
||||
{
|
||||
"implicit flow key": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
["a", "b", "c", "c", ""],
|
||||
[
|
||||
["a", "b", "c"],
|
||||
{
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
"e": "f"
|
||||
},
|
||||
[]
|
||||
],
|
||||
{
|
||||
"implicit block key": [
|
||||
{
|
||||
"implicit flow key": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"a": "ab\n\ncd\nef\n"
|
||||
},
|
||||
{
|
||||
"literal": "value\n",
|
||||
"folded": "value\n"
|
||||
},
|
||||
{
|
||||
"a": [
|
||||
"b",
|
||||
"c",
|
||||
{
|
||||
"d": ["e", "f"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"literal\n\ttext\n",
|
||||
"foo \n\n\t bar\n\nbaz\n",
|
||||
[
|
||||
{
|
||||
"a": "b"
|
||||
}
|
||||
],
|
||||
"ab",
|
||||
["plain", "double quoted", "single quoted", "block\n", "plain again"],
|
||||
{
|
||||
"a": " ",
|
||||
"b": " ",
|
||||
"c": " ",
|
||||
"d": " ",
|
||||
"e": "\n",
|
||||
"f": "\n",
|
||||
"g": "\n\n",
|
||||
"h": "\n\n"
|
||||
},
|
||||
{
|
||||
"key": "value with\ntabs"
|
||||
},
|
||||
{
|
||||
"": null
|
||||
},
|
||||
[
|
||||
{
|
||||
"single line": "value"
|
||||
},
|
||||
{
|
||||
"multi line": "value"
|
||||
}
|
||||
],
|
||||
"folded to a space,\nto a line feed, or \t \tnon-content",
|
||||
["literal\n", " folded\n", "keep\n\n", " strip"],
|
||||
{
|
||||
"key": "value"
|
||||
},
|
||||
{
|
||||
"american": ["Boston Red Sox", "Detroit Tigers", "New York Yankees"],
|
||||
"national": ["New York Mets", "Chicago Cubs", "Atlanta Braves"]
|
||||
},
|
||||
" 1st non-empty\n2nd non-empty 3rd non-empty ",
|
||||
{},
|
||||
[
|
||||
["a", "b"],
|
||||
{
|
||||
"a": "b"
|
||||
},
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
],
|
||||
"folded to a space,\nto a line feed, or \t \tnon-content",
|
||||
[
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
],
|
||||
["detected\n", "\n\n# detected\n", " explicit\n", "\t\ndetected\n"],
|
||||
{
|
||||
"top1": [
|
||||
"item1",
|
||||
{
|
||||
"key2": "value2"
|
||||
},
|
||||
"item3"
|
||||
],
|
||||
"top2": "value2"
|
||||
},
|
||||
{
|
||||
"foo": [42],
|
||||
"bar": [44]
|
||||
},
|
||||
{
|
||||
"23": "d",
|
||||
"a": 4.2
|
||||
},
|
||||
"Document",
|
||||
{
|
||||
"Date": "2001-11-23 15:03:17 -5",
|
||||
"User": "ed",
|
||||
"Fatal": "Unknown variable \"bar\"",
|
||||
"Stack": [
|
||||
{
|
||||
"file": "TopClass.py",
|
||||
"line": 23,
|
||||
"code": "x = MoreObject(\"345\\n\")\n"
|
||||
},
|
||||
{ "file": "MoreClass.py", "line": 58, "code": "foo = bar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"plain key": "in-line value",
|
||||
"": null,
|
||||
"quoted key": ["entry"]
|
||||
},
|
||||
["12", 12, "12"],
|
||||
{
|
||||
"aaa": "bbb"
|
||||
},
|
||||
[":,"],
|
||||
{
|
||||
"sequence": ["one", "two"],
|
||||
"mapping": {
|
||||
"sky": "blue",
|
||||
"sea": "green"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": ["a", "b"]
|
||||
},
|
||||
"here's to \"quotes\"",
|
||||
{
|
||||
"hr": 65,
|
||||
"avg": 0.278,
|
||||
"rbi": 147
|
||||
},
|
||||
"\n\nliteral\n \n\ntext\n",
|
||||
" 1st non-empty\n2nd non-empty 3rd non-empty ",
|
||||
"literal\n\ttext\n",
|
||||
{
|
||||
"block mapping": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
" foo\nbar\nbaz ",
|
||||
"ab cd\nef\n\ngh\n",
|
||||
"foo",
|
||||
{
|
||||
"top1": {
|
||||
"key1": "one"
|
||||
},
|
||||
"top2": {
|
||||
"key2": "two"
|
||||
},
|
||||
"top3": {
|
||||
"key3": "three"
|
||||
},
|
||||
"top4": {
|
||||
"key4": "four"
|
||||
},
|
||||
"top5": {
|
||||
"key5": "five"
|
||||
},
|
||||
"top6": "six",
|
||||
"top7": "seven"
|
||||
},
|
||||
[
|
||||
{
|
||||
"url": "http://example.org"
|
||||
}
|
||||
],
|
||||
{
|
||||
"sequence": ["one", "two"],
|
||||
"mapping": {
|
||||
"sky": "blue",
|
||||
"sea": "green"
|
||||
}
|
||||
},
|
||||
{
|
||||
"invoice": 34843,
|
||||
"date": "2001-01-23",
|
||||
"bill-to": {
|
||||
"given": "Chris",
|
||||
"family": "Dumars",
|
||||
"address": {
|
||||
"lines": "458 Walkman Dr.\nSuite #292\n",
|
||||
"city": "Royal Oak",
|
||||
"state": "MI",
|
||||
"postal": 48046
|
||||
}
|
||||
},
|
||||
"ship-to": {
|
||||
"given": "Chris",
|
||||
"family": "Dumars",
|
||||
"address": {
|
||||
"lines": "458 Walkman Dr.\nSuite #292\n",
|
||||
"city": "Royal Oak",
|
||||
"state": "MI",
|
||||
"postal": 48046
|
||||
}
|
||||
},
|
||||
"product": [
|
||||
{
|
||||
"sku": "BL394D",
|
||||
"quantity": 4,
|
||||
"description": "Basketball",
|
||||
"price": 450
|
||||
},
|
||||
{
|
||||
"sku": "BL4438H",
|
||||
"quantity": 1,
|
||||
"description": "Super Hoop",
|
||||
"price": 2392
|
||||
}
|
||||
],
|
||||
"tax": 251.42,
|
||||
"total": 4443.52,
|
||||
"comments": "Late afternoon is best. Backup contact is Nancy Billsmer @ 338-4338."
|
||||
},
|
||||
["a", "b", "a", "b"],
|
||||
[
|
||||
null,
|
||||
"block node\n",
|
||||
["one", "two"],
|
||||
{
|
||||
"one": "two"
|
||||
}
|
||||
],
|
||||
{
|
||||
"a": "scalar a",
|
||||
"b": "scalar a"
|
||||
},
|
||||
{
|
||||
"foo": "",
|
||||
"": "bar"
|
||||
},
|
||||
{
|
||||
"key": "value"
|
||||
},
|
||||
"scalar %YAML 1.2",
|
||||
{
|
||||
"Folding": "Empty line\nas a line feed",
|
||||
"Chomping": "Clipped empty lines\n"
|
||||
},
|
||||
{
|
||||
"key": "value"
|
||||
},
|
||||
[
|
||||
["name", "hr", "avg"],
|
||||
["Mark McGwire", 65, 0.278],
|
||||
["Sammy Sosa", 63, 0.288]
|
||||
],
|
||||
{
|
||||
"literal": "value\n",
|
||||
"folded": "value\n"
|
||||
},
|
||||
{
|
||||
"Mark McGwire": {
|
||||
"hr": 65,
|
||||
"avg": 0.278
|
||||
},
|
||||
"Sammy Sosa": {
|
||||
"hr": 63,
|
||||
"avg": 0.288
|
||||
}
|
||||
},
|
||||
{
|
||||
"a": "b",
|
||||
"c": "d"
|
||||
},
|
||||
{
|
||||
"key": [[["value"]]]
|
||||
},
|
||||
{
|
||||
"a": 1,
|
||||
"b": null,
|
||||
"c": 3
|
||||
},
|
||||
{
|
||||
"event": "outgoing HTTP response",
|
||||
"timestamp": "2021-01-21T18:44:57.116Z",
|
||||
"remoteAddress": "127.0.0.1",
|
||||
"deviceId": "202BC1-BM632w-000000",
|
||||
"connection": "2021-01-21T18:44:57.105Z",
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"content-length": 528,
|
||||
"server": "GenieACS/1.2.3+20210121000910",
|
||||
"soapserver": "GenieACS/1.2.3+20210121000910",
|
||||
"content-type": "text/xml; charset=\"utf-8\"",
|
||||
"set-cookie": "session=ea31d97ccf6832fb"
|
||||
},
|
||||
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<soap-env:Envelope xmlns:soap-enc=\"http://schemas.xmlsoap.org/soap/encoding/\" xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:cwmp=\"urn:dslforum-org:cwmp-1-0\"><soap-env:Header><cwmp:ID soap-env:mustUnderstand=\"1\">wdsx50vq</cwmp:ID></soap-env:Header><soap-env:Body><cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse></soap-env:Body></soap-env:Envelope>"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
import ava from "ava";
|
||||
import * as yaml from "yaml";
|
||||
import { stringify } from "../lib/common/yaml";
|
||||
|
||||
import testCases from "./yaml-tests.json";
|
||||
|
||||
ava("stringify", (t) => {
|
||||
for (const testCase of testCases) {
|
||||
let str = stringify(testCase);
|
||||
if (str.startsWith(">2")) str = ">3" + str.slice(2);
|
||||
if (str.startsWith("|2")) str = "|3" + str.slice(2);
|
||||
t.deepEqual(yaml.parse(yaml.stringify(testCase)), yaml.parse(str));
|
||||
}
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Copyright 2013-2019 GenieACS Inc.
|
||||
*
|
||||
* This file is part of GenieACS.
|
||||
*
|
||||
* GenieACS 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.
|
||||
*
|
||||
* GenieACS 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 GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { connect, devicesCollection } from "../lib/db";
|
||||
|
||||
const outputFilename = path.resolve(`data_model_${process.argv[2]}.json`);
|
||||
process.chdir(path.resolve(path.dirname(fs.realpathSync(__filename)), ".."));
|
||||
|
||||
const dataModel = {};
|
||||
|
||||
function recursive(object, p = ""): void {
|
||||
for (const key in object) {
|
||||
if (key[0] === "_" || !object[key]) continue;
|
||||
|
||||
const isObject = object[key]["_object"];
|
||||
const isValue = "_value" in object[key];
|
||||
|
||||
if ((isObject && isValue) || (!isObject && !isValue))
|
||||
throw new Error("Unexpected object");
|
||||
|
||||
if (isValue) {
|
||||
let value = object[key]["_value"];
|
||||
if (typeof value === "boolean") value = +value;
|
||||
|
||||
const values = [object[key]["_writable"] || false, value.toString()];
|
||||
if (object[key]["_type"]) values.push(object[key]["_type"]);
|
||||
|
||||
dataModel[`${p}${key}`] = values;
|
||||
}
|
||||
|
||||
if (isObject) {
|
||||
const values = [object[key]["_writable"] || false];
|
||||
dataModel[`${p}${key}.`] = values;
|
||||
recursive(object[key], `${p}${key}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
.then(() => {
|
||||
devicesCollection.findOne(
|
||||
{
|
||||
_id: process.argv[2],
|
||||
},
|
||||
{},
|
||||
(err, device) => {
|
||||
if (err) throw err;
|
||||
|
||||
if (!device) throw new Error("Device not found");
|
||||
|
||||
recursive(device);
|
||||
|
||||
fs.writeFile(outputFilename, JSON.stringify(dataModel, null, 2), () => {
|
||||
if (err) throw err;
|
||||
|
||||
process.stdout.write(`${outputFilename} saved\n`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
+6
-9
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"esnext", "dom"
|
||||
],
|
||||
"lib": ["esnext", "dom"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"target": "esnext",
|
||||
@@ -14,11 +12,10 @@
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"./bin/*.ts",
|
||||
"./lib/**/*",
|
||||
"./ui/**/*",
|
||||
"./test/**/*",
|
||||
"./build/**/*",
|
||||
"./tools/*.ts"
|
||||
"./bin/*.ts",
|
||||
"./lib/**/*",
|
||||
"./ui/**/*",
|
||||
"./test/**/*",
|
||||
"./build/**/*"
|
||||
]
|
||||
}
|
||||
@@ -64,50 +64,37 @@ const adminPages = [
|
||||
|
||||
let state;
|
||||
|
||||
let lastRenderTimestamp = 0;
|
||||
let pageVisitTimestamp = 0;
|
||||
let fulfillTimeout = null;
|
||||
|
||||
function fulfill(): void {
|
||||
clearTimeout(fulfillTimeout);
|
||||
fulfillTimeout = setTimeout(() => {
|
||||
store.fulfill(lastRenderTimestamp, pageVisitTimestamp, (updated) => {
|
||||
if (updated) m.redraw();
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function pagify(pageName, page): RouteResolver {
|
||||
const component: RouteResolver = {
|
||||
render: () => {
|
||||
lastRenderTimestamp = Date.now();
|
||||
const lastRenderTimestamp = Date.now();
|
||||
let p;
|
||||
if (state && state.error) p = m(errorPage.component, state);
|
||||
else p = m(contextifyComponent(page.component), state);
|
||||
fulfill();
|
||||
const attrs = {};
|
||||
attrs["page"] = pageName;
|
||||
attrs["oncreate"] = attrs["onupdate"] = () => {
|
||||
store.fulfill(lastRenderTimestamp);
|
||||
};
|
||||
return m(layout, attrs, p);
|
||||
},
|
||||
onmatch: null,
|
||||
};
|
||||
|
||||
component.onmatch = (args, requestedPath) => {
|
||||
pageVisitTimestamp = Date.now();
|
||||
store.setTimestamp(Date.now());
|
||||
if (!page.init) {
|
||||
state = null;
|
||||
fulfill();
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
page
|
||||
.init(args)
|
||||
.then((st) => {
|
||||
if (!st) return void m.route.set("/");
|
||||
state = st;
|
||||
resolve();
|
||||
fulfill();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!window.username && err.message.indexOf("authorized") >= 0) {
|
||||
|
||||
+17
-14
@@ -28,21 +28,24 @@ const component: ClosureComponent = (): Component => {
|
||||
name: vnode.attrs["id"],
|
||||
value: vnode.attrs["value"],
|
||||
oncreate: (_vnode) => {
|
||||
const editor = codeMirror.fromTextArea(_vnode.dom, {
|
||||
mode: vnode.attrs["mode"],
|
||||
lineNumbers: true,
|
||||
readOnly: vnode.attrs["readOnly"],
|
||||
extraKeys: {
|
||||
"Ctrl-Enter": () => {
|
||||
if (vnode.attrs["onSubmit"])
|
||||
vnode.attrs["onSubmit"](_vnode.dom);
|
||||
const editor = codeMirror.fromTextArea(
|
||||
_vnode.dom as HTMLTextAreaElement,
|
||||
{
|
||||
mode: vnode.attrs["mode"],
|
||||
lineNumbers: true,
|
||||
readOnly: vnode.attrs["readOnly"],
|
||||
extraKeys: {
|
||||
"Ctrl-Enter": () => {
|
||||
if (vnode.attrs["onSubmit"])
|
||||
vnode.attrs["onSubmit"](_vnode.dom);
|
||||
},
|
||||
"Cmd-Enter": () => {
|
||||
if (vnode.attrs["onSubmit"])
|
||||
vnode.attrs["onSubmit"](_vnode.dom);
|
||||
},
|
||||
},
|
||||
"Cmd-Enter": () => {
|
||||
if (vnode.attrs["onSubmit"])
|
||||
vnode.attrs["onSubmit"](_vnode.dom);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (vnode.attrs["onChange"]) {
|
||||
editor.on("change", (e) => {
|
||||
|
||||
+9
-3
@@ -39,6 +39,7 @@ import tags from "./components/tags";
|
||||
import ping from "./components/ping";
|
||||
import deviceLink from "./components/device-link";
|
||||
import longTextComponent from "./long-text-component";
|
||||
import loading from "./components/loading";
|
||||
|
||||
const comps = {
|
||||
parameter,
|
||||
@@ -54,6 +55,7 @@ const comps = {
|
||||
ping,
|
||||
"device-link": deviceLink,
|
||||
"long-text": longTextComponent,
|
||||
loading: loading,
|
||||
};
|
||||
|
||||
const contextifiedComponents = new WeakMap<ComponentTypes, ComponentTypes>();
|
||||
@@ -137,9 +139,13 @@ export function contextifyComponent(component: ComponentTypes): ComponentTypes {
|
||||
const view = state.view;
|
||||
state.view = function (vnode) {
|
||||
const context = vnodeContext.get(vnode) || {};
|
||||
const res = Reflect.apply(view, this, [vnode]);
|
||||
applyContext(res, context);
|
||||
return res;
|
||||
try {
|
||||
const res = Reflect.apply(view, this, [vnode]);
|
||||
applyContext(res, context);
|
||||
return res;
|
||||
} catch (err) {
|
||||
return m("p.error", { title: err.message }, "Error!");
|
||||
}
|
||||
};
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,8 @@ const component: ClosureComponent = (): Component => {
|
||||
view: (vnode) => {
|
||||
const device = vnode.attrs["device"];
|
||||
|
||||
const limit = vnode.attrs["limit"] > 0 ? vnode.attrs["limit"] : 100;
|
||||
|
||||
const search = m("input", {
|
||||
type: "text",
|
||||
placeholder: "Search parameters",
|
||||
@@ -86,7 +88,7 @@ const component: ClosureComponent = (): Component => {
|
||||
const str = p.value && p.value[0] ? `${k} ${p.value[0]}` : k;
|
||||
if (re && !re.test(str)) continue;
|
||||
++c;
|
||||
if (count < 100) filteredKeys.push(k);
|
||||
if (count < limit) filteredKeys.push(k);
|
||||
}
|
||||
count += c;
|
||||
}
|
||||
@@ -169,25 +171,29 @@ const component: ClosureComponent = (): Component => {
|
||||
});
|
||||
|
||||
return m(
|
||||
".all-parameters",
|
||||
"loading",
|
||||
{ queries: [vnode.attrs["deviceQuery"]] },
|
||||
m(
|
||||
"a.download-csv",
|
||||
{
|
||||
href: `api/devices/${encodeURIComponent(
|
||||
device["DeviceID.ID"].value[0]
|
||||
)}.csv`,
|
||||
download: "",
|
||||
style: "float: right;",
|
||||
},
|
||||
"Download"
|
||||
),
|
||||
search,
|
||||
m(
|
||||
".parameter-list",
|
||||
m("table", m("tbody", rows)),
|
||||
".all-parameters",
|
||||
m(
|
||||
"m",
|
||||
`Displaying ${filteredKeys.length} out of ${count} parameters.`
|
||||
"a.download-csv",
|
||||
{
|
||||
href: `api/devices/${encodeURIComponent(
|
||||
device["DeviceID.ID"].value[0]
|
||||
)}.csv`,
|
||||
download: "",
|
||||
style: "float: right;",
|
||||
},
|
||||
"Download"
|
||||
),
|
||||
search,
|
||||
m(
|
||||
".parameter-list",
|
||||
m("table", m("tbody", rows)),
|
||||
m(
|
||||
"m",
|
||||
`Displaying ${filteredKeys.length} out of ${count} parameters.`
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ const component: ClosureComponent = (): Component => {
|
||||
onclick: () => {
|
||||
taskQueue.stageDownload({
|
||||
name: "download",
|
||||
device: device["DeviceID.ID"].value[0],
|
||||
devices: [device["DeviceID.ID"].value[0]],
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { m } from "../components";
|
||||
import * as store from "../store";
|
||||
import * as notifications from "../notifications";
|
||||
import { getIcon } from "../icons";
|
||||
import { stringify } from "../../lib/common/yaml";
|
||||
|
||||
const component: ClosureComponent = (): Component => {
|
||||
return {
|
||||
@@ -38,6 +39,7 @@ const component: ClosureComponent = (): Component => {
|
||||
"Channel",
|
||||
"Code",
|
||||
"Message",
|
||||
"Detail",
|
||||
"Retries",
|
||||
"Timestamp",
|
||||
].map((l) => m("th", l));
|
||||
@@ -48,7 +50,9 @@ const component: ClosureComponent = (): Component => {
|
||||
rows.push([
|
||||
m("td", f["channel"]),
|
||||
m("td", f["code"]),
|
||||
m("td", f["message"]),
|
||||
m("td", m("long-text", { text: f["message"] })),
|
||||
m("td", m("long-text", { text: stringify(f["detail"]) })),
|
||||
|
||||
m("td", f["retries"]),
|
||||
m("td", new Date(f["timestamp"]).toLocaleString()),
|
||||
m(
|
||||
@@ -63,12 +67,12 @@ const component: ClosureComponent = (): Component => {
|
||||
.deleteResource("faults", f["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Fault deleted");
|
||||
store.fulfill(Date.now(), Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
m.redraw();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(Date.now(), Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -91,7 +95,11 @@ const component: ClosureComponent = (): Component => {
|
||||
);
|
||||
}
|
||||
|
||||
return m("table.table", thead, tbody);
|
||||
return m(
|
||||
"loading",
|
||||
{ queries: [faults] },
|
||||
m("table.table", thead, tbody)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright 2013-2021 GenieACS Inc.
|
||||
*
|
||||
* This file is part of GenieACS.
|
||||
*
|
||||
* GenieACS 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.
|
||||
*
|
||||
* GenieACS 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 GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { VnodeDOM, ClosureComponent, Component } from "mithril";
|
||||
|
||||
const component: ClosureComponent = (): Component => {
|
||||
let overlay: HTMLElement;
|
||||
let dom: Element;
|
||||
let loading = false;
|
||||
|
||||
function apply(vnode: VnodeDOM): void {
|
||||
if (!loading) {
|
||||
if (overlay) overlay.parentElement.remove();
|
||||
if (dom) dom.classList.remove("loading");
|
||||
overlay = null;
|
||||
dom = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dom && dom !== vnode.dom) dom.classList.remove("loading");
|
||||
|
||||
dom = vnode.dom;
|
||||
dom.classList.add("loading");
|
||||
|
||||
if (!overlay) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.pointerEvents = "none";
|
||||
overlay = document.createElement("div");
|
||||
overlay.classList.add("loading-overlay");
|
||||
overlay.style.position = "absolute";
|
||||
wrapper.appendChild(overlay);
|
||||
}
|
||||
|
||||
const wrapper = overlay.parentElement;
|
||||
if (wrapper.parentElement !== dom.parentElement)
|
||||
dom.parentNode.appendChild(wrapper);
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
overlay.style.width = `${dom.scrollWidth}px`;
|
||||
overlay.style.height = `${dom.scrollHeight}px`;
|
||||
overlay.style.left = `${domRect.left - wrapperRect.left}px`;
|
||||
overlay.style.top = `${domRect.top - wrapperRect.top}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
view: (vnode) => {
|
||||
const queries = vnode.attrs["queries"];
|
||||
loading = queries.some((q) => q.fulfilling);
|
||||
return vnode.children;
|
||||
},
|
||||
oncreate: apply,
|
||||
onupdate: apply,
|
||||
onremove: () => {
|
||||
if (overlay) overlay.parentElement.remove();
|
||||
if (dom) dom.classList.remove("loading");
|
||||
overlay = null;
|
||||
dom = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default component;
|
||||
@@ -38,6 +38,11 @@ const component: ClosureComponent = (): Component => {
|
||||
return m(
|
||||
"tr",
|
||||
{
|
||||
oncreate: (vn) => {
|
||||
(vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom
|
||||
? ""
|
||||
: "none";
|
||||
},
|
||||
onupdate: (vn) => {
|
||||
(vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom
|
||||
? ""
|
||||
@@ -49,7 +54,11 @@ const component: ClosureComponent = (): Component => {
|
||||
);
|
||||
});
|
||||
|
||||
return m("table.parameter-list", rows);
|
||||
return m(
|
||||
"loading",
|
||||
{ queries: [vnode.attrs["deviceQuery"]] },
|
||||
m("table.parameter-list", rows)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -149,7 +149,14 @@ const component: ClosureComponent = (): Component => {
|
||||
let label;
|
||||
if (vnode.attrs["label"]) label = m("h2", vnode.attrs["label"]);
|
||||
|
||||
return [label, m("table.table", thead, m("tbody", rows))];
|
||||
return [
|
||||
label,
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [vnode.attrs["deviceQuery"]] },
|
||||
m("table.table", thead, m("tbody", rows))
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,8 @@ const evaluateParam = memoize((exp, obj, now: number) => {
|
||||
timestamp = p.valueTimestamp;
|
||||
value = p.value[0];
|
||||
parameter = exp[1];
|
||||
if (p.value[1] === "xsd:dateTime" && typeof value === "number")
|
||||
value = new Date(value).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ const component: ClosureComponent = (): Component => {
|
||||
const { value, timestamp, parameter } = evaluateParam(
|
||||
vnode.attrs["parameter"],
|
||||
vnode.attrs["device"],
|
||||
store.getTimestamp()
|
||||
store.getTimestamp() + store.getClockSkew()
|
||||
);
|
||||
|
||||
if (value == null) return null;
|
||||
@@ -90,7 +92,7 @@ const component: ClosureComponent = (): Component => {
|
||||
onclick: () => {
|
||||
taskQueue.stageSpv({
|
||||
name: "setParameterValues",
|
||||
device: device["DeviceID.ID"].value[0],
|
||||
devices: [device["DeviceID.ID"].value[0]],
|
||||
parameterValues: [
|
||||
[
|
||||
parameter,
|
||||
@@ -115,7 +117,7 @@ const component: ClosureComponent = (): Component => {
|
||||
e.redraw = false;
|
||||
// Don't update any child element
|
||||
if (e.target === (el as VnodeDOM).dom) {
|
||||
const now = Date.now();
|
||||
const now = Date.now() + store.getClockSkew();
|
||||
const localeString = new Date(timestamp).toLocaleString();
|
||||
e.target.title = `${localeString} (${timeAgo(now - timestamp)})`;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,11 @@ const component: ClosureComponent = (vn): Component => {
|
||||
param = device["Device.ManagementServer.ConnectionRequestURL"];
|
||||
|
||||
let h;
|
||||
if (param && param.value) {
|
||||
try {
|
||||
const url = new URL(param.value[0]);
|
||||
h = url.hostname;
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (host !== h) {
|
||||
@@ -77,7 +79,7 @@ const component: ClosureComponent = (vn): Component => {
|
||||
}
|
||||
}
|
||||
|
||||
return m("div", `Pinging ${host}:`);
|
||||
return m("div", host ? `Pinging ${host}:` : "");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,10 +78,11 @@ const component: ClosureComponent = (): Component => {
|
||||
)
|
||||
.then(() => {
|
||||
e.target.disabled = false;
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
e.target.disabled = false;
|
||||
notifications.push("error", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { m } from "../components";
|
||||
import * as notifications from "../notifications";
|
||||
import * as store from "../store";
|
||||
import { getIcon } from "../icons";
|
||||
import { decodeTag } from "../../lib/common";
|
||||
|
||||
const component: ClosureComponent = (): Component => {
|
||||
return {
|
||||
@@ -32,7 +33,7 @@ const component: ClosureComponent = (): Component => {
|
||||
|
||||
const tags = [];
|
||||
for (const p of Object.keys(device))
|
||||
if (p.startsWith("Tags.")) tags.push(p.slice(5));
|
||||
if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
|
||||
|
||||
tags.sort();
|
||||
|
||||
@@ -63,7 +64,7 @@ const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${deviceId}: Tags updated`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
e.target.disabled = false;
|
||||
@@ -98,7 +99,7 @@ const component: ClosureComponent = (): Component => {
|
||||
.then(() => {
|
||||
e.target.disabled = false;
|
||||
notifications.push("success", `${deviceId}: Tags updated`);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
e.target.disabled = false;
|
||||
|
||||
+32
-1
@@ -42,6 +42,35 @@ export function flattenConfig(config: Record<string, unknown>): any {
|
||||
return flatten;
|
||||
}
|
||||
|
||||
// Order keys such that nested objects come last
|
||||
function orderKeys(config: any): number {
|
||||
let res = 1;
|
||||
if (typeof config !== "object") return res;
|
||||
if (Array.isArray(config)) {
|
||||
for (const c of config) res += orderKeys(c);
|
||||
return res;
|
||||
}
|
||||
|
||||
const weights: [string, number][] = Object.entries(config).map(([k, v]) => [
|
||||
k,
|
||||
orderKeys(v),
|
||||
]);
|
||||
|
||||
weights.sort((a, b) => {
|
||||
if (a[1] !== b[1]) return a[1] - b[1];
|
||||
if (b[0] > a[0]) return -1;
|
||||
else return 1;
|
||||
});
|
||||
|
||||
for (const [k, w] of weights) {
|
||||
res += w;
|
||||
const v = config[k];
|
||||
delete config[k];
|
||||
config[k] = v;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function structureConfig(config: Config[]): any {
|
||||
config.sort((a, b) => (a._id > b._id ? 1 : a._id < b._id ? -1 : 0));
|
||||
const _config = {};
|
||||
@@ -93,7 +122,9 @@ export function structureConfig(config: Config[]): any {
|
||||
return object;
|
||||
};
|
||||
|
||||
return toArray(_config);
|
||||
const res = toArray(_config);
|
||||
orderKeys(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function diffConfig(
|
||||
|
||||
+125
-106
@@ -60,12 +60,12 @@ function putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {
|
||||
.resourceExists("config", id)
|
||||
.then((exists) => {
|
||||
if (exists && isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Config already exists" });
|
||||
}
|
||||
|
||||
if (!exists && !isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Config does not exist" });
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ function putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {
|
||||
"success",
|
||||
`Config ${exists ? "updated" : "created"}`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
@@ -87,11 +87,11 @@ function putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {
|
||||
.deleteResource("config", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Config deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
@@ -147,37 +147,42 @@ function renderTable(confsResponse, searchString): Children {
|
||||
{
|
||||
title: "Edit config value",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: conf,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const ErrorList = errors ? Object.values(errors) : [];
|
||||
if (ErrorList.length) {
|
||||
for (const err of ErrorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: conf,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const ErrorList = errors ? Object.values(errors) : [];
|
||||
if (ErrorList.length) {
|
||||
for (const err of ErrorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
getIcon("edit")
|
||||
@@ -244,38 +249,43 @@ export const component: ClosureComponent = (): Component => {
|
||||
{
|
||||
title: "Create new config",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New config"
|
||||
@@ -307,39 +317,44 @@ export const component: ClosureComponent = (): Component => {
|
||||
"button",
|
||||
{
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
uiConfigComponent,
|
||||
Object.assign(
|
||||
{
|
||||
onUpdate: (errs: string[]) => {
|
||||
const errors = errs ? Object.values(errs) : [];
|
||||
if (errors.length) {
|
||||
for (const err of errors)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
notifications.push(
|
||||
"success",
|
||||
`${sub.name.replace(
|
||||
/^[a-z]/,
|
||||
sub.name[0].toUpperCase()
|
||||
)} config updated`
|
||||
);
|
||||
overlay.close(cb);
|
||||
}
|
||||
store.fulfill(0, Date.now());
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
uiConfigComponent,
|
||||
Object.assign(
|
||||
{
|
||||
onUpdate: (errs: string[]) => {
|
||||
const errors = errs ? Object.values(errs) : [];
|
||||
if (errors.length) {
|
||||
for (const err of errors)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
notifications.push(
|
||||
"success",
|
||||
`${sub.name.replace(
|
||||
/^[a-z]/,
|
||||
sub.name[0].toUpperCase()
|
||||
)} config updated`
|
||||
);
|
||||
overlay.close(cb);
|
||||
},
|
||||
}
|
||||
store.setTimestamp(Date.now());
|
||||
},
|
||||
attrs
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
onError: (err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.setTimestamp(Date.now());
|
||||
overlay.close(cb);
|
||||
},
|
||||
},
|
||||
attrs
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
`Edit ${sub.name}`
|
||||
@@ -351,14 +366,18 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing config"),
|
||||
m(
|
||||
".all-parameters",
|
||||
search,
|
||||
"loading",
|
||||
{ queries: [confs] },
|
||||
m(
|
||||
".parameter-list",
|
||||
{ style: "height: 400px" },
|
||||
renderTable(confs, vnode.state["searchString"])
|
||||
),
|
||||
m(".actions-bar", [newConfig].concat(subs))
|
||||
".all-parameters",
|
||||
search,
|
||||
m(
|
||||
".parameter-list",
|
||||
{ style: "height: 400px" },
|
||||
renderTable(confs, vnode.state["searchString"])
|
||||
),
|
||||
m(".actions-bar", [newConfig].concat(subs))
|
||||
)
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
+63
-4
@@ -46,6 +46,7 @@ button,
|
||||
select,
|
||||
option,
|
||||
textarea {
|
||||
outline-color: var(--color4);
|
||||
font-family: sans-serif;
|
||||
color: var(--color5);
|
||||
}
|
||||
@@ -178,6 +179,8 @@ body {
|
||||
}
|
||||
|
||||
& > .queue {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
& > .pending {
|
||||
animation: 0.5s linear 0s infinite alternate pending-blink;
|
||||
border-radius: 0.4em;
|
||||
@@ -283,9 +286,21 @@ body {
|
||||
}
|
||||
|
||||
& > .logo {
|
||||
margin: 10px;
|
||||
display: inline;
|
||||
height: 56px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
& > img {
|
||||
margin: 10px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
& > .version {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
& > .user-menu {
|
||||
@@ -327,12 +342,18 @@ body {
|
||||
flex-grow: 1;
|
||||
margin: 10px;
|
||||
min-height: 250px;
|
||||
|
||||
/* Workaround for Chrome ignoring bottom margin when flex container overflows */
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
table.table {
|
||||
border-spacing: 0;
|
||||
text-align: left;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
|
||||
& table.table {
|
||||
@@ -738,3 +759,41 @@ span.parameter-value {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&::after {
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTEwIDUwYTQwIDQwIDAgMDA4MCAwIDQwIDQyIDAgMDEtODAgMCIgZmlsbD0iI2IxMmQ1YyIgLz48L3N2Zz4=);
|
||||
content: "";
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
animation: spin 1s linear infinite;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: #eee;
|
||||
overflow: hidden;
|
||||
height: 3px;
|
||||
& > .progress-bar {
|
||||
height: 100%;
|
||||
background-color: #b12d5c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2013-2021 GenieACS Inc.
|
||||
*
|
||||
* This file is part of GenieACS.
|
||||
*
|
||||
* GenieACS 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.
|
||||
*
|
||||
* GenieACS 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 GenieACS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import m, { ClosureComponent, Component, Vnode } from "mithril";
|
||||
|
||||
const elements: Map<string, Vnode> = new Map();
|
||||
|
||||
// Source: https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
|
||||
function hash(str: string): number {
|
||||
let res = 0;
|
||||
for (let i = 0; i < str.length; ++i) {
|
||||
const c = str.charCodeAt(i);
|
||||
res = (res << 5) - res + c;
|
||||
res |= 0;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getDatalistId(options: string[]): string {
|
||||
const id = "datalist" + options.reduce((acc, cur) => acc ^ hash(cur), 0);
|
||||
if (!elements.has(id)) {
|
||||
const n = m(
|
||||
"datalist",
|
||||
{ id },
|
||||
options.map((o) => m("option", { value: o }))
|
||||
);
|
||||
elements.set(id, n);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const component: ClosureComponent = (): Component => {
|
||||
return {
|
||||
view: () => {
|
||||
return [...elements.values()];
|
||||
},
|
||||
onupdate: () => {
|
||||
for (const id of elements.keys()) {
|
||||
const used = document.querySelector(`[list='${id}']`);
|
||||
if (!used) elements.delete(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default component;
|
||||
+16
-4
@@ -42,13 +42,25 @@ export const component: ClosureComponent = (): Component => {
|
||||
view: (vnode) => {
|
||||
document.title = `${vnode.attrs["deviceId"]} - Devices - GenieACS`;
|
||||
|
||||
const dev = store.fetch("devices", vnode.attrs["deviceFilter"]).value;
|
||||
if (!dev.length) return "Loading";
|
||||
const dev = store.fetch("devices", vnode.attrs["deviceFilter"]);
|
||||
if (!dev.value.length) {
|
||||
if (!dev.fulfilling)
|
||||
return m("p.error", `No such device ${vnode.attrs["deviceId"]}`);
|
||||
return m(
|
||||
"loading",
|
||||
{ queries: [dev] },
|
||||
m("div", { style: "height: 100px;" })
|
||||
);
|
||||
}
|
||||
|
||||
const conf = config.ui.device;
|
||||
const cmps = [];
|
||||
|
||||
for (const c of Object.values(conf))
|
||||
cmps.push(m.context({ device: dev[0] }, c["type"], c));
|
||||
for (const c of Object.values(conf)) {
|
||||
cmps.push(
|
||||
m.context({ device: dev.value[0], deviceQuery: dev }, c["type"], c)
|
||||
);
|
||||
}
|
||||
|
||||
return [m("h1", vnode.attrs["deviceId"]), cmps];
|
||||
},
|
||||
|
||||
+38
-23
@@ -23,7 +23,7 @@ import config from "./config";
|
||||
import indexTableComponent from "./index-table-component";
|
||||
import filterComponent from "./filter-component";
|
||||
import * as store from "./store";
|
||||
import * as taskQueue from "./task-queue";
|
||||
import { queueTask, stageDownload } from "./task-queue";
|
||||
import * as notifications from "./notifications";
|
||||
import { parse, stringify } from "../lib/common/expression-parser";
|
||||
import { evaluate, extractParams } from "../lib/common/expression";
|
||||
@@ -91,12 +91,11 @@ function renderActions(selected: Set<string>): Children {
|
||||
title: "Reboot selected devices",
|
||||
disabled: !selected.size,
|
||||
onclick: () => {
|
||||
for (const d of selected) {
|
||||
taskQueue.queueTask({
|
||||
name: "reboot",
|
||||
device: d,
|
||||
});
|
||||
}
|
||||
const tasks = [...selected].map((s) => ({
|
||||
name: "reboot",
|
||||
device: s,
|
||||
}));
|
||||
queueTask(...tasks);
|
||||
},
|
||||
},
|
||||
"Reboot"
|
||||
@@ -110,18 +109,34 @@ function renderActions(selected: Set<string>): Children {
|
||||
title: "Factory reset selected devices",
|
||||
disabled: !selected.size,
|
||||
onclick: () => {
|
||||
for (const d of selected) {
|
||||
taskQueue.queueTask({
|
||||
name: "factoryReset",
|
||||
device: d,
|
||||
});
|
||||
}
|
||||
const tasks = [...selected].map((s) => ({
|
||||
name: "factoryReset",
|
||||
device: s,
|
||||
}));
|
||||
queueTask(...tasks);
|
||||
},
|
||||
},
|
||||
"Reset"
|
||||
)
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
m(
|
||||
"button.critical",
|
||||
{
|
||||
title: "Push a firmware or a config file",
|
||||
disabled: !selected.size,
|
||||
onclick: () => {
|
||||
stageDownload({
|
||||
name: "download",
|
||||
devices: [...selected],
|
||||
});
|
||||
},
|
||||
},
|
||||
"Push file"
|
||||
)
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
m(
|
||||
"button.primary",
|
||||
@@ -139,14 +154,14 @@ function renderActions(selected: Set<string>): Children {
|
||||
.deleteResource("devices", id)
|
||||
.then(() => {
|
||||
notifications.push("success", `${id}: Deleted`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", `${id}: ${err.message}`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
});
|
||||
}
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
},
|
||||
},
|
||||
"Delete"
|
||||
@@ -171,14 +186,14 @@ function renderActions(selected: Set<string>): Children {
|
||||
.updateTags(id, { [tag]: true })
|
||||
.then(() => {
|
||||
notifications.push("success", `${id}: Tags updated`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", `${id}: ${err.message}`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
});
|
||||
}
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
},
|
||||
},
|
||||
"Tag"
|
||||
@@ -205,14 +220,14 @@ function renderActions(selected: Set<string>): Children {
|
||||
.updateTags(id, { [tag]: false })
|
||||
.then(() => {
|
||||
notifications.push("success", `${id}: Tags updated`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", `${id}: ${err.message}`);
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
});
|
||||
}
|
||||
if (--counter === 0) store.fulfill(0, Date.now());
|
||||
if (--counter === 0) store.setTimestamp(Date.now());
|
||||
},
|
||||
},
|
||||
"Untag"
|
||||
@@ -321,7 +336,7 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing devices"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m("loading", { queries: [devs, count] }, m(indexTableComponent, attrs)),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+104
-76
@@ -24,10 +24,21 @@ import m, {
|
||||
Component,
|
||||
VnodeDOM,
|
||||
} from "mithril";
|
||||
import * as taskQueue from "./task-queue";
|
||||
import * as store from "./store";
|
||||
import * as notifications from "./notifications";
|
||||
import { getIcon } from "./icons";
|
||||
import {
|
||||
clear,
|
||||
commit,
|
||||
deleteTask,
|
||||
getQueue,
|
||||
getStaging,
|
||||
QueueTask,
|
||||
queueTask,
|
||||
StageTask,
|
||||
} from "./task-queue";
|
||||
|
||||
const invalid: WeakSet<StageTask> = new WeakSet();
|
||||
|
||||
function mparam(param): Children {
|
||||
return m("span.parameter", { title: param }, `${param}`);
|
||||
@@ -37,11 +48,11 @@ function mval(val): Children {
|
||||
return m("span.value", { title: val }, `${val}`);
|
||||
}
|
||||
|
||||
function renderStagingSpv(task, queueFunc, cancelFunc): Children {
|
||||
function keydown(e): void {
|
||||
if (e.keyCode === 13) queueFunc();
|
||||
else if (e.keyCode === 27) cancelFunc();
|
||||
else e.redraw = false;
|
||||
function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
|
||||
function keydown(e: KeyboardEvent): void {
|
||||
if (e.key === "Enter") queueFunc();
|
||||
else if (e.key === "Escape") cancelFunc();
|
||||
else e["redraw"] = false;
|
||||
}
|
||||
|
||||
let input;
|
||||
@@ -65,13 +76,13 @@ function renderStagingSpv(task, queueFunc, cancelFunc): Children {
|
||||
]
|
||||
);
|
||||
} else {
|
||||
const type = task.parameterValues[0][2];
|
||||
let value = task.parameterValues[0][1];
|
||||
if (type === "xsd:dateTime" && typeof value === "number")
|
||||
value = new Date(value).toJSON() || value;
|
||||
input = m("input", {
|
||||
type: ["xsd:dateTime", "xsd:int", "xsd:unsignedInt"].includes(
|
||||
task.parameterValues[0][2]
|
||||
)
|
||||
? "number"
|
||||
: "text",
|
||||
value: task.parameterValues[0][1],
|
||||
type: ["xsd:int", "xsd:unsignedInt"].includes(type) ? "number" : "text",
|
||||
value: value,
|
||||
oninput: (e) => {
|
||||
e.redraw = false;
|
||||
task.parameterValues[0][1] = input.dom.value;
|
||||
@@ -90,21 +101,35 @@ function renderStagingSpv(task, queueFunc, cancelFunc): Children {
|
||||
return [m("span", "Editing ", mparam(task.parameterValues[0][0])), input];
|
||||
}
|
||||
|
||||
function renderStagingDownload(task): Children {
|
||||
task.invalid = !task.fileName || !task.fileType;
|
||||
function renderStagingDownload(task: StageTask): Children {
|
||||
if (!task.fileName || !task.fileType) invalid.add(task);
|
||||
else invalid.delete(task);
|
||||
const files = store.fetch("files", true);
|
||||
const idParts = task.device.split("-");
|
||||
const oui = decodeURIComponent(idParts[0]);
|
||||
const productClass =
|
||||
idParts.length === 3 ? decodeURIComponent(idParts[1]) : "";
|
||||
let oui = "";
|
||||
let productClass = "";
|
||||
for (const d of task.devices) {
|
||||
const parts = d.split("-");
|
||||
if (oui === "") oui = parts[0];
|
||||
else if (oui !== parts[0]) oui = null;
|
||||
if (parts.length === 3) {
|
||||
if (productClass === "") productClass = parts[1];
|
||||
else if (productClass !== parts[1]) productClass = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (oui) oui = decodeURIComponent(oui);
|
||||
if (productClass) productClass = decodeURIComponent(productClass);
|
||||
|
||||
const typesList = [
|
||||
"",
|
||||
"1 Firmware Upgrade Image",
|
||||
"2 Web Content",
|
||||
"3 Vendor Configuration File",
|
||||
"4 Tone File",
|
||||
"5 Ringer File",
|
||||
...new Set([
|
||||
"",
|
||||
"1 Firmware Upgrade Image",
|
||||
"2 Web Content",
|
||||
"3 Vendor Configuration File",
|
||||
"4 Tone File",
|
||||
"5 Ringer File",
|
||||
...files.value.map((f) => f["metadata.fileType"]).filter((f) => f),
|
||||
]),
|
||||
].map((t) =>
|
||||
m(
|
||||
"option",
|
||||
@@ -162,13 +187,17 @@ function renderStagingDownload(task): Children {
|
||||
];
|
||||
}
|
||||
|
||||
function renderStaging(staging): Child[] {
|
||||
function renderStaging(staging: Set<StageTask>): Child[] {
|
||||
const elements: Child[] = [];
|
||||
|
||||
for (const s of staging) {
|
||||
const queueFunc = (): void => {
|
||||
staging.delete(s);
|
||||
taskQueue.queueTask(s);
|
||||
for (const d of s.devices) {
|
||||
const t = Object.assign({ device: d }, s);
|
||||
delete t.devices;
|
||||
queueTask(t);
|
||||
}
|
||||
};
|
||||
const cancelFunc = (): void => {
|
||||
staging.delete(s);
|
||||
@@ -181,7 +210,7 @@ function renderStaging(staging): Child[] {
|
||||
|
||||
const queue = m(
|
||||
"button.primary",
|
||||
{ title: "Queue task", onclick: queueFunc, disabled: s.invalid },
|
||||
{ title: "Queue task", onclick: queueFunc, disabled: invalid.has(s) },
|
||||
"Queue"
|
||||
);
|
||||
const cancel = m(
|
||||
@@ -195,7 +224,7 @@ function renderStaging(staging): Child[] {
|
||||
return elements;
|
||||
}
|
||||
|
||||
function renderQueue(queue): Child[] {
|
||||
function renderQueue(queue: Set<QueueTask>): Child[] {
|
||||
const details: Child[] = [];
|
||||
const devices: { [deviceId: string]: any[] } = {};
|
||||
for (const t of queue) {
|
||||
@@ -215,7 +244,7 @@ function renderQueue(queue): Child[] {
|
||||
{
|
||||
title: "Retry this task",
|
||||
onclick: () => {
|
||||
taskQueue.queueTask(t);
|
||||
queueTask(t);
|
||||
},
|
||||
},
|
||||
getIcon("retry")
|
||||
@@ -229,7 +258,7 @@ function renderQueue(queue): Child[] {
|
||||
{
|
||||
title: "Remove this task",
|
||||
onclick: () => {
|
||||
taskQueue.deleteTask(t);
|
||||
deleteTask(t);
|
||||
},
|
||||
},
|
||||
getIcon("remove")
|
||||
@@ -329,7 +358,7 @@ function renderNotifications(notifs): Child[] {
|
||||
},
|
||||
onbeforeremove: (vnode) => {
|
||||
(vnode.dom as HTMLDivElement).style.opacity = "0";
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500);
|
||||
@@ -347,8 +376,8 @@ function renderNotifications(notifs): Child[] {
|
||||
const component: ClosureComponent = (): Component => {
|
||||
return {
|
||||
view: (vnode) => {
|
||||
const queue = taskQueue.getQueue();
|
||||
const staging = taskQueue.getStaging();
|
||||
const queue = getQueue();
|
||||
const staging = getStaging();
|
||||
const notifs = notifications.getNotifications();
|
||||
|
||||
let drawerElement, statusElement;
|
||||
@@ -394,56 +423,55 @@ const component: ClosureComponent = (): Component => {
|
||||
title: "Commit queued tasks",
|
||||
disabled: !statusCount.queued,
|
||||
onclick: () => {
|
||||
const tasks = Array.from(taskQueue.getQueue()).filter(
|
||||
const tasks = Array.from(getQueue()).filter(
|
||||
(t) => t["status"] === "queued"
|
||||
);
|
||||
taskQueue
|
||||
.commit(
|
||||
tasks,
|
||||
(deviceId, err, connectionRequestStatus, tasks2) => {
|
||||
if (err) {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionRequestStatus !== "OK") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: ${connectionRequestStatus}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of tasks2) {
|
||||
if (t.status === "stale") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: No contact from device`
|
||||
);
|
||||
return;
|
||||
} else if (t.status === "fault") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: Task(s) faulted`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commit(
|
||||
tasks,
|
||||
(deviceId, err, connectionRequestStatus, tasks2) => {
|
||||
if (err) {
|
||||
notifications.push(
|
||||
"success",
|
||||
`${deviceId}: Task(s) committed`
|
||||
"error",
|
||||
`${deviceId}: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
)
|
||||
|
||||
if (connectionRequestStatus !== "OK") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: ${connectionRequestStatus}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of tasks2) {
|
||||
if (t.status === "stale") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: No contact from device`
|
||||
);
|
||||
return;
|
||||
} else if (t.status === "fault") {
|
||||
notifications.push(
|
||||
"error",
|
||||
`${deviceId}: Task(s) faulted`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
notifications.push(
|
||||
"success",
|
||||
`${deviceId}: Task(s) committed`
|
||||
);
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
notifications.push("error", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -453,7 +481,7 @@ const component: ClosureComponent = (): Component => {
|
||||
"button",
|
||||
{
|
||||
title: "Clear tasks",
|
||||
onclick: taskQueue.clear,
|
||||
onclick: clear,
|
||||
disabled: !queueElements.length,
|
||||
},
|
||||
"Clear"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
import * as notifications from "./notifications";
|
||||
|
||||
export let codeMirror;
|
||||
export let yaml;
|
||||
export let codeMirror: typeof import("codemirror");
|
||||
export let yaml: typeof import("yaml");
|
||||
|
||||
let note;
|
||||
|
||||
@@ -43,15 +43,13 @@ export function loadCodeMirror(): Promise<void> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const promises = [
|
||||
import(/* webpackChunkName: "codemirror" */ "codemirror"),
|
||||
import(
|
||||
/* webpackChunkName: "codemirror" */ "codemirror/mode/javascript/javascript"
|
||||
),
|
||||
import(/* webpackChunkName: "codemirror" */ "codemirror/mode/yaml/yaml"),
|
||||
import("codemirror"),
|
||||
import("codemirror/mode/javascript/javascript"),
|
||||
import("codemirror/mode/yaml/yaml"),
|
||||
];
|
||||
Promise.all(promises)
|
||||
.then((modules) => {
|
||||
codeMirror = modules[0];
|
||||
codeMirror = modules[0].default;
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -65,7 +63,7 @@ export function loadYaml(): Promise<void> {
|
||||
if (yaml) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
import(/* webpackChunkName: "yaml" */ "yaml")
|
||||
import("yaml")
|
||||
.then((module) => {
|
||||
yaml = module;
|
||||
resolve();
|
||||
|
||||
+13
-12
@@ -27,7 +27,7 @@ import * as notifications from "./notifications";
|
||||
import memoize from "../lib/common/memoize";
|
||||
import * as smartQuery from "./smart-query";
|
||||
import { map, parse, stringify } from "../lib/common/expression-parser";
|
||||
import { loadYaml, yaml } from "./dynamic-loader";
|
||||
import { stringify as yamlStringify } from "../lib/common/yaml";
|
||||
|
||||
const PAGE_SIZE = config.ui.pageSize || 10;
|
||||
|
||||
@@ -76,13 +76,7 @@ export function init(
|
||||
|
||||
const sort = args.hasOwnProperty("sort") ? "" + args["sort"] : "";
|
||||
const filter = args.hasOwnProperty("filter") ? "" + args["filter"] : "";
|
||||
return new Promise((resolve, reject) => {
|
||||
loadYaml()
|
||||
.then(() => {
|
||||
resolve({ filter, sort });
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
return Promise.resolve({ filter, sort });
|
||||
}
|
||||
|
||||
export const component: ClosureComponent = (): Component => {
|
||||
@@ -147,8 +141,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return m("a", { href: deviceHref }, fault["device"]);
|
||||
}
|
||||
|
||||
if (attr.id === "message")
|
||||
return m("long-text", { text: fault["message"] });
|
||||
|
||||
if (attr.id === "detail")
|
||||
return m("long-text", { text: yaml.stringify(fault["detail"]) });
|
||||
return m("long-text", { text: yamlStringify(fault["detail"]) });
|
||||
|
||||
if (attr.id === "timestamp")
|
||||
return new Date(fault["timestamp"]).toLocaleString();
|
||||
@@ -190,11 +187,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} faults deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -211,7 +208,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing faults"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [faults, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+106
-86
@@ -45,7 +45,6 @@ const attributes: {
|
||||
{
|
||||
id: "metadata.fileType",
|
||||
label: "Type",
|
||||
type: "combo",
|
||||
options: [
|
||||
"1 Firmware Upgrade Image",
|
||||
"2 Web Content",
|
||||
@@ -74,58 +73,32 @@ const unpackSmartQuery = memoize((query) => {
|
||||
});
|
||||
});
|
||||
|
||||
interface ValidationErrors {
|
||||
[prop: string]: string;
|
||||
}
|
||||
|
||||
function putActionHandler(action, _object): Promise<ValidationErrors> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const object = Object.assign({}, _object);
|
||||
if (action === "save") {
|
||||
const file = object["file"] ? object["file"][0] : null;
|
||||
delete object["file"];
|
||||
|
||||
if (!file) return void resolve({ file: "File not selected" });
|
||||
|
||||
const id = file.name;
|
||||
|
||||
store
|
||||
.resourceExists("files", id)
|
||||
.then((exists) => {
|
||||
if (exists) {
|
||||
store.fulfill(0, Date.now());
|
||||
return void resolve({ file: "File already exists" });
|
||||
}
|
||||
const headers = Object.assign(
|
||||
{
|
||||
"Content-Type": "application/octet-stream",
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
object
|
||||
);
|
||||
|
||||
store
|
||||
.xhrRequest({
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
url: `api/files/${encodeURIComponent(id)}`,
|
||||
serialize: (body) => body, // Identity function to prevent JSON.parse on blob data
|
||||
body: file,
|
||||
})
|
||||
.then(() => {
|
||||
notifications.push(
|
||||
"success",
|
||||
`File ${exists ? "updated" : "created"}`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
reject(new Error("Undefined action"));
|
||||
}
|
||||
function upload(
|
||||
file: File,
|
||||
headers: Record<string, string>,
|
||||
abortSignal?: AbortSignal,
|
||||
progressListener?: (e: ProgressEvent) => void
|
||||
): Promise<void> {
|
||||
headers = Object.assign(
|
||||
{
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
headers
|
||||
);
|
||||
return store.xhrRequest({
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
url: `api/files/${encodeURIComponent(file.name)}`,
|
||||
serialize: (body) => body, // Identity function to prevent JSON.parse on blob data
|
||||
body: file,
|
||||
config: (xhr) => {
|
||||
if (progressListener)
|
||||
xhr.upload.addEventListener("progress", progressListener);
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) xhr.abort();
|
||||
abortSignal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,6 +185,9 @@ export const component: ClosureComponent = (): Component => {
|
||||
attrs["sortAttributes"] = sortAttributes;
|
||||
attrs["onSortChange"] = onSortChange;
|
||||
attrs["downloadUrl"] = downloadUrl;
|
||||
attrs["recordActionsCallback"] = (file) => {
|
||||
return [m("a", { href: "api/blob/files/" + file["_id"] }, "Download")];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("files", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
@@ -221,38 +197,78 @@ export const component: ClosureComponent = (): Component => {
|
||||
{
|
||||
title: "Create new file",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
let cb: () => Children = null;
|
||||
const abortController = new AbortController();
|
||||
let progress = -1;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: async (action, _object) => {
|
||||
if (action !== "save")
|
||||
throw new Error("Undefined action");
|
||||
const object = Object.assign({}, _object);
|
||||
const file = object["file"]
|
||||
? object["file"][0]
|
||||
: null;
|
||||
delete object["file"];
|
||||
if (!file) {
|
||||
notifications.push("error", "File not selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await store.resourceExists("files", file.name)) {
|
||||
store.setTimestamp(Date.now());
|
||||
notifications.push("error", "File already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const progressListener = (e: ProgressEvent): void => {
|
||||
progress = e.loaded / e.total;
|
||||
m.redraw();
|
||||
};
|
||||
|
||||
progress = 0;
|
||||
try {
|
||||
await upload(
|
||||
file,
|
||||
object,
|
||||
abortController.signal,
|
||||
progressListener
|
||||
);
|
||||
store.setTimestamp(Date.now());
|
||||
notifications.push("success", "File created");
|
||||
overlay.close(cb);
|
||||
} catch (err) {
|
||||
notifications.push("error", err.message);
|
||||
}
|
||||
progress = -1;
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => {
|
||||
if (progress < 0) return [null, comp];
|
||||
return [
|
||||
m(
|
||||
"div.progress",
|
||||
m("div.progress-bar", {
|
||||
style: `width: ${Math.trunc(progress * 100)}%`,
|
||||
})
|
||||
),
|
||||
comp,
|
||||
];
|
||||
};
|
||||
overlay.open(cb);
|
||||
overlay.open(cb, () => {
|
||||
if (
|
||||
comp.state["current"]["modified"] &&
|
||||
!confirm("You have unsaved changes. Close anyway?")
|
||||
)
|
||||
return false;
|
||||
abortController.abort();
|
||||
return true;
|
||||
});
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -280,11 +296,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} files deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -302,7 +318,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing files"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [files, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,8 +44,12 @@ const getAutocomplete = memoize((resource) => {
|
||||
|
||||
function validateQuery(q: Expression): void {
|
||||
q = normalize(q);
|
||||
if (Array.isArray(q) && q[0] === "CASE") q = ["AND", ...q.slice(1)];
|
||||
filterToMongoQuery(q);
|
||||
if (Array.isArray(q) && q[0] === "CASE") {
|
||||
q = q.slice(1).filter((a) => Array.isArray(a));
|
||||
if (q.length > 1) q = ["AND", ...q];
|
||||
else q = q[0];
|
||||
}
|
||||
if (Array.isArray(q)) filterToMongoQuery(q);
|
||||
}
|
||||
|
||||
function parseFilter(resource, f): Expression {
|
||||
|
||||
@@ -27,6 +27,8 @@ interface Attribute {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
function renderTable(
|
||||
attributes: Attribute[],
|
||||
data: Record<string, any>[],
|
||||
@@ -37,7 +39,7 @@ function renderTable(
|
||||
onSortChange: (obj: Record<string, number>) => void,
|
||||
downloadUrl?: string,
|
||||
valueCallback?: (attr: Attribute, record: Record<string, any>) => Children,
|
||||
actionsCallback?: Children | ((selected: Set<string>) => Children),
|
||||
actionsCallback?: Children | ((sel: Set<string>) => Children),
|
||||
recordActionsCallback?: Children | ((record: Record<string, any>) => Children)
|
||||
): Children {
|
||||
const records = data || [];
|
||||
@@ -180,7 +182,8 @@ function renderTable(
|
||||
{
|
||||
title: "Show more records",
|
||||
onclick: showMoreCallback,
|
||||
disabled: !data.length || records.length >= total,
|
||||
disabled:
|
||||
!data.length || records.length >= Math.min(MAX_PAGE_SIZE, total),
|
||||
},
|
||||
"More"
|
||||
)
|
||||
|
||||
+8
-1
@@ -23,6 +23,8 @@ import drawerComponent from "./drawer-component";
|
||||
import userMenu from "./user-menu";
|
||||
import adminMenu from "./admin-menu";
|
||||
import * as overlay from "./overlay";
|
||||
import { version as VERSION } from "../package.json";
|
||||
import datalist from "./datalist";
|
||||
|
||||
const adminPages = [
|
||||
"presets",
|
||||
@@ -51,7 +53,11 @@ const component: ClosureComponent = (): Component => {
|
||||
|
||||
return [
|
||||
m("#header", [
|
||||
m("img.logo", { src: "logo.svg" }),
|
||||
m(
|
||||
"div.logo",
|
||||
m("img", { src: "logo.svg" }),
|
||||
m("span.version", "v" + VERSION)
|
||||
),
|
||||
m(userMenu),
|
||||
m(menu, attrs),
|
||||
m(drawerComponent),
|
||||
@@ -64,6 +70,7 @@ const component: ClosureComponent = (): Component => {
|
||||
])
|
||||
),
|
||||
overlay.render(),
|
||||
m(datalist),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ export const component: ClosureComponent = (): Component => {
|
||||
location.reload();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
notifications.push("error", err.response || err.message);
|
||||
e.target.disabled = false;
|
||||
});
|
||||
return false;
|
||||
|
||||
+15
-6
@@ -20,16 +20,24 @@
|
||||
import m, { Children } from "mithril";
|
||||
|
||||
type OverlayCallback = () => Children;
|
||||
type CloseCallback = () => boolean;
|
||||
|
||||
let overlayCallback: OverlayCallback = null;
|
||||
let closeCallback: CloseCallback = null;
|
||||
|
||||
export function open(callback: OverlayCallback): void {
|
||||
export function open(
|
||||
callback: OverlayCallback,
|
||||
closeCb: CloseCallback = null
|
||||
): void {
|
||||
overlayCallback = callback;
|
||||
closeCallback = closeCb;
|
||||
}
|
||||
|
||||
export function close(callback: OverlayCallback): boolean {
|
||||
export function close(callback: OverlayCallback, force = true): boolean {
|
||||
if (callback === overlayCallback) {
|
||||
if (!force && closeCallback && !closeCallback()) return false;
|
||||
overlayCallback = null;
|
||||
closeCallback = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -43,7 +51,7 @@ export function render(): Children {
|
||||
{
|
||||
tabindex: 0,
|
||||
onclick: () => {
|
||||
close(overlayCallback);
|
||||
close(overlayCallback, false);
|
||||
},
|
||||
style: "opacity: 0",
|
||||
oncreate: (vnode) => {
|
||||
@@ -52,7 +60,7 @@ export function render(): Children {
|
||||
},
|
||||
onbeforeremove: (vnode) => {
|
||||
(vnode.dom as HTMLDivElement).style.opacity = "0";
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500);
|
||||
@@ -75,9 +83,10 @@ export function render(): Children {
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw();
|
||||
if (overlayCallback && e.key === "Escape" && close(overlayCallback, false))
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
if (close(overlayCallback)) m.redraw();
|
||||
if (close(overlayCallback, false)) m.redraw();
|
||||
});
|
||||
+49
-40
@@ -117,7 +117,7 @@ function putActionHandler(action, _object): Promise<ValidationErrors> {
|
||||
.resourceExists("permissions", id)
|
||||
.then((exists) => {
|
||||
if (exists) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Permission already exists" });
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ function putActionHandler(action, _object): Promise<ValidationErrors> {
|
||||
.putResource("permissions", id, object)
|
||||
.then(() => {
|
||||
notifications.push("success", "Permission created");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
@@ -136,11 +136,11 @@ function putActionHandler(action, _object): Promise<ValidationErrors> {
|
||||
.deleteResource("permissions", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Permission deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
@@ -285,38 +285,43 @@ export const component: ClosureComponent = (): Component => {
|
||||
{
|
||||
title: "Create new permission",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -346,11 +351,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} permissions deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -368,7 +373,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing permissions"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [permissions, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+21
-15
@@ -116,21 +116,27 @@ function drawChart(chartData): Children {
|
||||
|
||||
legend.push(m("span.legend-total", `Total: ${total}`));
|
||||
|
||||
return m("div", { class: "pie-chart" }, [
|
||||
m(
|
||||
"svg",
|
||||
{
|
||||
// Adding 2 as padding; strokes must not be more than 2
|
||||
viewBox: "-102 -102 204 204",
|
||||
width: "204px",
|
||||
height: "204px",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
||||
},
|
||||
paths.concat(links)
|
||||
),
|
||||
m(".legend", legend),
|
||||
]);
|
||||
return m(
|
||||
"loading",
|
||||
{
|
||||
queries: Object.values(chartData.slices).map((s) => s["count"]),
|
||||
},
|
||||
m("div", { class: "pie-chart" }, [
|
||||
m(
|
||||
"svg",
|
||||
{
|
||||
// Adding 2 as padding; strokes must not be more than 2
|
||||
viewBox: "-102 -102 204 204",
|
||||
width: "204px",
|
||||
height: "204px",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
||||
},
|
||||
paths.concat(links)
|
||||
),
|
||||
m(".legend", legend),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const component: ClosureComponent = (): Component => {
|
||||
|
||||
+79
-64
@@ -86,12 +86,12 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.resourceExists("presets", id)
|
||||
.then((exists) => {
|
||||
if (exists && isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Preset already exists" });
|
||||
}
|
||||
|
||||
if (!exists && !isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Preset does not exist" });
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
"success",
|
||||
`Preset ${exists ? "updated" : "created"}`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
@@ -113,12 +113,12 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.deleteResource("presets", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Preset deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
} else {
|
||||
reject(new Error("Undefined action"));
|
||||
@@ -287,7 +287,38 @@ export const component: ClosureComponent = (): Component => {
|
||||
"a",
|
||||
{
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: preset,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = (): Children => {
|
||||
if (!preset.provision) {
|
||||
return m(
|
||||
"div",
|
||||
@@ -295,15 +326,37 @@ export const component: ClosureComponent = (): Component => {
|
||||
"This UI only supports presets with a single 'provision' configuraiton. If this preset was originally created from the old UI (genieacs-gui), you must edit it there."
|
||||
);
|
||||
}
|
||||
return comp;
|
||||
};
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
return m(
|
||||
if (window.authorizer.hasAccess("presets", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new preset",
|
||||
onclick: () => {
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: preset,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
@@ -326,55 +379,13 @@ export const component: ClosureComponent = (): Component => {
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("presets", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new preset",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -402,11 +413,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} presets deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -424,7 +435,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing presets"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [presets, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
+85
-65
@@ -66,12 +66,12 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.resourceExists("provisions", id)
|
||||
.then((exists) => {
|
||||
if (exists && isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Provision already exists" });
|
||||
}
|
||||
|
||||
if (!exists && !isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Provision does not exist" });
|
||||
}
|
||||
|
||||
@@ -82,10 +82,16 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
"success",
|
||||
`Provision ${exists ? "updated" : "created"}`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
.catch((err) => {
|
||||
if (err["code"] === 400 && err["response"]) {
|
||||
reject(new Error(err["response"]));
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
} else if (action === "delete") {
|
||||
@@ -93,11 +99,11 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.deleteResource("provisions", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Provision deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
@@ -207,15 +213,67 @@ export const component: ClosureComponent = (): Component => {
|
||||
"a",
|
||||
{
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: provision,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("provisions", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new provision",
|
||||
onclick: () => {
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: provision,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
@@ -238,55 +296,13 @@ export const component: ClosureComponent = (): Component => {
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("provisions", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new provision",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -316,11 +332,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} provisions deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -338,7 +354,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing provisions"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [provisions, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { VnodeDOM, ClosureComponent, Component, Children } from "mithril";
|
||||
import { m } from "./components";
|
||||
import codeEditorComponent from "./code-editor-component";
|
||||
import { getDatalistId } from "./datalist";
|
||||
|
||||
const singular = {
|
||||
presets: "preset",
|
||||
@@ -56,6 +57,7 @@ function createField(current, attr, focus): Children {
|
||||
: null,
|
||||
onchange: (e) => {
|
||||
current.object[attr.id] = e.target.value;
|
||||
current.modified = true;
|
||||
e.redraw = false;
|
||||
},
|
||||
},
|
||||
@@ -80,6 +82,7 @@ function createField(current, attr, focus): Children {
|
||||
if (e.target.checked) currentSelected.add(op);
|
||||
else currentSelected.delete(op);
|
||||
current.object[attr.id] = Array.from(currentSelected);
|
||||
current.modified = true;
|
||||
e.redraw = false;
|
||||
},
|
||||
};
|
||||
@@ -98,6 +101,7 @@ function createField(current, attr, focus): Children {
|
||||
},
|
||||
onChange: (value) => {
|
||||
current.object[attr.id] = value;
|
||||
current.modified = true;
|
||||
},
|
||||
};
|
||||
return m(codeEditorComponent, attrs);
|
||||
@@ -112,6 +116,7 @@ function createField(current, attr, focus): Children {
|
||||
: null,
|
||||
onchange: (e) => {
|
||||
current.object[attr.id] = e.target.files;
|
||||
current.modified = true;
|
||||
e.redraw = false;
|
||||
},
|
||||
});
|
||||
@@ -132,6 +137,7 @@ function createField(current, attr, focus): Children {
|
||||
: null,
|
||||
oninput: (e) => {
|
||||
current.object[attr.id] = e.target.value;
|
||||
current.modified = true;
|
||||
e.redraw = false;
|
||||
},
|
||||
onkeypress: (e) => {
|
||||
@@ -146,9 +152,14 @@ function createField(current, attr, focus): Children {
|
||||
});
|
||||
}
|
||||
|
||||
let datalist: string = null;
|
||||
if (attr.options) datalist = getDatalistId(attr.options);
|
||||
|
||||
return m("input", {
|
||||
type: attr.type === "password" ? "password" : "text",
|
||||
name: attr.id,
|
||||
list: datalist,
|
||||
autocomplete: datalist ? "off" : null,
|
||||
disabled: attr.id === "_id" && !current.isNew,
|
||||
value: current.object[attr.id],
|
||||
oncreate: focus
|
||||
@@ -158,6 +169,7 @@ function createField(current, attr, focus): Children {
|
||||
: null,
|
||||
oninput: (e) => {
|
||||
current.object[attr.id] = e.target.value;
|
||||
current.modified = true;
|
||||
e.redraw = false;
|
||||
},
|
||||
});
|
||||
@@ -174,6 +186,7 @@ const component: ClosureComponent = (): Component => {
|
||||
vnode.state["current"] = {
|
||||
isNew: !base["_id"],
|
||||
object: Object.assign({}, base),
|
||||
modified: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@
|
||||
|
||||
import config from "./config";
|
||||
import { Expression } from "../lib/types";
|
||||
import { encodeTag } from "../lib/common";
|
||||
|
||||
const resources = {
|
||||
devices: {},
|
||||
@@ -138,7 +139,7 @@ function queryMac(param, value): Expression {
|
||||
}
|
||||
|
||||
function queryTag(tag: string): Expression {
|
||||
const t = tag.replace(/[^a-zA-Z0-9-]+/g, "_");
|
||||
const t = encodeTag(tag);
|
||||
return ["IS NOT NULL", ["PARAM", `Tags.${t}`]];
|
||||
}
|
||||
|
||||
|
||||
+180
-176
@@ -34,6 +34,8 @@ const memoizedEvaluate = memoize(evaluate);
|
||||
let fulfillTimestamp = 0;
|
||||
let connectionNotification, configNotification, versionNotification;
|
||||
|
||||
let clockSkew = 0;
|
||||
|
||||
const queries = {
|
||||
filter: new WeakMap(),
|
||||
bookmark: new WeakMap(),
|
||||
@@ -76,14 +78,14 @@ for (const r of [
|
||||
}
|
||||
|
||||
class QueryResponse {
|
||||
public get fulfilled(): boolean {
|
||||
public get fulfilled(): number {
|
||||
queries.accessed.set(this, Date.now());
|
||||
return !!queries.fulfilled.get(this);
|
||||
return queries.fulfilled.get(this) || 0;
|
||||
}
|
||||
|
||||
public get fulfilling(): boolean {
|
||||
queries.accessed.set(this, Date.now());
|
||||
return queries.fulfilling.has(this);
|
||||
return !(queries.fulfilled.get(this) >= fulfillTimestamp);
|
||||
}
|
||||
|
||||
public get value(): any {
|
||||
@@ -93,11 +95,13 @@ class QueryResponse {
|
||||
}
|
||||
|
||||
function checkConnection(): void {
|
||||
const now1 = Date.now();
|
||||
m.request({
|
||||
url: "status",
|
||||
method: "GET",
|
||||
background: true,
|
||||
extract: (xhr) => {
|
||||
const now2 = Date.now();
|
||||
if (xhr.status !== 200) {
|
||||
if (!connectionNotification) {
|
||||
connectionNotification = notifications.push(
|
||||
@@ -112,6 +116,21 @@ function checkConnection(): void {
|
||||
connectionNotification = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const nowAvg = Math.trunc((now1 + now2) / 2);
|
||||
const skew = Date.parse(xhr.getResponseHeader("Date")) - nowAvg;
|
||||
if (Math.abs(skew - clockSkew) > 5000 && now2 - now1 < 1000) {
|
||||
clockSkew = skew;
|
||||
console.warn(
|
||||
`System and server clocks are out of sync. Adding ${clockSkew}ms offset to any client-side time relative calculations.`
|
||||
);
|
||||
setTimestamp(now2);
|
||||
m.redraw();
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore in case of missing or invalid Date header
|
||||
}
|
||||
|
||||
const configChanged =
|
||||
xhr.getResponseHeader("x-config-snapshot") !== configSnapshot;
|
||||
const versionChanged =
|
||||
@@ -173,10 +192,11 @@ export async function xhrRequest(
|
||||
|
||||
// https://mithril.js.org/request.html#error-handling
|
||||
if (xhr.status !== 304 && Math.floor(xhr.status / 100) !== 2) {
|
||||
if (xhr.status === 403) throw new Error("Not authorized");
|
||||
const err = new Error();
|
||||
err["message"] =
|
||||
xhr.status === 0
|
||||
? "Server is unrachable"
|
||||
? "Server is unreachable"
|
||||
: `Unexpected response status code ${xhr.status}`;
|
||||
err["code"] = xhr.status;
|
||||
err["response"] = xhr.responseText;
|
||||
@@ -208,7 +228,7 @@ export async function xhrRequest(
|
||||
|
||||
export function unpackExpression(exp: Expression): Expression {
|
||||
if (!Array.isArray(exp)) return exp;
|
||||
const e = memoizedEvaluate(exp, null, fulfillTimestamp);
|
||||
const e = memoizedEvaluate(exp, null, fulfillTimestamp + clockSkew);
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -294,7 +314,7 @@ function compareFunction(sort: {
|
||||
function findMatches(resourceType, filter, sort, limit): any[] {
|
||||
let value = [];
|
||||
for (const obj of resources[resourceType].objects.values())
|
||||
if (evaluate(filter, obj, fulfillTimestamp)) value.push(obj);
|
||||
if (evaluate(filter, obj, fulfillTimestamp + clockSkew)) value.push(obj);
|
||||
|
||||
value = value.sort(compareFunction(sort));
|
||||
if (limit) value = value.slice(0, limit);
|
||||
@@ -347,18 +367,7 @@ export function fetch(
|
||||
return queryResponse;
|
||||
}
|
||||
|
||||
function _fulfill(
|
||||
accessTimestamp: number,
|
||||
_fulfillTimestamp: number
|
||||
): Promise<boolean> {
|
||||
let updated = false;
|
||||
|
||||
if (_fulfillTimestamp > fulfillTimestamp) {
|
||||
for (const resource of Object.values(resources))
|
||||
resource.combinedFilter = null;
|
||||
fulfillTimestamp = _fulfillTimestamp;
|
||||
}
|
||||
|
||||
export function fulfill(accessTimestamp: number): void {
|
||||
const allPromises = [];
|
||||
|
||||
for (const [resourceType, resource] of Object.entries(resources)) {
|
||||
@@ -372,37 +381,32 @@ function _fulfill(
|
||||
|
||||
if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) {
|
||||
queries.fulfilling.add(queryResponse);
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = unpackExpression(filter);
|
||||
allPromises.push(
|
||||
new Promise((resolve, reject) => {
|
||||
updated = true;
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = unpackExpression(filter);
|
||||
xhrRequest({
|
||||
method: "HEAD",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(filter),
|
||||
}),
|
||||
extract: (xhr) => {
|
||||
if (!xhr.status) {
|
||||
throw new Error("Server is unrachable");
|
||||
} else if (xhr.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected response status code ${xhr.status}`
|
||||
);
|
||||
}
|
||||
return +xhr.getResponseHeader("x-total-count");
|
||||
},
|
||||
background: true,
|
||||
})
|
||||
.then((c) => {
|
||||
queries.value.set(queryResponse, c);
|
||||
queries.fulfilled.set(queryResponse, fulfillTimestamp);
|
||||
queries.fulfilling.delete(queryResponse);
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => reject(err));
|
||||
xhrRequest({
|
||||
method: "HEAD",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(filter),
|
||||
}),
|
||||
extract: (xhr) => {
|
||||
if (xhr.status === 403) throw new Error("Not authorized");
|
||||
if (!xhr.status) {
|
||||
throw new Error("Server is unreachable");
|
||||
} else if (xhr.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected response status code ${xhr.status}`
|
||||
);
|
||||
}
|
||||
return +xhr.getResponseHeader("x-total-count");
|
||||
},
|
||||
background: false,
|
||||
}).then((c) => {
|
||||
queries.value.set(queryResponse, c);
|
||||
queries.fulfilled.set(queryResponse, fulfillTimestamp);
|
||||
queries.fulfilling.delete(queryResponse);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -427,45 +431,39 @@ function _fulfill(
|
||||
const limit = queries.limit.get(queryResponse);
|
||||
const sort = queries.sort.get(queryResponse);
|
||||
if (limit) {
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = unpackExpression(filter);
|
||||
allPromises.push(
|
||||
new Promise((resolve, reject) => {
|
||||
updated = true;
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = unpackExpression(filter);
|
||||
xhrRequest({
|
||||
method: "GET",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(filter),
|
||||
limit: 1,
|
||||
skip: limit - 1,
|
||||
sort: JSON.stringify(sort),
|
||||
projection: Object.keys(sort).join(","),
|
||||
}),
|
||||
})
|
||||
.then((res) => {
|
||||
if ((res as any[]).length) {
|
||||
// Generate bookmark object
|
||||
const bm = Object.keys(sort).reduce((b, k) => {
|
||||
if (res[0][k] != null) {
|
||||
if (typeof res[0][k] === "object") {
|
||||
if (res[0][k].value != null)
|
||||
b[k] = res[0][k].value[0];
|
||||
} else {
|
||||
b[k] = res[0][k];
|
||||
}
|
||||
}
|
||||
|
||||
return b;
|
||||
}, {});
|
||||
queries.bookmark.set(queryResponse, bm);
|
||||
} else {
|
||||
queries.bookmark.delete(queryResponse);
|
||||
xhrRequest({
|
||||
method: "GET",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(filter),
|
||||
limit: 1,
|
||||
skip: limit - 1,
|
||||
sort: JSON.stringify(sort),
|
||||
projection: Object.keys(sort).join(","),
|
||||
}),
|
||||
background: true,
|
||||
}).then((res) => {
|
||||
if ((res as any[]).length) {
|
||||
// Generate bookmark object
|
||||
const bm = Object.keys(sort).reduce((b, k) => {
|
||||
if (res[0][k] != null) {
|
||||
if (typeof res[0][k] === "object") {
|
||||
if (res[0][k].value != null) b[k] = res[0][k].value[0];
|
||||
} else {
|
||||
b[k] = res[0][k];
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
|
||||
return b;
|
||||
}, {});
|
||||
queries.bookmark.set(queryResponse, bm);
|
||||
} else {
|
||||
queries.bookmark.delete(queryResponse);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -473,31 +471,88 @@ function _fulfill(
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Promise.all(allPromises)
|
||||
.then(() => {
|
||||
const allPromises2 = [];
|
||||
for (const [resourceType, toFetch] of Object.entries(toFetchAll)) {
|
||||
let combinedFilter = null;
|
||||
Promise.all(allPromises)
|
||||
.then(() => {
|
||||
let updated = false;
|
||||
const allPromises2 = [];
|
||||
for (const [resourceType, toFetch] of Object.entries(toFetchAll)) {
|
||||
let combinedFilter = null;
|
||||
|
||||
for (const queryResponse of toFetch) {
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = memoizedEvaluate(filter, null, fulfillTimestamp + clockSkew);
|
||||
const bookmark = queries.bookmark.get(queryResponse);
|
||||
const sort = queries.sort.get(queryResponse);
|
||||
if (bookmark) filter = limitFilter(filter, sort, bookmark);
|
||||
combinedFilter = or(combinedFilter, filter);
|
||||
}
|
||||
|
||||
const [union, diff] = unionDiff(
|
||||
resources[resourceType].combinedFilter,
|
||||
combinedFilter
|
||||
);
|
||||
|
||||
if (!diff) {
|
||||
for (const queryResponse of toFetch) {
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = memoizedEvaluate(filter, null, fulfillTimestamp);
|
||||
filter = memoizedEvaluate(
|
||||
filter,
|
||||
null,
|
||||
fulfillTimestamp + clockSkew
|
||||
);
|
||||
const limit = queries.limit.get(queryResponse);
|
||||
const bookmark = queries.bookmark.get(queryResponse);
|
||||
const sort = queries.sort.get(queryResponse);
|
||||
if (bookmark) filter = limitFilter(filter, sort, bookmark);
|
||||
combinedFilter = or(combinedFilter, filter);
|
||||
|
||||
queries.value.set(
|
||||
queryResponse,
|
||||
findMatches(resourceType, filter, sort, limit)
|
||||
);
|
||||
queries.fulfilled.set(queryResponse, fulfillTimestamp);
|
||||
queries.fulfilling.delete(queryResponse);
|
||||
updated = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [union, diff] = unionDiff(
|
||||
resources[resourceType].combinedFilter,
|
||||
combinedFilter
|
||||
);
|
||||
let deleted = new Set<string>();
|
||||
if (!resources[resourceType].combinedFilter)
|
||||
deleted = new Set(resources[resourceType].objects.keys());
|
||||
|
||||
const combinedFilterDiff = diff;
|
||||
resources[resourceType].combinedFilter = union;
|
||||
|
||||
allPromises2.push(
|
||||
xhrRequest({
|
||||
method: "GET",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(combinedFilterDiff),
|
||||
}),
|
||||
background: false,
|
||||
}).then((res) => {
|
||||
for (const r of res as any[]) {
|
||||
const id =
|
||||
resourceType === "devices"
|
||||
? r["DeviceID.ID"].value[0]
|
||||
: r["_id"];
|
||||
resources[resourceType].objects.set(id, r);
|
||||
deleted.delete(id);
|
||||
}
|
||||
|
||||
for (const d of deleted) {
|
||||
const obj = resources[resourceType].objects.get(d);
|
||||
if (
|
||||
evaluate(combinedFilterDiff, obj, fulfillTimestamp + clockSkew)
|
||||
)
|
||||
resources[resourceType].objects.delete(d);
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
for (const queryResponse of toFetch) {
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = memoizedEvaluate(filter, null, fulfillTimestamp);
|
||||
filter = unpackExpression(filter);
|
||||
const limit = queries.limit.get(queryResponse);
|
||||
const bookmark = queries.bookmark.get(queryResponse);
|
||||
const sort = queries.sort.get(queryResponse);
|
||||
@@ -510,87 +565,33 @@ function _fulfill(
|
||||
queries.fulfilled.set(queryResponse, fulfillTimestamp);
|
||||
queries.fulfilling.delete(queryResponse);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
updated = true;
|
||||
let deleted = new Set<string>();
|
||||
if (!resources[resourceType].combinedFilter)
|
||||
deleted = new Set(resources[resourceType].objects.keys());
|
||||
|
||||
const combinedFilterDiff = diff;
|
||||
resources[resourceType].combinedFilter = union;
|
||||
|
||||
allPromises2.push(
|
||||
new Promise((resolve2, reject2) => {
|
||||
xhrRequest({
|
||||
method: "GET",
|
||||
url:
|
||||
`api/${resourceType}/?` +
|
||||
m.buildQueryString({
|
||||
filter: memoizedStringify(combinedFilterDiff),
|
||||
}),
|
||||
})
|
||||
.then((res) => {
|
||||
for (const r of res as any[]) {
|
||||
const id =
|
||||
resourceType === "devices"
|
||||
? r["DeviceID.ID"].value[0]
|
||||
: r["_id"];
|
||||
resources[resourceType].objects.set(id, r);
|
||||
deleted.delete(id);
|
||||
}
|
||||
|
||||
for (const d of deleted) {
|
||||
const obj = resources[resourceType].objects.get(d);
|
||||
if (evaluate(combinedFilterDiff, obj, fulfillTimestamp))
|
||||
resources[resourceType].objects.delete(d);
|
||||
}
|
||||
|
||||
for (const queryResponse of toFetch) {
|
||||
let filter = queries.filter.get(queryResponse);
|
||||
filter = unpackExpression(filter);
|
||||
const limit = queries.limit.get(queryResponse);
|
||||
const bookmark = queries.bookmark.get(queryResponse);
|
||||
const sort = queries.sort.get(queryResponse);
|
||||
if (bookmark) filter = limitFilter(filter, sort, bookmark);
|
||||
|
||||
queries.value.set(
|
||||
queryResponse,
|
||||
findMatches(resourceType, filter, sort, limit)
|
||||
);
|
||||
queries.fulfilled.set(queryResponse, fulfillTimestamp);
|
||||
queries.fulfilling.delete(queryResponse);
|
||||
}
|
||||
resolve2();
|
||||
})
|
||||
.catch(reject2);
|
||||
})
|
||||
);
|
||||
}
|
||||
Promise.all(allPromises2)
|
||||
.then(() => resolve(updated))
|
||||
.catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function fulfill(
|
||||
accessTimestamp: number,
|
||||
_fulfillTimestamp: number,
|
||||
callback?: (updated: boolean) => void
|
||||
): void {
|
||||
_fulfill(accessTimestamp, _fulfillTimestamp).then(callback, (err) => {
|
||||
notifications.push("error", err.message);
|
||||
if (callback) callback(false);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
if (updated) m.redraw();
|
||||
return Promise.all(allPromises2);
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
export function getTimestamp(): number {
|
||||
return fulfillTimestamp;
|
||||
}
|
||||
|
||||
export function setTimestamp(t: number): void {
|
||||
if (t > fulfillTimestamp) {
|
||||
fulfillTimestamp = t;
|
||||
for (const resource of Object.values(resources))
|
||||
resource.combinedFilter = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getClockSkew(): number {
|
||||
return clockSkew;
|
||||
}
|
||||
|
||||
export function postTasks(
|
||||
deviceId: string,
|
||||
tasks: QueueTask[]
|
||||
@@ -605,6 +606,8 @@ export function postTasks(
|
||||
url: `api/devices/${encodeURIComponent(deviceId)}/tasks`,
|
||||
body: tasks,
|
||||
extract: (xhr) => {
|
||||
if (xhr.status === 403) throw new Error("Not authorized");
|
||||
if (!xhr.status) throw new Error("Server is unreachable");
|
||||
if (xhr.status !== 200) throw new Error(xhr.response);
|
||||
const connectionRequestStatus = xhr.getResponseHeader(
|
||||
"Connection-Request"
|
||||
@@ -675,7 +678,8 @@ export function resourceExists(resource: string, id: string): Promise<number> {
|
||||
filter: memoizedStringify(filter),
|
||||
}),
|
||||
extract: (xhr) => {
|
||||
if (!xhr.status) throw new Error("Server is unrachable");
|
||||
if (xhr.status === 403) throw new Error("Not authorized");
|
||||
if (!xhr.status) throw new Error("Server is unreachable");
|
||||
else if (xhr.status !== 200)
|
||||
throw new Error(`Unexpected response status code ${xhr.status}`);
|
||||
return +xhr.getResponseHeader("x-total-count");
|
||||
@@ -689,7 +693,7 @@ export function evaluateExpression(
|
||||
obj: Record<string, unknown>
|
||||
): Expression {
|
||||
if (!Array.isArray(exp)) return exp;
|
||||
return memoizedEvaluate(exp, obj, fulfillTimestamp);
|
||||
return memoizedEvaluate(exp, obj, fulfillTimestamp + clockSkew);
|
||||
}
|
||||
|
||||
export function changePassword(
|
||||
|
||||
+44
-13
@@ -19,23 +19,41 @@
|
||||
|
||||
import * as store from "./store";
|
||||
import { Task } from "../lib/types";
|
||||
import * as notifications from "./notifications";
|
||||
|
||||
export interface QueueTask extends Task {
|
||||
status?: string;
|
||||
device?: string;
|
||||
device: string;
|
||||
}
|
||||
|
||||
const queue: Set<QueueTask> = new Set();
|
||||
const staging: Set<QueueTask> = new Set();
|
||||
export interface StageTask extends Task {
|
||||
devices: string[];
|
||||
}
|
||||
|
||||
export function queueTask(task: QueueTask): void {
|
||||
staging.delete(task);
|
||||
task.status = "queued";
|
||||
queue.add(task);
|
||||
const MAX_QUEUE = 100;
|
||||
|
||||
const queue: Set<QueueTask> = new Set();
|
||||
const staging: Set<StageTask> = new Set();
|
||||
|
||||
function canQueue(tasks: QueueTask[]): boolean {
|
||||
let count = queue.size;
|
||||
for (const task of tasks) if (!queue.has(task)) ++count;
|
||||
return count <= MAX_QUEUE;
|
||||
}
|
||||
|
||||
export function queueTask(...tasks: QueueTask[]): void {
|
||||
if (!canQueue(tasks)) {
|
||||
notifications.push("error", "Too many tasks in queue");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
task.status = "queued";
|
||||
queue.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTask(task: QueueTask): void {
|
||||
staging.delete(task);
|
||||
queue.delete(task);
|
||||
}
|
||||
|
||||
@@ -47,7 +65,7 @@ export function clear(): void {
|
||||
queue.clear();
|
||||
}
|
||||
|
||||
export function getStaging(): Set<QueueTask> {
|
||||
export function getStaging(): Set<StageTask> {
|
||||
return staging;
|
||||
}
|
||||
|
||||
@@ -55,11 +73,19 @@ export function clearStaging(): void {
|
||||
staging.clear();
|
||||
}
|
||||
|
||||
export function stageSpv(task: QueueTask): void {
|
||||
export function stageSpv(task: StageTask): void {
|
||||
if (queue.size + task.devices.length > MAX_QUEUE) {
|
||||
notifications.push("error", "Too many tasks in queue");
|
||||
return;
|
||||
}
|
||||
staging.add(task);
|
||||
}
|
||||
|
||||
export function stageDownload(task: QueueTask): void {
|
||||
export function stageDownload(task: StageTask): void {
|
||||
if (queue.size + task.devices.length > MAX_QUEUE) {
|
||||
notifications.push("error", "Too many tasks in queue");
|
||||
return;
|
||||
}
|
||||
staging.add(task);
|
||||
}
|
||||
|
||||
@@ -69,14 +95,19 @@ export function commit(
|
||||
deviceId: string,
|
||||
err: Error,
|
||||
conReqStatus: string,
|
||||
tasks: QueueTask[]
|
||||
_tasks: QueueTask[]
|
||||
) => void
|
||||
): Promise<void> {
|
||||
const devices: { [deviceId: string]: any[] } = {};
|
||||
|
||||
if (!canQueue(tasks))
|
||||
return Promise.reject(new Error("Too many tasks in queue"));
|
||||
|
||||
for (const t of tasks) {
|
||||
devices[t.device] = devices[t.device] || [];
|
||||
devices[t.device].push(t);
|
||||
queueTask(t);
|
||||
t.status = "queued";
|
||||
queue.add(t);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -55,7 +55,8 @@ function putActionHandler(prefix: string[], dataYaml: string): Promise<any> {
|
||||
for (const f of res) current[f._id] = f.value;
|
||||
|
||||
const diff = configFunctions.diffConfig(current, updated);
|
||||
if (!diff.add.length && !diff.remove.length) return void resolve();
|
||||
if (!diff.add.length && !diff.remove.length)
|
||||
return void resolve(null);
|
||||
|
||||
const promises = [];
|
||||
|
||||
@@ -74,7 +75,7 @@ function putActionHandler(prefix: string[], dataYaml: string): Promise<any> {
|
||||
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
resolve();
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
@@ -102,12 +103,7 @@ const component: ClosureComponent = (): Component => {
|
||||
|
||||
const yamlString =
|
||||
config && Object.values(config).length
|
||||
? yaml.stringify(config, {
|
||||
schema: "failsafe",
|
||||
tags: (args) => {
|
||||
return args;
|
||||
},
|
||||
})
|
||||
? yaml.stringify(config, { schema: "failsafe" })
|
||||
: "";
|
||||
|
||||
const attrs = {
|
||||
@@ -120,6 +116,7 @@ const component: ClosureComponent = (): Component => {
|
||||
},
|
||||
onChange: (value) => {
|
||||
vnode.state["updatedYaml"] = value;
|
||||
vnode.state["modified"] = true;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+97
-82
@@ -85,12 +85,12 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.resourceExists("users", id)
|
||||
.then((exists) => {
|
||||
if (exists && isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "User already exists" });
|
||||
}
|
||||
|
||||
if (!exists && !isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "User does not exist" });
|
||||
}
|
||||
|
||||
@@ -102,14 +102,14 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.changePassword(id, password)
|
||||
.then(() => {
|
||||
notifications.push("success", "User created");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
notifications.push("success", "User updated");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
@@ -120,11 +120,11 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.deleteResource("users", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "User deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
@@ -237,46 +237,46 @@ export const component: ClosureComponent = (): Component => {
|
||||
"a",
|
||||
{
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
const children = [
|
||||
m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: {
|
||||
_id: user._id,
|
||||
roles: user.roles.split(","),
|
||||
},
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: {
|
||||
_id: user._id,
|
||||
roles: user.roles.split(","),
|
||||
},
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
resource: "users",
|
||||
attributes: attributes,
|
||||
}
|
||||
)
|
||||
),
|
||||
];
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
resource: "users",
|
||||
attributes: attributes,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
cb = () => {
|
||||
const children: Children = [comp];
|
||||
if (canWrite) {
|
||||
children.push(m("hr"));
|
||||
const _attrs = {
|
||||
@@ -292,7 +292,13 @@ export const component: ClosureComponent = (): Component => {
|
||||
|
||||
return children;
|
||||
};
|
||||
overlay.open(cb);
|
||||
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
@@ -317,38 +323,43 @@ export const component: ClosureComponent = (): Component => {
|
||||
{
|
||||
title: "Create new user",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -376,11 +387,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} users deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -398,7 +409,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing users"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [users, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,12 +66,12 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.resourceExists("virtualParameters", id)
|
||||
.then((exists) => {
|
||||
if (exists && isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Virtual parameter already exists" });
|
||||
}
|
||||
|
||||
if (!exists && !isNew) {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
return void resolve({ _id: "Virtual parameter does not exist" });
|
||||
}
|
||||
|
||||
@@ -82,10 +82,16 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
"success",
|
||||
`Virtual parameter ${exists ? "updated" : "created"}`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch(reject);
|
||||
.catch((err) => {
|
||||
if (err["code"] === 400 && err["response"]) {
|
||||
reject(new Error(err["response"]));
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
} else if (action === "delete") {
|
||||
@@ -93,11 +99,11 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
|
||||
.deleteResource("virtualParameters", object["_id"])
|
||||
.then(() => {
|
||||
notifications.push("success", "Virtual parameter deleted");
|
||||
store.fulfill(0, Date.now());
|
||||
resolve();
|
||||
store.setTimestamp(Date.now());
|
||||
resolve(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
@@ -207,15 +213,67 @@ export const component: ClosureComponent = (): Component => {
|
||||
"a",
|
||||
{
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: virtualParameter,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("virtualParameters", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new virtual parameter",
|
||||
onclick: () => {
|
||||
let cb: () => Children = null;
|
||||
const comp = m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
base: virtualParameter,
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, false)
|
||||
return new Promise<void>((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
@@ -238,55 +296,13 @@ export const component: ClosureComponent = (): Component => {
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
},
|
||||
},
|
||||
"Show"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
if (window.authorizer.hasAccess("virtualParameters", 3)) {
|
||||
attrs["actionsCallback"] = (selected: Set<string>): Children => {
|
||||
return [
|
||||
m(
|
||||
"button.primary",
|
||||
{
|
||||
title: "Create new virtual parameter",
|
||||
onclick: () => {
|
||||
const cb = (): Children => {
|
||||
return m(
|
||||
putFormComponent,
|
||||
Object.assign(
|
||||
{
|
||||
actionHandler: (action, object) => {
|
||||
return new Promise((resolve) => {
|
||||
putActionHandler(action, object, true)
|
||||
.then((errors) => {
|
||||
const errorList = errors
|
||||
? Object.values(errors)
|
||||
: [];
|
||||
if (errorList.length) {
|
||||
for (const err of errorList)
|
||||
notifications.push("error", err);
|
||||
} else {
|
||||
overlay.close(cb);
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
formData
|
||||
)
|
||||
);
|
||||
};
|
||||
overlay.open(cb);
|
||||
cb = () => comp;
|
||||
overlay.open(
|
||||
cb,
|
||||
() =>
|
||||
!comp.state["current"]["modified"] ||
|
||||
confirm("You have unsaved changes. Close anyway?")
|
||||
);
|
||||
},
|
||||
},
|
||||
"New"
|
||||
@@ -316,11 +332,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
"success",
|
||||
`${res.length} virtual parameters deleted`
|
||||
);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
})
|
||||
.catch((err) => {
|
||||
notifications.push("error", err.message);
|
||||
store.fulfill(0, Date.now());
|
||||
store.setTimestamp(Date.now());
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -338,7 +354,11 @@ export const component: ClosureComponent = (): Component => {
|
||||
return [
|
||||
m("h1", "Listing virtual parameters"),
|
||||
m(filterComponent, filterAttrs),
|
||||
m(indexTableComponent, attrs),
|
||||
m(
|
||||
"loading",
|
||||
{ queries: [virtualParameters, count] },
|
||||
m(indexTableComponent, attrs)
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user