mirror of
https://github.com/octoleo/syncthing.git
synced 2024-11-08 22:31:04 +00:00
newgui: Merge separate repo into syncthing/syncthing
Co-authored-by: Audrius Butkevicius <audrius.butkevicius@gmail.com> Co-authored-by: Simon Frei <freisim93@gmail.com>
This commit is contained in:
commit
0471daf771
13
newgui/.editorconfig
Normal file
13
newgui/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
46
newgui/.gitignore
vendored
Normal file
46
newgui/.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Only exists if Bazel was run
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events*.json
|
||||
speed-measure-plugin*.json
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
373
newgui/LICENSE
Normal file
373
newgui/LICENSE
Normal file
@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
65
newgui/README.md
Normal file
65
newgui/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Syncthing Tech UI
|
||||
|
||||
## Usage
|
||||
|
||||
This is a very bare bones read-only GUI for viewing the status of large
|
||||
setups. Download a [release
|
||||
zip](https://github.com/kastelo/syncthing-tech-ui/releases) and unpack it
|
||||
into the GUI override directory (assuming default Linux setup):
|
||||
|
||||
```
|
||||
$ cd ~/.config/syncthing
|
||||
$ mkdir -p gui/default
|
||||
$ cd gui/default
|
||||
$ unzip ~/tech-ui-v1.0.0.zip
|
||||
```
|
||||
|
||||
Then load the GUI via http://localhost:8384/tech-ui/ or similar. You should see something like this:
|
||||
|
||||
![Screenshot](screenshot.png)
|
||||
|
||||
## Development server
|
||||
|
||||
Run `npm run serve` for a dev server. Navigate to `http://localhost:4200/`. The
|
||||
app will automatically reload if you change any of the source files.
|
||||
|
||||
## Production server
|
||||
|
||||
In production we serve the UI through Syncthing itself. The easiest way to
|
||||
do that is to simply put the built assets in the `gui` subdirectory of
|
||||
Syncthing's config directory.
|
||||
|
||||
```
|
||||
$ npm run build -- --prod
|
||||
$ rsync -va --delete dist/tech-ui/ ~/.config/syncthing/gui/default/tech-ui/
|
||||
```
|
||||
|
||||
Adjust for your actual Syncthing config dir if different. Navigate to
|
||||
`http://localhost:8384/tech-ui/`.
|
||||
|
||||
Another option is to start Syncthing with the STGUIASSETS environment
|
||||
variable pointing to the distribution directory.
|
||||
|
||||
```
|
||||
$ npm run build -- --prod
|
||||
$ ln -sf . dist/default
|
||||
$ export STGUIASSETS=$(pwd)/dist
|
||||
$ syncthing
|
||||
```
|
||||
|
||||
The magic is symlink is because Syncthing will look for the GUI in the
|
||||
`default` subdirectory. Navigate to `http://localhost:8384/tech-ui/`.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You
|
||||
can also use `ng generate
|
||||
directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## License
|
||||
|
||||
MPLv2
|
||||
|
||||
## Copyright
|
||||
|
||||
Copyright (c) 2020 The Syncthing Authors
|
128
newgui/angular.json
Normal file
128
newgui/angular.json
Normal file
@ -0,0 +1,128 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"tech-ui": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/tech-ui",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "tech-ui:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "tech-ui:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "tech-ui:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.spec.json",
|
||||
"e2e/tsconfig.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "tech-ui:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "tech-ui:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}},
|
||||
"defaultProject": "tech-ui"
|
||||
}
|
12
newgui/browserslist
Normal file
12
newgui/browserslist
Normal file
@ -0,0 +1,12 @@
|
||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
# You can see what browsers were selected by your queries by running:
|
||||
# npx browserslist
|
||||
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
not IE 9-11 # For IE 9-11 support, remove 'not'.
|
32
newgui/e2e/protractor.conf.js
Normal file
32
newgui/e2e/protractor.conf.js
Normal file
@ -0,0 +1,32 @@
|
||||
// @ts-check
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
/**
|
||||
* @type { import("protractor").Config }
|
||||
*/
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
browserName: 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
23
newgui/e2e/src/app.e2e-spec.ts
Normal file
23
newgui/e2e/src/app.e2e-spec.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getTitleText()).toEqual('tech-ui app is running!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
11
newgui/e2e/src/app.po.ts
Normal file
11
newgui/e2e/src/app.po.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo(): Promise<unknown> {
|
||||
return browser.get(browser.baseUrl) as Promise<unknown>;
|
||||
}
|
||||
|
||||
getTitleText(): Promise<string> {
|
||||
return element(by.css('app-root .content span')).getText() as Promise<string>;
|
||||
}
|
||||
}
|
13
newgui/e2e/tsconfig.json
Normal file
13
newgui/e2e/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
32
newgui/karma.conf.js
Normal file
32
newgui/karma.conf.js
Normal file
@ -0,0 +1,32 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/tech-ui'),
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
17151
newgui/package-lock.json
generated
Normal file
17151
newgui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
newgui/package.json
Normal file
53
newgui/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "tech-ui",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^9.1.0",
|
||||
"@angular/cdk": "^9.2.0",
|
||||
"@angular/common": "^9.1.0",
|
||||
"@angular/compiler": "^9.1.0",
|
||||
"@angular/core": "^9.1.0",
|
||||
"@angular/flex-layout": "^9.0.0-beta.29",
|
||||
"@angular/forms": "^9.1.0",
|
||||
"@angular/material": "^9.2.0",
|
||||
"@angular/platform-browser": "^9.1.0",
|
||||
"@angular/platform-browser-dynamic": "^9.1.0",
|
||||
"@angular/router": "^9.1.0",
|
||||
"angular-in-memory-web-api": "^0.10.0",
|
||||
"chart.js": "^2.9.3",
|
||||
"component": "^1.1.0",
|
||||
"rxjs": "^6.5.5",
|
||||
"tslib": "^1.10.0",
|
||||
"zone.js": "^0.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^0.901.0",
|
||||
"@angular/cli": "^9.1.0",
|
||||
"@angular/compiler-cli": "^9.1.0",
|
||||
"@angular/language-service": "^9.1.0",
|
||||
"@types/jasmine": "^3.5.10",
|
||||
"@types/jasminewd2": "~2.0.3",
|
||||
"@types/node": "^12.12.34",
|
||||
"codelyzer": "^5.2.2",
|
||||
"jasmine-core": "~3.5.0",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "~4.3.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "~2.1.0",
|
||||
"karma-jasmine": "~2.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.5.3",
|
||||
"protractor": "~5.4.3",
|
||||
"ts-node": "~8.3.0",
|
||||
"tslint": "~5.18.0",
|
||||
"typescript": "~3.7.5"
|
||||
}
|
||||
}
|
BIN
newgui/screenshot.png
Normal file
BIN
newgui/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 734 KiB |
9
newgui/src/app/api-utils.ts
Normal file
9
newgui/src/app/api-utils.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { environment } from '../environments/environment'
|
||||
|
||||
export const deviceID = (): String => {
|
||||
const dID: String = environment.production ? globalThis.metadata['deviceID'] : '12345';
|
||||
return dID.substring(0, 5)
|
||||
}
|
||||
|
||||
export const apiURL: String = '/'
|
||||
export const apiRetry: number = 3;
|
11
newgui/src/app/app-routing.module.ts
Normal file
11
newgui/src/app/app-routing.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
|
||||
const routes: Routes = [];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
1
newgui/src/app/app.component.html
Normal file
1
newgui/src/app/app.component.html
Normal file
@ -0,0 +1 @@
|
||||
<app-dashboard></app-dashboard>
|
9
newgui/src/app/app.component.scss
Normal file
9
newgui/src/app/app.component.scss
Normal file
@ -0,0 +1,9 @@
|
||||
/* Structure */
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-form-field {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
22
newgui/src/app/app.component.spec.ts
Normal file
22
newgui/src/app/app.component.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});
|
10
newgui/src/app/app.component.ts
Normal file
10
newgui/src/app/app.component.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor() { }
|
||||
}
|
87
newgui/src/app/app.module.ts
Normal file
87
newgui/src/app/app.module.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatListModule } from '@angular/material/list'
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FlexLayoutModule } from '@angular/flex-layout';
|
||||
|
||||
import { httpInterceptorProviders } from './http-interceptors';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
import { StatusListComponent } from './lists/status-list/status-list.component';
|
||||
import { DeviceListComponent } from './lists/device-list/device-list.component';
|
||||
import { DonutChartComponent } from './charts/donut-chart/donut-chart.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { ListToggleComponent } from './list-toggle/list-toggle.component';
|
||||
|
||||
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
|
||||
import { InMemoryConfigDataService } from './services/in-memory-config-data.service';
|
||||
|
||||
import { deviceID } from './api-utils';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ChartItemComponent } from './charts/chart-item/chart-item.component';
|
||||
import { ChartComponent } from './charts/chart/chart.component';
|
||||
import { FolderListComponent } from './lists/folder-list/folder-list.component';
|
||||
import { DialogComponent } from './dialog/dialog.component';
|
||||
import { CardComponent, CardTitleComponent, CardContentComponent } from './card/card.component';
|
||||
import { TrimPipe } from './trim.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
StatusListComponent,
|
||||
DeviceListComponent,
|
||||
ListToggleComponent,
|
||||
DashboardComponent,
|
||||
DonutChartComponent,
|
||||
ChartComponent,
|
||||
ChartItemComponent,
|
||||
FolderListComponent,
|
||||
DialogComponent,
|
||||
CardComponent,
|
||||
CardTitleComponent,
|
||||
CardContentComponent,
|
||||
TrimPipe,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
BrowserAnimationsModule,
|
||||
MatInputModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatButtonToggleModule,
|
||||
MatCardModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
MatListModule,
|
||||
MatButtonModule,
|
||||
FlexLayoutModule,
|
||||
HttpClientModule,
|
||||
HttpClientXsrfModule.withOptions({
|
||||
headerName: 'X-CSRF-Token-' + deviceID(),
|
||||
cookieName: 'CSRF-Token-' + deviceID(),
|
||||
}),
|
||||
environment.production ?
|
||||
[] : HttpClientInMemoryWebApiModule.forRoot(InMemoryConfigDataService,
|
||||
{ dataEncapsulation: false, delay: 10 }),
|
||||
],
|
||||
providers: [httpInterceptorProviders],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
|
||||
export class AppModule { }
|
||||
|
||||
|
37
newgui/src/app/card/card.component.scss
Normal file
37
newgui/src/app/card/card.component.scss
Normal file
@ -0,0 +1,37 @@
|
||||
// Import theming functions
|
||||
@import '~@angular/material/theming';
|
||||
|
||||
@mixin tui-card-theme($theme) {
|
||||
// Extract the palettes you need from the theme definition.
|
||||
$primary: map-get($theme, primary);
|
||||
$accent: map-get($theme, accent);
|
||||
$background: map-get($theme, background);
|
||||
$foreground: map-get($theme, foreground);
|
||||
|
||||
.tui-card {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tui-card-title {
|
||||
padding: 16px 16px 0 16px;
|
||||
font-size: mat-font-size($tech-ui-typography, subheading-2);
|
||||
font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif;
|
||||
color: mat-color($primary);
|
||||
}
|
||||
|
||||
.tui-card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tui-button-toggle .mat-button-toggle-appearance-standard .mat-button-toggle-label-content {
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tui-card {
|
||||
background-color: map_get($mat-grey, 800);
|
||||
}
|
||||
}
|
||||
}
|
25
newgui/src/app/card/card.component.spec.ts
Normal file
25
newgui/src/app/card/card.component.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CardComponent } from './card.component';
|
||||
|
||||
describe('CardComponent', () => {
|
||||
let component: CardComponent;
|
||||
let fixture: ComponentFixture<CardComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CardComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
35
newgui/src/app/card/card.component.ts
Normal file
35
newgui/src/app/card/card.component.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { cardElevation } from '../style';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card',
|
||||
template: '<div class="{{elevation}} tui-card"><ng-content></ng-content></div>',
|
||||
styleUrls: ['./card.component.scss']
|
||||
})
|
||||
export class CardComponent implements OnInit {
|
||||
elevation: string = cardElevation;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-title',
|
||||
template: '<div class="tui-card-title"><ng-content></ng-content></div>',
|
||||
styleUrls: ['./card.component.scss']
|
||||
})
|
||||
export class CardTitleComponent {
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-content',
|
||||
template: '<div class="tui-card-content"><ng-content></ng-content></div>',
|
||||
styleUrls: ['./card.component.scss']
|
||||
})
|
||||
export class CardContentComponent {
|
||||
constructor() { }
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<div fxLayout="row" fxLayoutAlign="space-between start" [ngClass]="(_selected)?'item selected':'item'">
|
||||
<div><a href="#">{{state}}</a>: </div>
|
||||
<div>{{count}}</div>
|
||||
</div>
|
27
newgui/src/app/charts/chart-item/chart-item.component.scss
Normal file
27
newgui/src/app/charts/chart-item/chart-item.component.scss
Normal file
@ -0,0 +1,27 @@
|
||||
@mixin chart-item-theme($theme) {
|
||||
.item {
|
||||
cursor: pointer;
|
||||
padding: 3px 7px 3px 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #DDDDDD;
|
||||
color: #303030;
|
||||
}
|
||||
.selected a {
|
||||
color: #303030;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.selected {
|
||||
background-color: map_get($mat-grey, 900);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.selected a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ChartItemComponent } from './chart-item.component';
|
||||
|
||||
describe('ChartItemComponent', () => {
|
||||
let component: ChartItemComponent;
|
||||
let fixture: ComponentFixture<ChartItemComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ ChartItemComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ChartItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
19
newgui/src/app/charts/chart-item/chart-item.component.ts
Normal file
19
newgui/src/app/charts/chart-item/chart-item.component.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chart-item',
|
||||
templateUrl: './chart-item.component.html',
|
||||
styleUrls: ['./chart-item.component.scss']
|
||||
})
|
||||
export class ChartItemComponent {
|
||||
@Input() state: string;
|
||||
@Input() count: number;
|
||||
@Input('selected')
|
||||
set selected(s: boolean) {
|
||||
this._selected = s;
|
||||
}
|
||||
|
||||
_selected: boolean = true;
|
||||
|
||||
constructor() { }
|
||||
}
|
14
newgui/src/app/charts/chart/chart.component.html
Normal file
14
newgui/src/app/charts/chart/chart.component.html
Normal file
@ -0,0 +1,14 @@
|
||||
<app-card>
|
||||
<app-card-title>{{title | uppercase}}</app-card-title>
|
||||
<app-card-content>
|
||||
<div fxLayout="row" fxLayoutAlign="space-between stretch">
|
||||
<app-donut-chart [elementID]="chartID" fxFlex="30" [title]="title" (stateEvent)="onItemSelect($event)">
|
||||
</app-donut-chart>
|
||||
<div class=" items" fxLayout="column" fxLayoutAlign="start end" fxFlex="70">
|
||||
<app-chart-item *ngFor="let state of states" (click)="onItemSelect(state)" [state]="state.label"
|
||||
[count]="state.count" [selected]="state.selected">
|
||||
</app-chart-item>
|
||||
</div>
|
||||
</div>
|
||||
</app-card-content>
|
||||
</app-card>
|
0
newgui/src/app/charts/chart/chart.component.scss
Normal file
0
newgui/src/app/charts/chart/chart.component.scss
Normal file
26
newgui/src/app/charts/chart/chart.component.spec.ts
Normal file
26
newgui/src/app/charts/chart/chart.component.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { async, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ChartComponent } from './chart.component';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
class MockService {
|
||||
getEach() {
|
||||
// unimplemented
|
||||
}
|
||||
};
|
||||
|
||||
describe('ChartComponent', () => {
|
||||
let component: ChartComponent;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [ChartComponent]
|
||||
}).compileComponents();
|
||||
component = TestBed.inject(ChartComponent);
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
115
newgui/src/app/charts/chart/chart.component.ts
Normal file
115
newgui/src/app/charts/chart/chart.component.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Component, OnInit, ViewChild, Input, Type } from '@angular/core';
|
||||
import Folder from '../../folder'
|
||||
import { FolderService } from 'src/app/services/folder.service';
|
||||
import { DonutChartComponent } from '../donut-chart/donut-chart.component';
|
||||
import { DeviceService } from 'src/app/services/device.service';
|
||||
import Device from 'src/app/device';
|
||||
import { StType } from '../../type';
|
||||
import { FilterService } from 'src/app/services/filter.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
||||
export interface ChartItemState {
|
||||
label: string,
|
||||
count: number,
|
||||
color: string,
|
||||
selected: boolean,
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-chart',
|
||||
templateUrl: './chart.component.html',
|
||||
styleUrls: ['./chart.component.scss']
|
||||
})
|
||||
|
||||
export class ChartComponent implements OnInit {
|
||||
@ViewChild(DonutChartComponent) donutChart: DonutChartComponent;
|
||||
@Input() type: StType;
|
||||
title: string;
|
||||
chartID: string;
|
||||
states: ChartItemState[] = [];
|
||||
|
||||
private observer: Observable<any>;
|
||||
private activeChartState: ChartItemState;
|
||||
|
||||
constructor(
|
||||
private folderService: FolderService,
|
||||
private deviceService: DeviceService,
|
||||
private filterService: FilterService,
|
||||
) { }
|
||||
|
||||
onItemSelect(s: ChartItemState) {
|
||||
// Send chart item state to filter
|
||||
this.filterService.changeFilter({ type: this.type, text: s.label });
|
||||
|
||||
// Deselect all other items
|
||||
this.states.forEach(s => {
|
||||
s.selected = false;
|
||||
});
|
||||
|
||||
// Select item only
|
||||
if (s !== this.activeChartState) {
|
||||
s.selected = true;
|
||||
this.activeChartState = s;
|
||||
} else {
|
||||
this.activeChartState = null;
|
||||
this.filterService.changeFilter({ type: this.type, text: "" })
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
switch (this.type) {
|
||||
case StType.Folder:
|
||||
this.title = "Folders";
|
||||
this.chartID = 'foldersChart';
|
||||
this.observer = this.folderService.folderAdded$;
|
||||
break;
|
||||
case StType.Device:
|
||||
this.title = "Devices";
|
||||
this.chartID = 'devicesChart';
|
||||
this.observer = this.deviceService.deviceAdded$;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
let totalCount: number = 0;
|
||||
this.observer.subscribe(
|
||||
t => {
|
||||
// Count the number of folders and set chart
|
||||
totalCount++;
|
||||
this.donutChart.count = totalCount;
|
||||
|
||||
// Get StateType and convert to string
|
||||
const stateType = t.stateType;
|
||||
const state = t.state;
|
||||
let color;
|
||||
switch (this.type) {
|
||||
case StType.Folder:
|
||||
color = Folder.stateTypeToColor(t.stateType);
|
||||
break;
|
||||
case StType.Device:
|
||||
color = Device.stateTypeToColor(stateType);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if state exists
|
||||
let found: boolean = false;
|
||||
this.states.forEach(s => {
|
||||
if (s.label === state) {
|
||||
s.count = s.count + 1;
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
this.states.push({ label: state, count: 1, color: color, selected: false });
|
||||
}
|
||||
|
||||
this.donutChart.updateData(this.states);
|
||||
},
|
||||
err => console.error('Observer got an error: ' + err),
|
||||
() => {
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
<div class="chart-container">
|
||||
<canvas id={{elementID}} width="100px" height="100px"></canvas>
|
||||
<div class="center" fxLayout="column" fxLayoutAlign="center center">
|
||||
<div class="{{_countClass}}">{{_count}}</div>
|
||||
<div class="title">{{title}}</div>
|
||||
</div>
|
||||
</div>
|
48
newgui/src/app/charts/donut-chart/donut-chart.component.scss
Normal file
48
newgui/src/app/charts/donut-chart/donut-chart.component.scss
Normal file
@ -0,0 +1,48 @@
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom: 0; right: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
overflow: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: calc(0.5rem + 0.625vw);
|
||||
display:none;
|
||||
}
|
||||
|
||||
.count-total {
|
||||
font-size: calc(1rem + 0.625vw);
|
||||
}
|
||||
|
||||
.large-count-total {
|
||||
font-size: calc(0.5rem + 0.625vw);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.count-total {
|
||||
font-size: calc(1.00rem + 0.625vw);
|
||||
}
|
||||
}
|
||||
@media (min-width: 800px) and (max-width: 1000px) {
|
||||
.title {
|
||||
font-size: calc(0.35rem + 0.625vw);
|
||||
}
|
||||
|
||||
.count-total {
|
||||
font-size: calc(1.35rem + 0.625vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width:1000px) {
|
||||
.title {
|
||||
display: inline;
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DonutChartComponent } from './donut-chart.component';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('DonutChartComponent', () => {
|
||||
let component: DonutChartComponent;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [DonutChartComponent],
|
||||
providers: [DonutChartComponent]
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(DonutChartComponent);
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
87
newgui/src/app/charts/donut-chart/donut-chart.component.ts
Normal file
87
newgui/src/app/charts/donut-chart/donut-chart.component.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Chart } from 'chart.js'
|
||||
import { tooltip } from '../tooltip'
|
||||
import { FilterService } from 'src/app/services/filter.service';
|
||||
import { ChartItemState } from '../chart/chart.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-donut-chart',
|
||||
templateUrl: './donut-chart.component.html',
|
||||
styleUrls: ['./donut-chart.component.scss']
|
||||
})
|
||||
export class DonutChartComponent {
|
||||
@Input() elementID: string;
|
||||
@Input() title: number;
|
||||
@Output() stateEvent = new EventEmitter<ChartItemState>();;
|
||||
|
||||
_count: number;
|
||||
_countClass = "count-total";
|
||||
set count(n: number) {
|
||||
if (n >= 1000) { // use a smaller font
|
||||
this._countClass = "large-count-total"
|
||||
}
|
||||
this._count = n;
|
||||
}
|
||||
|
||||
private canvas: any;
|
||||
private ctx: any;
|
||||
private chart: Chart;
|
||||
private states: ChartItemState[];
|
||||
|
||||
constructor(private filterService: FilterService) { }
|
||||
|
||||
updateData(states: ChartItemState[]): void {
|
||||
this.states = states;
|
||||
// Using object destructuring
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
let s = states[i];
|
||||
this.chart.data.labels[i] = s.label;
|
||||
this.chart.data.datasets[0].data[i] = s.count;
|
||||
this.chart.data.datasets[0].backgroundColor[i] = s.color;
|
||||
}
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
removeAllData(withAnimation: boolean): void {
|
||||
this.chart.data.labels.pop();
|
||||
this.chart.data.datasets.forEach((dataset) => {
|
||||
dataset.data = [];
|
||||
});
|
||||
this.chart.update(withAnimation);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.canvas = document.getElementById(this.elementID);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.chart = new Chart(this.ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
cutoutPercentage: 77,
|
||||
responsive: true,
|
||||
onClick: (e) => {
|
||||
var activePoints = this.chart.getElementsAtEvent(e);
|
||||
if (activePoints.length > 0) {
|
||||
const index = activePoints[0]["_index"];
|
||||
this.stateEvent.emit(this.states[index]);
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltips: {
|
||||
// Disable the on-canvas tooltip
|
||||
enabled: false,
|
||||
custom: tooltip(),
|
||||
},
|
||||
animation: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
62
newgui/src/app/charts/tooltip.ts
Normal file
62
newgui/src/app/charts/tooltip.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// Adapted from https://www.chartjs.org/samples/latest/tooltips/custom-pie.html
|
||||
export let tooltip: () => (tooltip: any) => void =
|
||||
function (): (tooltip: any) => void {
|
||||
return function (tooltip: any): void {
|
||||
// Tooltip Element
|
||||
const tooltipEl = document.getElementById('chartjs-tooltip');
|
||||
|
||||
// Hide if no tooltip
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Set caret Position
|
||||
tooltipEl.classList.remove('above', 'below', 'no-transform');
|
||||
if (tooltip.yAlign) {
|
||||
tooltipEl.classList.add(tooltip.yAlign);
|
||||
} else {
|
||||
tooltipEl.classList.add('no-transform');
|
||||
}
|
||||
|
||||
function getBody(bodyItem) {
|
||||
return bodyItem.lines;
|
||||
}
|
||||
|
||||
// Set Text
|
||||
if (tooltip.body) {
|
||||
let titleLines = tooltip.title || [];
|
||||
const bodyLines = tooltip.body.map(getBody);
|
||||
|
||||
let innerHtml = '<thead>';
|
||||
|
||||
titleLines.forEach(function (title) {
|
||||
innerHtml += '<tr><th>' + title + '</th></tr>';
|
||||
});
|
||||
innerHtml += '</thead><tbody>';
|
||||
|
||||
bodyLines.forEach(function (body, i) {
|
||||
let colors = tooltip.labelColors[i];
|
||||
let style = 'background:' + colors.backgroundColor;
|
||||
style += '; border-color:' + colors.borderColor;
|
||||
style += '; border-width: 2px';
|
||||
let span = '<span class="chartjs-tooltip-key" style="' + style + '"></span>';
|
||||
innerHtml += '<tr><td>' + span + body + '</td></tr>';
|
||||
});
|
||||
innerHtml += '</tbody>';
|
||||
|
||||
let tableRoot = tooltipEl.querySelector('table');
|
||||
tableRoot.innerHTML = innerHtml;
|
||||
}
|
||||
|
||||
var position = this._chart.canvas.getBoundingClientRect();
|
||||
|
||||
// Display, position, and set styles for font
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.position = 'absolute';
|
||||
tooltipEl.style.left = position.left + window.pageXOffset + tooltip.caretX + 'px';
|
||||
tooltipEl.style.top = position.top + window.pageYOffset + tooltip.caretY + 'px';
|
||||
tooltipEl.style.padding = tooltip.yPadding + 'px ' + tooltip.xPadding + 'px';
|
||||
tooltipEl.style.pointerEvents = 'none';
|
||||
}
|
||||
};
|
7
newgui/src/app/completion.ts
Normal file
7
newgui/src/app/completion.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Completion {
|
||||
completion: number;
|
||||
globalBytes: number;
|
||||
needBytes: number;
|
||||
needDeletes: number;
|
||||
needItems: number;
|
||||
}
|
16
newgui/src/app/connections.ts
Normal file
16
newgui/src/app/connections.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export interface SystemConnections {
|
||||
connections: { deviceId?: Connection };
|
||||
total: Connection;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
address: string;
|
||||
at: string;
|
||||
clientVersion: string;
|
||||
connected: boolean;
|
||||
crypto: string;
|
||||
inBytesTotal: number;
|
||||
outBytesTotal: number;
|
||||
paused: boolean;
|
||||
type: string;
|
||||
}
|
21
newgui/src/app/dashboard/dashboard.component.html
Normal file
21
newgui/src/app/dashboard/dashboard.component.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!--<div class="grid-container" gdAreas="header header | folders devices | status-list status-list | footer footer"
|
||||
gdGap="16px" gdRows="auto auto auto"> -->
|
||||
<!--<div class="grid-container" fxLayout="row" fxLayoutGap="16px grid" fxLayoutAlign="stretch">-->
|
||||
<div class="header" fxLayout="row" fxLayoutAlign="space-between center">
|
||||
<div fxLayout="row" fxLayoutGap="16px" fxLayoutAlign="start center">
|
||||
<img src="assets/logo-horizontal.svg" width="150px" />
|
||||
<span>Tech UI</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<mat-progress-bar mode="determinate" value="{{progressValue}}" [@progressBar]="isLoading ? 'start' : 'done'">
|
||||
</mat-progress-bar>
|
||||
</div>
|
||||
<div fxLayout="column" fxLayoutGap="16px" class="grid-container" [@loading]="isLoading ? 'start' : 'done'">
|
||||
<div fxLayout="row" fxLayoutGap="16px" fxLayoutAlign="space-between stretch">
|
||||
<app-chart [type]=folderChart fxFlex="50"></app-chart>
|
||||
<app-chart [type]=deviceChart fxFlex="50"></app-chart>
|
||||
</div>
|
||||
<app-status-list gdArea="status-list"></app-status-list>
|
||||
<div></div>
|
||||
</div>
|
13
newgui/src/app/dashboard/dashboard.component.scss
Normal file
13
newgui/src/app/dashboard/dashboard.component.scss
Normal file
@ -0,0 +1,13 @@
|
||||
.header {
|
||||
margin: 15px 3vw 12px 3vw;
|
||||
font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0 3vw 0 3vw;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
margin: 10px calc(10px + 3.3vw);
|
||||
min-width: 600px;
|
||||
}
|
34
newgui/src/app/dashboard/dashboard.component.spec.ts
Normal file
34
newgui/src/app/dashboard/dashboard.component.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DashboardComponent } from './dashboard.component';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
describe('DashboardComponent', () => {
|
||||
let component: DashboardComponent;
|
||||
let fixture: ComponentFixture<DashboardComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [DashboardComponent],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
NoopAnimationsModule,
|
||||
BrowserAnimationsModule,
|
||||
],
|
||||
providers: [DashboardComponent]
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(DashboardComponent);
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should compile', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
117
newgui/src/app/dashboard/dashboard.component.ts
Normal file
117
newgui/src/app/dashboard/dashboard.component.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { Component, OnInit, AfterViewInit, ViewChild, AfterViewChecked } from '@angular/core';
|
||||
import {
|
||||
trigger,
|
||||
state,
|
||||
style,
|
||||
animate,
|
||||
transition,
|
||||
} from '@angular/animations';
|
||||
import { SystemConfigService } from '../services/system-config.service';
|
||||
import { StType } from '../type';
|
||||
import { FilterService } from '../services/filter.service';
|
||||
import { ProgressService } from '../services/progress.service';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MessageService } from '../services/message.service';
|
||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DialogComponent } from '../dialog/dialog.component';
|
||||
import { FolderService } from '../services/folder.service';
|
||||
import { DeviceService } from '../services/device.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
providers: [FilterService],
|
||||
animations: [
|
||||
trigger('loading', [
|
||||
state('start', style({
|
||||
marginTop: '20px',
|
||||
})),
|
||||
state('done', style({
|
||||
marginTop: '0px',
|
||||
})),
|
||||
transition('start => done', [
|
||||
animate('0.2s 0.2s')
|
||||
]),
|
||||
transition('done => start', [
|
||||
animate('0.2s 0.2s')
|
||||
]),
|
||||
]),
|
||||
trigger('progressBar', [
|
||||
state('start', style({
|
||||
opacity: 100,
|
||||
visibility: 'visible'
|
||||
})),
|
||||
state('done', style({
|
||||
opacity: 0,
|
||||
visibility: 'hidden'
|
||||
})),
|
||||
transition('start => done', [
|
||||
animate('0.35s')
|
||||
]),
|
||||
transition('done => start', [
|
||||
animate('0.35s')
|
||||
]),
|
||||
]),
|
||||
]
|
||||
})
|
||||
export class DashboardComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild(MatProgressBar) progressBar: MatProgressBar;
|
||||
folderChart: StType = StType.Folder;
|
||||
deviceChart: StType = StType.Device;
|
||||
progressValue: number = 0;
|
||||
isLoading = true;
|
||||
private dialogRef: MatDialogRef<DialogComponent>;
|
||||
|
||||
|
||||
constructor(
|
||||
private systemConfigService: SystemConfigService,
|
||||
private folderService: FolderService,
|
||||
private deviceService: DeviceService,
|
||||
private progressService: ProgressService,
|
||||
private messageService: MessageService,
|
||||
public dialog: MatDialog
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
// Request data from Rest API
|
||||
this.systemConfigService.getSystemConfig().subscribe(
|
||||
_ => {
|
||||
// Request devices and folders for charts and lists
|
||||
this.folderService.requestFolders();
|
||||
this.deviceService.requestDevices();
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.isLoading = true;
|
||||
|
||||
// Listen for progress service changes
|
||||
let t = setInterval(() => {
|
||||
if (this.progressService.isComplete()) {
|
||||
clearInterval(t);
|
||||
this.progressValue = 100;
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.progressValue = this.progressService.percentValue;
|
||||
}, 100);
|
||||
|
||||
// Listen for messages from other services/components
|
||||
this.messageService.messageAdded$
|
||||
.subscribe(
|
||||
_ => {
|
||||
// Open dialog
|
||||
if (!this.dialogRef)
|
||||
this.dialogRef = this.dialog.open(DialogComponent);
|
||||
|
||||
this.dialogRef.afterClosed().subscribe(
|
||||
_ => {
|
||||
this.dialogRef = null;
|
||||
this.messageService.clear();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
126
newgui/src/app/device.ts
Normal file
126
newgui/src/app/device.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { colors } from './style';
|
||||
import Folder from './folder';
|
||||
import { Completion } from './completion';
|
||||
|
||||
interface Device {
|
||||
deviceID: string;
|
||||
name: string;
|
||||
stateType: Device.StateType;
|
||||
state: string;
|
||||
paused: boolean;
|
||||
connected: boolean;
|
||||
completion: Completion;
|
||||
used: boolean; // indicates if a folder is using the device
|
||||
folders: Folder[];
|
||||
}
|
||||
|
||||
namespace Device {
|
||||
export enum StateType {
|
||||
Insync = 1,
|
||||
UnusedInsync,
|
||||
Unknown,
|
||||
Syncing,
|
||||
Paused,
|
||||
UnusedPaused,
|
||||
Disconnected,
|
||||
UnusedDisconnected,
|
||||
}
|
||||
|
||||
export function stateTypeToString(s: StateType): string {
|
||||
switch (s) {
|
||||
case StateType.Insync:
|
||||
return 'Up to Date';
|
||||
case StateType.UnusedInsync:
|
||||
return 'Connected (Unused)';
|
||||
case StateType.Unknown:
|
||||
return 'Unknown';
|
||||
case StateType.Syncing:
|
||||
return 'Syncing';
|
||||
case StateType.Paused:
|
||||
return 'Paused';
|
||||
case StateType.UnusedPaused:
|
||||
return 'Paused (Unused)';
|
||||
case StateType.Disconnected:
|
||||
return 'Disconnected';
|
||||
case StateType.UnusedDisconnected:
|
||||
return 'Disconnected (Unused)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stateTypeToColor looks up a hex color string based on StateType
|
||||
* @param s StateType
|
||||
*/
|
||||
export function stateTypeToColor(s: StateType): string {
|
||||
switch (s) {
|
||||
case StateType.Insync:
|
||||
return colors.get("blue");
|
||||
case StateType.UnusedInsync:
|
||||
return colors.get("grey");
|
||||
case StateType.Unknown:
|
||||
return colors.get("grey");
|
||||
case StateType.Syncing:
|
||||
return colors.get("green");
|
||||
case StateType.Paused:
|
||||
return colors.get("grey");
|
||||
case StateType.UnusedPaused:
|
||||
return colors.get("grey");
|
||||
case StateType.Disconnected:
|
||||
return colors.get("yellow");
|
||||
case StateType.UnusedDisconnected:
|
||||
return colors.get("grey");
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateType(d: Device): StateType {
|
||||
// StateType Unknown is set in DeviceService
|
||||
if (d.stateType === StateType.Unknown) {
|
||||
return StateType.Unknown;
|
||||
}
|
||||
|
||||
if (d.paused) {
|
||||
return d.used ? StateType.Paused : StateType.UnusedPaused;
|
||||
}
|
||||
|
||||
if (d.connected) {
|
||||
if (d.completion.completion === 100) {
|
||||
return d.used ? StateType.Insync : StateType.UnusedInsync;
|
||||
} else {
|
||||
return StateType.Syncing;
|
||||
}
|
||||
}
|
||||
|
||||
return d.used ? StateType.Disconnected : StateType.UnusedDisconnected;
|
||||
}
|
||||
|
||||
export function recalcCompletion(d: Device) {
|
||||
if (!d || !d.completion || !d.folders) {
|
||||
return
|
||||
}
|
||||
var total = 0, needed = 0, deletes = 0, items = 0;
|
||||
d.folders.forEach(folder => {
|
||||
if (!folder || !folder.completion)
|
||||
return
|
||||
needed += folder.completion.needBytes;
|
||||
items += folder.completion.needItems;
|
||||
deletes += folder.completion.needDeletes;
|
||||
});
|
||||
if (total == 0) {
|
||||
d.completion.completion = 100;
|
||||
d.completion.needBytes = 0;
|
||||
d.completion.needItems = 0;
|
||||
} else {
|
||||
d.completion.completion = Math.floor(100 * (1 - needed / total));
|
||||
d.completion.needBytes = needed;
|
||||
d.completion.needItems = items + deletes;
|
||||
}
|
||||
|
||||
if (needed == 0 && deletes > 0) {
|
||||
// We don't need any data, but we have deletes that we need
|
||||
// to do. Drop down the completion percentage to indicate
|
||||
// that we have stuff to do.
|
||||
d.completion.completion = 95;
|
||||
}
|
||||
}
|
||||
}
|
||||
export default Device;
|
6
newgui/src/app/dialog/dialog.component.html
Normal file
6
newgui/src/app/dialog/dialog.component.html
Normal file
@ -0,0 +1,6 @@
|
||||
<div mat-dialog-content fxLayout="column" fxLayoutAlign="space-between center">
|
||||
<mat-list>
|
||||
<mat-list-item *ngFor='let message of messageService.messages'>{{message}}</mat-list-item>
|
||||
</mat-list>
|
||||
<button mat-stroked-button [mat-dialog-close] cdkFocusInitial>Close</button>
|
||||
</div>
|
0
newgui/src/app/dialog/dialog.component.scss
Normal file
0
newgui/src/app/dialog/dialog.component.scss
Normal file
25
newgui/src/app/dialog/dialog.component.spec.ts
Normal file
25
newgui/src/app/dialog/dialog.component.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DialogComponent } from './dialog.component';
|
||||
|
||||
describe('DialogComponent', () => {
|
||||
let component: DialogComponent;
|
||||
let fixture: ComponentFixture<DialogComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ DialogComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
26
newgui/src/app/dialog/dialog.component.ts
Normal file
26
newgui/src/app/dialog/dialog.component.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MessageService } from '../services/message.service';
|
||||
|
||||
export interface DialogData {
|
||||
message: 'example message';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dialog',
|
||||
templateUrl: './dialog.component.html',
|
||||
styleUrls: ['./dialog.component.scss']
|
||||
})
|
||||
export class DialogComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<DialogComponent>,
|
||||
public messageService: MessageService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void { }
|
||||
|
||||
onNoClick(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
200
newgui/src/app/folder.ts
Normal file
200
newgui/src/app/folder.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import Device from './device';
|
||||
import { colors } from './style';
|
||||
import { Completion } from './completion';
|
||||
|
||||
interface Folder {
|
||||
id: string;
|
||||
label: string;
|
||||
devices: Device[];
|
||||
status: Folder.Status;
|
||||
stateType: Folder.StateType;
|
||||
state: string;
|
||||
paused: boolean;
|
||||
completion: Completion;
|
||||
path: string;
|
||||
}
|
||||
|
||||
namespace Folder {
|
||||
export enum StateType {
|
||||
Paused = 1,
|
||||
Unknown,
|
||||
Unshared,
|
||||
WaitingToScan,
|
||||
Stopped,
|
||||
Scanning,
|
||||
Idle,
|
||||
LocalAdditions,
|
||||
WaitingToSync,
|
||||
PreparingToSync,
|
||||
Syncing,
|
||||
OutOfSync,
|
||||
FailedItems,
|
||||
}
|
||||
|
||||
/**
|
||||
* stateTypeToString returns a string representation of
|
||||
* the StateType enum
|
||||
* @param s StateType
|
||||
*/
|
||||
export function stateTypeToString(s: StateType): string {
|
||||
switch (s) {
|
||||
case StateType.Paused:
|
||||
return 'Paused';
|
||||
case StateType.Unknown:
|
||||
return 'Unknown';
|
||||
case StateType.Unshared:
|
||||
return 'Unshared';
|
||||
case StateType.WaitingToSync:
|
||||
return 'Waiting to Sync';
|
||||
case StateType.Stopped:
|
||||
return 'Stopped';
|
||||
case StateType.Scanning:
|
||||
return 'Scanning';
|
||||
case StateType.Idle:
|
||||
return 'Up to Date';
|
||||
case StateType.LocalAdditions:
|
||||
return 'Local Additions';
|
||||
case StateType.WaitingToScan:
|
||||
return 'Waiting to Scan';
|
||||
case StateType.PreparingToSync:
|
||||
return 'Preparing to Sync';
|
||||
case StateType.Syncing:
|
||||
return 'Syncing';
|
||||
case StateType.OutOfSync:
|
||||
return 'Out of Sync';
|
||||
case StateType.FailedItems:
|
||||
return 'Failed Items';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stateTypeToColor looks up a hex color string based on StateType
|
||||
* @param s StateType
|
||||
*/
|
||||
export function stateTypeToColor(s: StateType): string {
|
||||
switch (s) {
|
||||
case StateType.Paused:
|
||||
return colors.get("grey");
|
||||
case StateType.Unknown:
|
||||
return colors.get("grey");
|
||||
case StateType.Unshared:
|
||||
return colors.get("grey");
|
||||
case StateType.WaitingToSync:
|
||||
return colors.get("yellow");
|
||||
case StateType.Stopped:
|
||||
return colors.get("grey");
|
||||
case StateType.Scanning:
|
||||
return colors.get("grey");
|
||||
case StateType.Idle:
|
||||
return colors.get("blue");
|
||||
case StateType.LocalAdditions:
|
||||
return colors.get("grey");
|
||||
case StateType.WaitingToScan:
|
||||
return colors.get("grey");
|
||||
case StateType.PreparingToSync:
|
||||
return colors.get("grey");
|
||||
case StateType.Syncing:
|
||||
return colors.get("green");
|
||||
case StateType.OutOfSync:
|
||||
return colors.get("grey");
|
||||
case StateType.FailedItems:
|
||||
return colors.get("red");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getStateType looks at a folder and determines the correct
|
||||
* StateType to return
|
||||
*
|
||||
* Possible state values from API
|
||||
* "idle", "scanning", "scan-waiting", "sync-waiting", "sync-preparing"
|
||||
* "syncing", "error", "unknown"
|
||||
*
|
||||
* @param f Folder
|
||||
*/
|
||||
export function getStateType(f: Folder): StateType {
|
||||
if (f.paused) {
|
||||
return StateType.Paused;
|
||||
}
|
||||
|
||||
if (!f.status || (Object.keys(f.status).length === 0)) {
|
||||
return StateType.Unknown;
|
||||
}
|
||||
|
||||
const fs: Folder.Status = f.status;
|
||||
const state: string = fs.state;
|
||||
|
||||
// Match API string to StateType
|
||||
switch (state) {
|
||||
case "idle":
|
||||
return StateType.Idle;
|
||||
case "scanning":
|
||||
return StateType.Scanning;
|
||||
case "scan-waiting":
|
||||
return StateType.WaitingToScan;
|
||||
case "sync-waiting":
|
||||
return StateType.WaitingToSync;
|
||||
case "sync-preparing":
|
||||
return StateType.PreparingToSync;
|
||||
case "syncing":
|
||||
return StateType.Syncing;
|
||||
case "error":
|
||||
// legacy, the state is called "stopped" in the gui
|
||||
return StateType.Stopped;
|
||||
case "unknown":
|
||||
return StateType.Unknown;
|
||||
}
|
||||
|
||||
if (fs.needTotalItems > 0) {
|
||||
return StateType.OutOfSync;
|
||||
}
|
||||
if (fs.pullErrors > 0) {
|
||||
return StateType.FailedItems;
|
||||
}
|
||||
if (fs.receiveOnlyTotalItems > 0) {
|
||||
return StateType.LocalAdditions;
|
||||
}
|
||||
if (f.devices.length <= 1) {
|
||||
return StateType.Unshared;
|
||||
}
|
||||
|
||||
return StateType.Unknown;
|
||||
}
|
||||
|
||||
|
||||
export interface Status {
|
||||
globalBytes: number;
|
||||
globalDeleted: number;
|
||||
globalDirectories: number;
|
||||
globalFiles: number;
|
||||
globalSymlinks: number;
|
||||
globalTotalItems: number;
|
||||
ignorePatterns: boolean;
|
||||
inSyncBytes: number;
|
||||
inSyncFiles: number;
|
||||
invalid: string;
|
||||
localBytes: number;
|
||||
localDeleted: number;
|
||||
localDirectories: number;
|
||||
localFiles: number;
|
||||
localSymlinks: number;
|
||||
needBytes: number;
|
||||
needDeletes: number;
|
||||
needDirectories: number;
|
||||
needFiles: number;
|
||||
needSymlinks: number;
|
||||
needTotalItems: number;
|
||||
pullErrors: number;
|
||||
receiveOnlyChangedBytes: number;
|
||||
receiveOnlyChangedDeletes: number;
|
||||
receiveOnlyChangedDirectories: number;
|
||||
receiveOnlyChangedFiles: number;
|
||||
receiveOnlyChangedSymlinks: number;
|
||||
receiveOnlyTotalItems: number;
|
||||
sequence: number;
|
||||
state: string;
|
||||
stateChanged: string;
|
||||
version: number;
|
||||
}
|
||||
}
|
||||
export default Folder;
|
16
newgui/src/app/http-interceptors/caching.interceptor.spec.ts
Normal file
16
newgui/src/app/http-interceptors/caching.interceptor.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CachingInterceptor } from './caching.interceptor';
|
||||
|
||||
describe('CachingInterceptor', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CachingInterceptor
|
||||
]
|
||||
}));
|
||||
|
||||
it('should be created', () => {
|
||||
const interceptor: CachingInterceptor = TestBed.inject(CachingInterceptor);
|
||||
expect(interceptor).toBeTruthy();
|
||||
});
|
||||
});
|
58
newgui/src/app/http-interceptors/caching.interceptor.ts
Normal file
58
newgui/src/app/http-interceptors/caching.interceptor.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpHeaders,
|
||||
HttpResponse
|
||||
} from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { RequestCacheService } from '../services/request-cache.service'
|
||||
|
||||
@Injectable()
|
||||
export class CachingInterceptor implements HttpInterceptor {
|
||||
constructor(private cache: RequestCacheService) { }
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler) {
|
||||
// continue if not cachable.
|
||||
if (!isCachable(req)) { return next.handle(req); }
|
||||
|
||||
const cachedResponse = this.cache.get(req);
|
||||
return cachedResponse ?
|
||||
of(cachedResponse) : sendRequest(req, next, this.cache);
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this request cachable? */
|
||||
function isCachable(req: HttpRequest<any>) {
|
||||
// Only GET requests are cachable
|
||||
return req.method === 'GET';
|
||||
/*
|
||||
return req.method === 'GET' &&
|
||||
-1 < req.url.indexOf("url");
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server response observable by sending request to `next()`.
|
||||
* Will add the response to the cache on the way out.
|
||||
*/
|
||||
function sendRequest(
|
||||
req: HttpRequest<any>,
|
||||
next: HttpHandler,
|
||||
cache: RequestCacheService): Observable<HttpEvent<any>> {
|
||||
|
||||
// No headers allowed in npm search request
|
||||
const noHeaderReq = req.clone({ headers: new HttpHeaders() });
|
||||
|
||||
return next.handle(noHeaderReq).pipe(
|
||||
tap(event => {
|
||||
// There may be other events besides the response.
|
||||
if (event instanceof HttpResponse) {
|
||||
// cache.put(req, event); // Update the cache.
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
16
newgui/src/app/http-interceptors/csrf.interceptor.spec.ts
Normal file
16
newgui/src/app/http-interceptors/csrf.interceptor.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CSRFInterceptor } from './csrf.interceptor';
|
||||
|
||||
describe('CsrfInterceptor', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CSRFInterceptor
|
||||
]
|
||||
}));
|
||||
|
||||
it('should be created', () => {
|
||||
const interceptor: CSRFInterceptor = TestBed.inject(CSRFInterceptor);
|
||||
expect(interceptor).toBeTruthy();
|
||||
});
|
||||
});
|
29
newgui/src/app/http-interceptors/csrf.interceptor.ts
Normal file
29
newgui/src/app/http-interceptors/csrf.interceptor.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { deviceID } from '../api-utils';
|
||||
import {
|
||||
HttpInterceptor, HttpHandler, HttpRequest, HttpHeaders
|
||||
} from '@angular/common/http';
|
||||
|
||||
import { CookieService } from '../services/cookie.service';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class CSRFInterceptor implements HttpInterceptor {
|
||||
|
||||
constructor(private cookieService: CookieService) { }
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler) {
|
||||
const dID: String = deviceID();
|
||||
const csrfCookie = 'CSRF-Token-' + dID
|
||||
|
||||
// Clone the request and replace the original headers with
|
||||
// cloned headers, updated with the CSRF information.
|
||||
const csrfReq = req.clone({
|
||||
headers: req.headers.set('X-CSRF-Token-' + dID,
|
||||
this.cookieService.getCookie(csrfCookie))
|
||||
});
|
||||
|
||||
// send cloned request with header to the next handler.
|
||||
return next.handle(csrfReq);
|
||||
}
|
||||
}
|
16
newgui/src/app/http-interceptors/error.interceptor.spec.ts
Normal file
16
newgui/src/app/http-interceptors/error.interceptor.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ErrorInterceptor } from './error.interceptor';
|
||||
|
||||
describe('ErrorInterceptor', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ErrorInterceptor
|
||||
]
|
||||
}));
|
||||
|
||||
it('should be created', () => {
|
||||
const interceptor: ErrorInterceptor = TestBed.inject(ErrorInterceptor);
|
||||
expect(interceptor).toBeTruthy();
|
||||
});
|
||||
});
|
39
newgui/src/app/http-interceptors/error.interceptor.ts
Normal file
39
newgui/src/app/http-interceptors/error.interceptor.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpErrorResponse
|
||||
} from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { apiRetry } from '../api-utils';
|
||||
import { retry, catchError } from 'rxjs/operators';
|
||||
import { MessageService } from '../services/message.service';
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
|
||||
constructor(private messageService: MessageService) { }
|
||||
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(request)
|
||||
.pipe(
|
||||
retry(apiRetry),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
let errorMsg: string;
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client side
|
||||
errorMsg = `Error: ${error.error.message}`;
|
||||
} else {
|
||||
// Server side
|
||||
errorMsg = `Error Status: ${error.status}\nMessage: ${error.message}`;
|
||||
}
|
||||
console.log(errorMsg);
|
||||
|
||||
this.messageService.add(errorMsg);
|
||||
return throwError(errorMsg);
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
14
newgui/src/app/http-interceptors/index.ts
Normal file
14
newgui/src/app/http-interceptors/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/* "Barrel" of Http Interceptors */
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
|
||||
import { CSRFInterceptor } from './csrf.interceptor';
|
||||
import { CachingInterceptor } from './caching.interceptor';
|
||||
import { ErrorInterceptor } from './error.interceptor';
|
||||
|
||||
/** Http interceptor providers in outside-in order */
|
||||
export const httpInterceptorProviders = [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: CachingInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||
// CSRFInterceptor needs to be last
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: CSRFInterceptor, multi: true },
|
||||
];
|
4
newgui/src/app/list-toggle/list-toggle.component.html
Normal file
4
newgui/src/app/list-toggle/list-toggle.component.html
Normal file
@ -0,0 +1,4 @@
|
||||
<mat-button-toggle-group class="tui-button-toggle" name="fontStyle" aria-label="Font Style" value="folders">
|
||||
<mat-button-toggle value="folders" (click)="onSelect(listType.Folder)">Folders</mat-button-toggle>
|
||||
<mat-button-toggle value="devices" (click)="onSelect(listType.Device)">Devices</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
25
newgui/src/app/list-toggle/list-toggle.component.spec.ts
Normal file
25
newgui/src/app/list-toggle/list-toggle.component.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ListToggleComponent } from './list-toggle.component';
|
||||
|
||||
describe('ListToggleComponent', () => {
|
||||
let component: ListToggleComponent;
|
||||
let fixture: ComponentFixture<ListToggleComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ListToggleComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ListToggleComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
26
newgui/src/app/list-toggle/list-toggle.component.ts
Normal file
26
newgui/src/app/list-toggle/list-toggle.component.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { StType } from '../type';
|
||||
import { MatButtonToggleGroup } from '@angular/material/button-toggle';
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-toggle',
|
||||
templateUrl: './list-toggle.component.html',
|
||||
styleUrls: ['./list-toggle.component.scss']
|
||||
})
|
||||
|
||||
export class ListToggleComponent implements OnInit {
|
||||
@ViewChild(MatButtonToggleGroup) group: MatButtonToggleGroup;
|
||||
public listType = StType;
|
||||
// public toggleValue: string = "folders";
|
||||
@Output() listTypeEvent = new EventEmitter<StType>();
|
||||
|
||||
constructor() { }
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
onSelect(t: StType): void {
|
||||
this.listTypeEvent.emit(t);
|
||||
}
|
||||
}
|
31
newgui/src/app/lists/device-list/device-list.component.html
Normal file
31
newgui/src/app/lists/device-list/device-list.component.html
Normal file
@ -0,0 +1,31 @@
|
||||
<mat-form-field>
|
||||
<mat-label>Filter</mat-label>
|
||||
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. Up to Date">
|
||||
</mat-form-field>
|
||||
<table mat-table class="full-width-table" matSort aria-label="Devices" multiTemplateDataRows>
|
||||
<ng-container matColumnDef="{{column}}" *ngFor="let column of displayedColumns">
|
||||
<th mat-header-cell *matHeaderCellDef> {{column}} </th>
|
||||
<td mat-cell *matCellDef="let device"> {{device[column]}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<td mat-cell *matCellDef="let device" [attr.colspan]="displayedColumns.length">
|
||||
<div class="table-detail" [@detailExpand]="device == expandedDevice ? 'expanded' : 'collapsed'">
|
||||
<div class="detail-items">
|
||||
<span>Folders: </span>
|
||||
<span class="item-name" *ngFor="let folder of device.folders">{{folder.label | trim}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let device; columns: displayedColumns;" class="table-row"
|
||||
[class.expanded-row]="expandedDevice === device"
|
||||
(click)="expandedDevice = expandedDevice === device ? null : device">
|
||||
</tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator #paginator [length]="dataSource?.data.length" [pageIndex]="0" [pageSize]="25"
|
||||
[pageSizeOptions]="[25, 50, 100, 250]">
|
||||
</mat-paginator>
|
@ -0,0 +1,28 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { DeviceListComponent } from './device-list.component';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { ChangeDetectorRef } from '@angular/core';
|
||||
|
||||
describe('DeviceListComponent', () => {
|
||||
let component: DeviceListComponent;
|
||||
let fixture: ComponentFixture<DeviceListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [DeviceListComponent],
|
||||
imports: [HttpClientModule],
|
||||
providers: [DeviceListComponent, ChangeDetectorRef]
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(DeviceListComponent);
|
||||
}));
|
||||
|
||||
it('should compile', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
96
newgui/src/app/lists/device-list/device-list.component.ts
Normal file
96
newgui/src/app/lists/device-list/device-list.component.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { AfterViewInit, Component, OnInit, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTable, MatTableDataSource } from '@angular/material/table';
|
||||
|
||||
import Device from '../../device';
|
||||
import { SystemConfigService } from '../../services/system-config.service';
|
||||
import { FilterService } from 'src/app/services/filter.service';
|
||||
import { StType } from 'src/app/type';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { DeviceService } from 'src/app/services/device.service';
|
||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||
|
||||
@Component({
|
||||
selector: 'app-device-list',
|
||||
templateUrl: './device-list.component.html',
|
||||
styleUrls: ['../status-list/status-list.component.scss'],
|
||||
animations: [
|
||||
trigger('detailExpand', [
|
||||
state('collapsed', style({ height: '0px', minHeight: '0' })),
|
||||
state('expanded', style({ height: '*' })),
|
||||
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class DeviceListComponent implements AfterViewInit, OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
@ViewChild(MatTable) table: MatTable<Device>;
|
||||
@ViewChild(MatInput) input: MatInput;
|
||||
dataSource: MatTableDataSource<Device>;
|
||||
|
||||
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
|
||||
displayedColumns = ['deviceID', 'name', 'state'];
|
||||
expandedDevice: Device | null;
|
||||
|
||||
constructor(
|
||||
private deviceService: DeviceService,
|
||||
private filterService: FilterService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) { };
|
||||
|
||||
applyFilter(event: Event) {
|
||||
// Set previous filter value
|
||||
const filterValue = (event.target as HTMLInputElement).value;
|
||||
this.filterService.previousInputs.set(StType.Device, filterValue);
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.dataSource = new MatTableDataSource();
|
||||
this.dataSource.data = [];
|
||||
|
||||
// Replace all data when requests are finished
|
||||
this.deviceService.devicesUpdated$.subscribe(
|
||||
devices => {
|
||||
this.dataSource.data = devices;
|
||||
}
|
||||
);
|
||||
|
||||
// Add device as they come in
|
||||
let devices: Device[] = [];
|
||||
this.deviceService.deviceAdded$.subscribe(
|
||||
device => {
|
||||
devices.push(device);
|
||||
this.dataSource.data = devices;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.table.dataSource = this.dataSource;
|
||||
|
||||
const changeText = (text: string) => {
|
||||
this.dataSource.filter = text.trim().toLowerCase();
|
||||
this.input.value = text;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
// Set previous value
|
||||
changeText(this.filterService.previousInputs.get(StType.Device));
|
||||
|
||||
// Listen for filter changes from other components
|
||||
this.filterService.filterChanged$
|
||||
.subscribe(
|
||||
input => {
|
||||
if (input.type === StType.Device) {
|
||||
changeText(input.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() { }
|
||||
}
|
32
newgui/src/app/lists/folder-list/folder-list.component.html
Normal file
32
newgui/src/app/lists/folder-list/folder-list.component.html
Normal file
@ -0,0 +1,32 @@
|
||||
<mat-form-field>
|
||||
<mat-label>Filter</mat-label>
|
||||
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. Up to Date">
|
||||
</mat-form-field>
|
||||
<table mat-table class="full-width-table" matSort aria-label="Folders" multiTemplateDataRows>
|
||||
<ng-container matColumnDef="{{column}}" *ngFor="let column of displayedColumns">
|
||||
<th mat-header-cell *matHeaderCellDef> {{column}} </th>
|
||||
<td mat-cell *matCellDef="let folder"> {{folder[column]}} </td>
|
||||
</ng-container>
|
||||
<!-- Expanded Content Column - The detail row is made up of this one column that spans across all columns -->
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<td mat-cell *matCellDef="let folder" [attr.colspan]="displayedColumns.length">
|
||||
<div class="table-detail" [@detailExpand]="folder == expandedFolder ? 'expanded' : 'collapsed'">
|
||||
<div class="detail-items">
|
||||
<span>Shared with: </span>
|
||||
<span class="item-name" *ngFor="let device of folder.devices">{{device.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let folder; columns: displayedColumns;" class="table-row"
|
||||
[class.expanded-row]="expandedFolder === folder"
|
||||
(click)="expandedFolder = expandedFolder === folder ? null : folder">
|
||||
</tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator #paginator [length]="dataSource?.data.length" [pageIndex]="0" [pageSize]="25"
|
||||
[pageSizeOptions]="[25, 50, 100, 250]">
|
||||
</mat-paginator>
|
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FolderListComponent } from './folder-list.component';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { ChangeDetectorRef } from '@angular/core';
|
||||
|
||||
describe('FolderListComponent', () => {
|
||||
let component: FolderListComponent;
|
||||
let fixture: ComponentFixture<FolderListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [FolderListComponent],
|
||||
imports: [HttpClientModule],
|
||||
providers: [FolderListComponent, ChangeDetectorRef]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
component = TestBed.inject(FolderListComponent);
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
102
newgui/src/app/lists/folder-list/folder-list.component.ts
Normal file
102
newgui/src/app/lists/folder-list/folder-list.component.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { AfterViewInit, Component, OnInit, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTable, MatTableDataSource } from '@angular/material/table';
|
||||
|
||||
import Folder from '../../folder';
|
||||
import { SystemConfigService } from '../../services/system-config.service';
|
||||
import { FilterService } from 'src/app/services/filter.service';
|
||||
import { StType } from 'src/app/type';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { FolderService } from 'src/app/services/folder.service';
|
||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||
|
||||
@Component({
|
||||
selector: 'app-folder-list',
|
||||
templateUrl: './folder-list.component.html',
|
||||
styleUrls: ['../status-list/status-list.component.scss'],
|
||||
animations: [
|
||||
trigger('detailExpand', [
|
||||
state('collapsed', style({ height: '0px', minHeight: '0' })),
|
||||
state('expanded', style({ height: '*' })),
|
||||
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class FolderListComponent implements AfterViewInit, OnInit, OnDestroy {
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
@ViewChild(MatTable) table: MatTable<Folder>;
|
||||
@ViewChild(MatInput) input: MatInput;
|
||||
dataSource: MatTableDataSource<Folder>;
|
||||
|
||||
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
|
||||
displayedColumns = [
|
||||
"id",
|
||||
"label",
|
||||
"path",
|
||||
"state"
|
||||
];
|
||||
|
||||
expandedFolder: Folder | null;
|
||||
|
||||
constructor(
|
||||
private folderService: FolderService,
|
||||
private filterService: FilterService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {
|
||||
};
|
||||
|
||||
applyFilter(event: Event) {
|
||||
const filterValue = (event.target as HTMLInputElement).value;
|
||||
this.filterService.previousInputs.set(StType.Folder, filterValue);
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.dataSource = new MatTableDataSource();
|
||||
this.dataSource.data = [];
|
||||
|
||||
// Replace all data when requests are finished
|
||||
this.folderService.foldersUpdated$.subscribe(
|
||||
folders => {
|
||||
this.dataSource.data = folders;
|
||||
}
|
||||
);
|
||||
|
||||
// Add device as they come in
|
||||
let folders: Folder[] = [];
|
||||
this.folderService.folderAdded$.subscribe(
|
||||
folder => {
|
||||
folders.push(folder);
|
||||
this.dataSource.data = folders;
|
||||
}
|
||||
);;
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.table.dataSource = this.dataSource;
|
||||
|
||||
const changeText = (text: string) => {
|
||||
this.dataSource.filter = text.trim().toLowerCase();
|
||||
this.input.value = text;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
// Set previous value
|
||||
changeText(this.filterService.previousInputs.get(StType.Folder));
|
||||
|
||||
// Listen for filter changes from other components
|
||||
this.filterService.filterChanged$
|
||||
.subscribe(
|
||||
input => {
|
||||
if (input.type === StType.Folder) {
|
||||
changeText(input.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() { }
|
||||
}
|
10
newgui/src/app/lists/status-list/status-list.component.html
Normal file
10
newgui/src/app/lists/status-list/status-list.component.html
Normal file
@ -0,0 +1,10 @@
|
||||
<app-card class="status-list">
|
||||
<div fxLayout="row" fxLayoutAlign="space-between start">
|
||||
<app-card-title>{{title | uppercase}}</app-card-title>
|
||||
<app-list-toggle (listTypeEvent)="onToggle($event)" class="tui-card-toggle"></app-list-toggle>
|
||||
</div>
|
||||
<app-card-content>
|
||||
<app-folder-list *ngIf="currentListType===listType.Folder"></app-folder-list>
|
||||
<app-device-list *ngIf="currentListType===listType.Device"> </app-device-list>
|
||||
</app-card-content>
|
||||
</app-card>
|
70
newgui/src/app/lists/status-list/status-list.component.scss
Normal file
70
newgui/src/app/lists/status-list/status-list.component.scss
Normal file
@ -0,0 +1,70 @@
|
||||
.status-list .tui-card-toggle {
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.full-width-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mat-form-field {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
tr.detail-row {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
tr.table-row:not(.expanded-row):hover {
|
||||
background: whitesmoke;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
tr.table-row:not(.expanded-row):active {
|
||||
background: #DDDDDD;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
.expanded-row {
|
||||
background: #DDDDDD;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
.table-row td {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.table-detail {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detail-items {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// Hide empty name
|
||||
.item-name:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-name:not(:last-child):after {
|
||||
content: ", ";
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
tr.table-row:not(.expanded-row):hover {
|
||||
background: #212121;
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr.table-row:not(.expanded-row):active {
|
||||
background: #212121;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.expanded-row {
|
||||
background: #212121;
|
||||
color: white;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StatusListComponent } from './status-list.component';
|
||||
|
||||
describe('StatusListComponent', () => {
|
||||
let component: StatusListComponent;
|
||||
let fixture: ComponentFixture<StatusListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [StatusListComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(StatusListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
47
newgui/src/app/lists/status-list/status-list.component.ts
Normal file
47
newgui/src/app/lists/status-list/status-list.component.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Component, ViewChild, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { StType } from '../../type';
|
||||
import { cardElevation } from '../../style';
|
||||
import { FilterService } from 'src/app/services/filter.service';
|
||||
import { ListToggleComponent } from 'src/app/list-toggle/list-toggle.component';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-status-list',
|
||||
templateUrl: './status-list.component.html',
|
||||
styleUrls: ['./status-list.component.scss']
|
||||
})
|
||||
export class StatusListComponent {
|
||||
@ViewChild(ListToggleComponent) toggle: ListToggleComponent;
|
||||
currentListType: StType = StType.Folder;
|
||||
listType = StType; // used in html
|
||||
elevation: string = cardElevation;
|
||||
title: string = 'Status';
|
||||
|
||||
constructor(
|
||||
private filterService: FilterService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Listen for filter changes from other components
|
||||
this.filterService.filterChanged$.subscribe(
|
||||
input => {
|
||||
this.currentListType = input.type;
|
||||
|
||||
switch (input.type) {
|
||||
case StType.Folder:
|
||||
this.toggle.group.value = "folders";
|
||||
break;
|
||||
case StType.Device:
|
||||
this.toggle.group.value = "devices";
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.cdr.detectChanges(); // manually detect changes
|
||||
}
|
||||
|
||||
onToggle(t: StType) {
|
||||
this.currentListType = t;
|
||||
}
|
||||
}
|
26
newgui/src/app/mocks/mock-db-completion.ts
Normal file
26
newgui/src/app/mocks/mock-db-completion.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export const dbCompletion =
|
||||
[
|
||||
{
|
||||
"device": "YZJBJFX-RDBL7WY-6ZGKJ2D-4MJB4E7-ZATSDUY-LD6Y3L3-MLFUYWE-AEMXJAC",
|
||||
"completion": 100,
|
||||
"globalBytes": 156793013575,
|
||||
"needBytes": 0,
|
||||
"needDeletes": 0,
|
||||
"needItems": 0
|
||||
},
|
||||
{
|
||||
"completion": 80,
|
||||
"globalBytes": 3013575,
|
||||
"needBytes": 100,
|
||||
"needDeletes": 0,
|
||||
"needItems": 0
|
||||
}
|
||||
]
|
||||
/*
|
||||
{
|
||||
"completion": 100,
|
||||
"globalBytes": 156793013575,
|
||||
"needBytes": 0,
|
||||
"needDeletes": 0
|
||||
}
|
||||
*/
|
46
newgui/src/app/mocks/mock-db-status.ts
Normal file
46
newgui/src/app/mocks/mock-db-status.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export const dbStatus =
|
||||
[
|
||||
{ "folder": "GXWxf-3zgnU", "state": "active" },
|
||||
{ "folder": "Tyeho-ncvqp", "state": "idle" },
|
||||
{ "folder": "Ihpqp-3zgnq", "state": "idle" },
|
||||
{ "folder": "Abqqp-3zgnU", "state": "idle" },
|
||||
{ "folder": "Bawer-3zgnU", "state": "idle" },
|
||||
{ "folder": "Zpohq-3zgnU", "state": "idle" },
|
||||
{ "folder": "Lkmbn-3zgnU", "state": "idle" },
|
||||
{ "folder": "Poqff-3zgnU", "state": "idle" }
|
||||
]
|
||||
/*[{
|
||||
"folder": "GXWxf-3zgnU",
|
||||
"globalBytes": 0,
|
||||
"globalDeleted": 0,
|
||||
"globalDirectories": 0,
|
||||
"globalFiles": 0,
|
||||
"globalSymlinks": 0,
|
||||
"globalTotalItems": 0,
|
||||
"ignorePatterns": false,
|
||||
"inSyncBytes": 0,
|
||||
"inSyncFiles": 0,
|
||||
"invalid": "",
|
||||
"localBytes": 0,
|
||||
"localDeleted": 0,
|
||||
"localDirectories": 0,
|
||||
"localFiles": 0,
|
||||
"localSymlinks": 0,
|
||||
"localTotalItems": 0,
|
||||
"needBytes": 0,
|
||||
"needDeletes": 0,
|
||||
"needDirectories": 0,
|
||||
"needFiles": 0,
|
||||
"needSymlinks": 0,
|
||||
"needTotalItems": 0,
|
||||
"pullErrors": 0,
|
||||
"receiveOnlyChangedBytes": 0,
|
||||
"receiveOnlyChangedDeletes": 0,
|
||||
"receiveOnlyChangedDirectories": 0,
|
||||
"receiveOnlyChangedFiles": 0,
|
||||
"receiveOnlyChangedSymlinks": 0,
|
||||
"sequence": 0,
|
||||
"state": "idle",
|
||||
"stateChanged": "2018-08-08T07:04:57.301064781+02:00",
|
||||
"version": 0
|
||||
}]*/;
|
67
newgui/src/app/mocks/mock-system-config.ts
Normal file
67
newgui/src/app/mocks/mock-system-config.ts
Normal file
@ -0,0 +1,67 @@
|
||||
export const config = {
|
||||
"version": 15,
|
||||
"folders": [
|
||||
{ "id": "GXWxf-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "YZJBJFX-RDBL7WY-6ZGKJ2D-4MJB4E7-ZATSDUY-LD6Y3L3-MLFUYWE-AEMXJAC" }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Tyeho-ncvqp", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Ihpqp-3zgnq", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Abqqp-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Bawer-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Zpohq-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Lkmbn-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
{ "id": "Poqff-3zgnU", "label": "MyFolder", "path": "...", "type": "sendreceive", "devices": [{ "deviceID": "..." }], "rescanIntervalS": 60, "ignorePerms": false, "autoNormalize": true, "minDiskFreePct": 1, "versioning": { "type": "simple", "params": { "keep": "5" } }, "copiers": 0, "pullers": 0, "hashers": 0, "order": "random", "ignoreDelete": false, "scanProgressIntervalS": 0, "pullerSleepS": 0, "pullerPauseS": 0, "maxConflicts": 10, "disableSparseFiles": false, "disableTempIndexes": false, "fsync": false, "invalid": "" },
|
||||
],
|
||||
"devices": [
|
||||
{ "deviceID": "YZJBJFX-RDBL7WY-6ZGKJ2D-4MJB4E7-ZATSDUY-LD6Y3L3-MLFUYWE-AEMXJAC", "name": "Laptop", "addresses": ["dynamic", "tcp://192.168.1.2:22000"], "compression": "metadata", "certName": "", "introducer": false },
|
||||
{ "deviceID": "...", "name": "Server", "addresses": ["dynamic", "tcp://192.168.1.3:22000"], "compression": "metadata", "certName": "", "introducer": false },
|
||||
],
|
||||
"gui": {
|
||||
"enabled": true,
|
||||
"address": "127.0.0.1:8384",
|
||||
"user": "Username",
|
||||
"password": "$2a$10$ZFws69T4FlvWwsqeIwL.TOo5zOYqsa/.TxlUnsGYS.j3JvjFTmxo6",
|
||||
"useTLS": false,
|
||||
"apiKey": "pGahcht56664QU5eoFQW6szbEG6Ec2Cr",
|
||||
"insecureAdminAccess": false,
|
||||
"theme": "default"
|
||||
},
|
||||
"options": {
|
||||
"listenAddresses": [
|
||||
"default"
|
||||
],
|
||||
"globalAnnounceServers": [
|
||||
"default"
|
||||
],
|
||||
"globalAnnounceEnabled": true,
|
||||
"localAnnounceEnabled": true,
|
||||
"localAnnouncePort": 21027,
|
||||
"localAnnounceMCAddr": "[ff12::8384]:21027",
|
||||
"maxSendKbps": 0,
|
||||
"maxRecvKbps": 0,
|
||||
"reconnectionIntervalS": 60,
|
||||
"relaysEnabled": true,
|
||||
"relayReconnectIntervalM": 10,
|
||||
"startBrowser": false,
|
||||
"natEnabled": true,
|
||||
"natLeaseMinutes": 60,
|
||||
"natRenewalMinutes": 30,
|
||||
"natTimeoutSeconds": 10,
|
||||
"urAccepted": -1,
|
||||
"urUniqueId": "",
|
||||
"urURL": "https://data.syncthing.net/newdata",
|
||||
"urPostInsecurely": false,
|
||||
"urInitialDelayS": 1800,
|
||||
"restartOnWakeup": true,
|
||||
"autoUpgradeIntervalH": 12,
|
||||
"keepTemporariesH": 24,
|
||||
"cacheIgnoredFiles": false,
|
||||
"progressUpdateIntervalS": 5,
|
||||
"limitBandwidthInLan": false,
|
||||
"minHomeDiskFreePct": 1,
|
||||
"releasesURL": "https://upgrades.syncthing.net/meta.json",
|
||||
"alwaysLocalNets": [],
|
||||
"overwriteRemoteDeviceNamesOnConnect": false,
|
||||
"tempIndexMinBlocks": 10
|
||||
},
|
||||
"ignoredDevices": [],
|
||||
"ignoredFolders": []
|
||||
}
|
44
newgui/src/app/mocks/mock-system-connections.ts
Normal file
44
newgui/src/app/mocks/mock-system-connections.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export const connections = {
|
||||
"total": {
|
||||
"paused": false,
|
||||
"clientVersion": "",
|
||||
"at": "2015-11-07T17:29:47.691637262+01:00",
|
||||
"connected": false,
|
||||
"inBytesTotal": 1479,
|
||||
"type": "",
|
||||
"outBytesTotal": 1318,
|
||||
"address": ""
|
||||
},
|
||||
"connections": {
|
||||
"YZJBJFX-RDBL7WY-6ZGKJ2D-4MJB4E7-ZATSDUY-LD6Y3L3-MLFUYWE-AEMXJAC": {
|
||||
"connected": true,
|
||||
"inBytesTotal": 556,
|
||||
"paused": false,
|
||||
"at": "2015-11-07T17:29:47.691548971+01:00",
|
||||
"clientVersion": "v0.12.1",
|
||||
"address": "127.0.0.1:22002",
|
||||
"type": "TCP (Client)",
|
||||
"outBytesTotal": 550
|
||||
},
|
||||
"DOVII4U-SQEEESM-VZ2CVTC-CJM4YN5-QNV7DCU-5U3ASRL-YVFG6TH-W5DV5AA": {
|
||||
"outBytesTotal": 0,
|
||||
"type": "",
|
||||
"address": "",
|
||||
"at": "0001-01-01T00:00:00Z",
|
||||
"clientVersion": "",
|
||||
"paused": false,
|
||||
"inBytesTotal": 0,
|
||||
"connected": false
|
||||
},
|
||||
"UYGDMA4-TPHOFO5-2VQYDCC-7CWX7XW-INZINQT-LE4B42N-4JUZTSM-IWCSXA4": {
|
||||
"address": "",
|
||||
"type": "",
|
||||
"outBytesTotal": 0,
|
||||
"connected": false,
|
||||
"inBytesTotal": 0,
|
||||
"paused": false,
|
||||
"at": "0001-01-01T00:00:00Z",
|
||||
"clientVersion": ""
|
||||
}
|
||||
}
|
||||
}
|
59
newgui/src/app/mocks/mock-system-status.ts
Normal file
59
newgui/src/app/mocks/mock-system-status.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export const systemStatus = {
|
||||
"alloc": 30618136,
|
||||
"connectionServiceStatus": {
|
||||
"dynamic+https://relays.syncthing.net/endpoint": {
|
||||
"error": null,
|
||||
"lanAddresses": [
|
||||
"relay://23.92.71.120:443/?id=53STGR7-YBM6FCX-PAZ2RHM-YPY6OEJ-WYHVZO7-PCKQRCK-PZLTP7T-434XCAD&pingInterval=1m0s&networkTimeout=2m0s&sessionLimitBps=0&globalLimitBps=0&statusAddr=:22070&providedBy=canton7"
|
||||
],
|
||||
"wanAddresses": [
|
||||
"relay://23.92.71.120:443/?id=53STGR7-YBM6FCX-PAZ2RHM-YPY6OEJ-WYHVZO7-PCKQRCK-PZLTP7T-434XCAD&pingInterval=1m0s&networkTimeout=2m0s&sessionLimitBps=0&globalLimitBps=0&statusAddr=:22070&providedBy=canton7"
|
||||
]
|
||||
},
|
||||
"tcp://0.0.0.0:22000": {
|
||||
"error": null,
|
||||
"lanAddresses": [
|
||||
"tcp://0.0.0.0:22000"
|
||||
],
|
||||
"wanAddresses": [
|
||||
"tcp://0.0.0.0:22000"
|
||||
]
|
||||
}
|
||||
},
|
||||
"cpuPercent": 0,
|
||||
"discoveryEnabled": true,
|
||||
"discoveryErrors": {
|
||||
"global@https://discovery-v4-1.syncthing.net/v2/": "500 Internal Server Error",
|
||||
"global@https://discovery-v4-2.syncthing.net/v2/": "Post https://discovery-v4-2.syncthing.net/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)",
|
||||
"global@https://discovery-v4-3.syncthing.net/v2/": "Post https://discovery-v4-3.syncthing.net/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)",
|
||||
"global@https://discovery-v6-1.syncthing.net/v2/": "Post https://discovery-v6-1.syncthing.net/v2/: dial tcp [2001:470:28:4d6::5]:443: connect: no route to host",
|
||||
"global@https://discovery-v6-2.syncthing.net/v2/": "Post https://discovery-v6-2.syncthing.net/v2/: dial tcp [2604:a880:800:10::182:a001]:443: connect: no route to host",
|
||||
"global@https://discovery-v6-3.syncthing.net/v2/": "Post https://discovery-v6-3.syncthing.net/v2/: dial tcp [2400:6180:0:d0::d9:d001]:443: connect: no route to host"
|
||||
},
|
||||
"discoveryMethods": 8,
|
||||
"goroutines": 49,
|
||||
"lastDialStatus": {
|
||||
"tcp://10.20.30.40": {
|
||||
"when": "2019-05-16T07:41:23Z",
|
||||
"error": "dial tcp 10.20.30.40:22000: i/o timeout"
|
||||
},
|
||||
"tcp://172.16.33.3:22000": {
|
||||
"when": "2019-05-16T07:40:43Z",
|
||||
"ok": true
|
||||
},
|
||||
"tcp://83.233.120.221:22000": {
|
||||
"when": "2019-05-16T07:41:13Z",
|
||||
"error": "dial tcp 83.233.120.221:22000: connect: connection refused"
|
||||
}
|
||||
},
|
||||
"myID": "YZJBJFX-RDBL7WY-6ZGKJ2D-4MJB4E7-ZATSDUY-LD6Y3L3-MLFUYWE-AEMXJAC",
|
||||
"pathSeparator": "/",
|
||||
"startTime": "2016-06-06T19:41:43.039284753+02:00",
|
||||
"sys": 42092792,
|
||||
"themes": [
|
||||
"default",
|
||||
"dark"
|
||||
],
|
||||
"tilde": "/Users/jb",
|
||||
"uptime": 2635
|
||||
}
|
16
newgui/src/app/services/cookie.service.spec.ts
Normal file
16
newgui/src/app/services/cookie.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CookieService } from './cookie.service';
|
||||
|
||||
describe('CookieService', () => {
|
||||
let service: CookieService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(CookieService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
35
newgui/src/app/services/cookie.service.ts
Normal file
35
newgui/src/app/services/cookie.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CookieService {
|
||||
|
||||
constructor() { }
|
||||
|
||||
getCookie(name: string): string {
|
||||
let ca: Array<string> = document.cookie.split(';');
|
||||
let caLen: number = ca.length;
|
||||
let cookieName = `${name}=`;
|
||||
let c: string;
|
||||
|
||||
for (let i: number = 0; i < caLen; i += 1) {
|
||||
c = ca[i].replace(/^\s+/g, '');
|
||||
if (c.indexOf(cookieName) == 0) {
|
||||
return c.substring(cookieName.length, c.length);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
deleteCookie(name): void {
|
||||
this.setCookie(name, "", -1);
|
||||
}
|
||||
|
||||
setCookie(name: string, value: string, expireDays: number, path: string = ""): void {
|
||||
let d: Date = new Date();
|
||||
d.setTime(d.getTime() + expireDays * 24 * 60 * 60 * 1000);
|
||||
let expires: string = "expires=" + d.toUTCString();
|
||||
document.cookie = name + "=" + value + "; " + expires + (path.length > 0 ? "; path=" + path : "");
|
||||
}
|
||||
}
|
20
newgui/src/app/services/db-completion.service.spec.ts
Normal file
20
newgui/src/app/services/db-completion.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DbCompletionService } from './db-completion.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('DbCompletionService', () => {
|
||||
let service: DbCompletionService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [DbCompletionService]
|
||||
});
|
||||
service = TestBed.inject(DbCompletionService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
51
newgui/src/app/services/db-completion.service.ts
Normal file
51
newgui/src/app/services/db-completion.service.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { apiURL } from '../api-utils';
|
||||
import { Completion } from '../completion';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import { StType } from '../type';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DbCompletionService {
|
||||
private dbStatusUrl = environment.production ? apiURL + 'rest/db/completion' : 'api/dbCompletion';
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getCompletion(type: StType, id: string): Observable<Completion> {
|
||||
let httpOptions: { params: HttpParams };
|
||||
if (id) {
|
||||
switch (type) {
|
||||
case StType.Device:
|
||||
httpOptions = {
|
||||
params: new HttpParams().set('device', id)
|
||||
};
|
||||
break;
|
||||
case StType.Folder:
|
||||
httpOptions = {
|
||||
params: new HttpParams().set('folder', id)
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else { }
|
||||
|
||||
return this.http
|
||||
.get<Completion>(this.dbStatusUrl, httpOptions)
|
||||
.pipe(
|
||||
map(res => {
|
||||
// Remove from array in developement
|
||||
// in-memory-web-api returns arrays
|
||||
if (!environment.production) {
|
||||
const a: any = res as any;
|
||||
if (a.length > 0) {
|
||||
res = res[0];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
21
newgui/src/app/services/db-status.service.spec.ts
Normal file
21
newgui/src/app/services/db-status.service.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DbStatusService } from './db-status.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('DbStatusService', () => {
|
||||
let service: DbStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [DbStatusService]
|
||||
});
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(DbStatusService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
43
newgui/src/app/services/db-status.service.ts
Normal file
43
newgui/src/app/services/db-status.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../../environments/environment'
|
||||
import { apiURL } from '../api-utils'
|
||||
import Folder from '../folder'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DbStatusService {
|
||||
private dbStatusUrl = environment.production ? apiURL + 'rest/db/status' : 'api/dbStatus';
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getFolderStatus(id: string): Observable<Folder.Status> {
|
||||
let httpOptions: { params: HttpParams };
|
||||
if (id) {
|
||||
httpOptions = {
|
||||
params: new HttpParams().set('folder', id)
|
||||
};
|
||||
} else { }
|
||||
|
||||
return this.http
|
||||
.get<Folder.Status>(this.dbStatusUrl, httpOptions)
|
||||
.pipe(
|
||||
map(res => {
|
||||
// Remove from array in developement
|
||||
// in-memory-web-api returns arrays
|
||||
if (!environment.production) {
|
||||
const a: any = res as any;
|
||||
if (a.length > 0) {
|
||||
res = res[0];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
20
newgui/src/app/services/device.service.spec.ts
Normal file
20
newgui/src/app/services/device.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DeviceService } from './device.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('DeviceService', () => {
|
||||
let service: DeviceService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [DeviceService]
|
||||
});
|
||||
service = TestBed.inject(DeviceService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
128
newgui/src/app/services/device.service.ts
Normal file
128
newgui/src/app/services/device.service.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import Device from '../device';
|
||||
import { Observable, Subscriber, ReplaySubject, Subject } from 'rxjs';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { SystemConnectionsService } from './system-connections.service';
|
||||
import { DbCompletionService } from './db-completion.service';
|
||||
import { SystemConnections } from '../connections';
|
||||
import { SystemStatusService } from './system-status.service';
|
||||
import { ProgressService } from './progress.service';
|
||||
import { StType } from '../type';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DeviceService {
|
||||
private devices: Device[];
|
||||
private sysConns: SystemConnections;
|
||||
private devicesSubject: ReplaySubject<Device[]> = new ReplaySubject(1);
|
||||
devicesUpdated$ = this.devicesSubject.asObservable();
|
||||
private thisDevice: Device;
|
||||
|
||||
private deviceAddedSource = new Subject<Device>();
|
||||
deviceAdded$ = this.deviceAddedSource.asObservable();
|
||||
|
||||
constructor(
|
||||
private systemConfigService: SystemConfigService,
|
||||
private systemConnectionsService: SystemConnectionsService,
|
||||
private dbCompletionService: DbCompletionService,
|
||||
private systemStatusService: SystemStatusService,
|
||||
private progressService: ProgressService,
|
||||
) { }
|
||||
|
||||
getDeviceStatusInOrder(startIndex: number) {
|
||||
// Return if there aren't any device at the index
|
||||
if (startIndex >= (this.devices.length)) {
|
||||
this.devicesSubject.next(this.devices);
|
||||
// this.devicesSubject.complete();
|
||||
// this.deviceAddedSource.complete();
|
||||
return;
|
||||
}
|
||||
const device: Device = this.devices[startIndex];
|
||||
startIndex = startIndex + 1;
|
||||
|
||||
// Check if device in the connections
|
||||
if (this.sysConns.connections[device.deviceID] === undefined) {
|
||||
device.stateType = Device.StateType.Unknown;
|
||||
} else {
|
||||
// Set connected
|
||||
device.connected = this.sysConns.connections[device.deviceID].connected;
|
||||
|
||||
// TODO ? temporarily set to connected
|
||||
if (device.deviceID === this.thisDevice.deviceID) {
|
||||
device.connected = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.dbCompletionService.getCompletion(StType.Device, device.deviceID).subscribe(
|
||||
c => {
|
||||
device.completion = c;
|
||||
Device.recalcCompletion(device);
|
||||
device.stateType = Device.getStateType(device);
|
||||
device.state = Device.stateTypeToString(device.stateType);
|
||||
|
||||
this.deviceAddedSource.next(device);
|
||||
this.progressService.addToProgress(1);
|
||||
|
||||
// recursively get the status of the next device
|
||||
this.getDeviceStatusInOrder(startIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getEach() returns each device
|
||||
*/
|
||||
requestDevices() {
|
||||
this.systemConfigService.getDevices().subscribe(
|
||||
devices => {
|
||||
this.devices = devices;
|
||||
|
||||
// First check to see which device is local 'thisDevice'
|
||||
this.systemStatusService.getSystemStatus().subscribe(
|
||||
status => {
|
||||
this.devices.forEach(device => {
|
||||
if (device.deviceID === status.myID) {
|
||||
// TODO Determine if it should ignore thisDevice
|
||||
this.thisDevice = device;
|
||||
}
|
||||
});
|
||||
|
||||
// Check folder devices to see if the device is used
|
||||
this.systemConfigService.getFolders().subscribe(
|
||||
folders => {
|
||||
// Loop through all folder devices to see if the device is used
|
||||
this.devices.forEach(device => {
|
||||
// Alloc array if needed
|
||||
if (!device.folders) {
|
||||
device.folders = [];
|
||||
}
|
||||
|
||||
folders.forEach(folder => {
|
||||
folder.devices.forEach(fdevice => {
|
||||
if (device.deviceID === fdevice.deviceID) {
|
||||
// The device is used by a folder
|
||||
device.used = true;
|
||||
|
||||
// Add a reference to the folder to the device
|
||||
device.folders.push(folder);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// See if the connection is connected or undefined
|
||||
this.systemConnectionsService.getSystemConnections().subscribe(
|
||||
c => {
|
||||
this.sysConns = c;
|
||||
|
||||
// Synchronously get the status of each device
|
||||
this.getDeviceStatusInOrder(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
16
newgui/src/app/services/filter.service.spec.ts
Normal file
16
newgui/src/app/services/filter.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterService } from './filter.service';
|
||||
|
||||
describe('FilterService', () => {
|
||||
let service: FilterService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(FilterService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
30
newgui/src/app/services/filter.service.ts
Normal file
30
newgui/src/app/services/filter.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StType } from '../type';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface FilterInput {
|
||||
type: StType;
|
||||
text: string
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FilterService {
|
||||
previousInputs = new Map<StType, string>(
|
||||
[
|
||||
[StType.Folder, ""],
|
||||
[StType.Device, ""],
|
||||
]
|
||||
)
|
||||
|
||||
constructor() { }
|
||||
|
||||
private filterChangeSource = new Subject<FilterInput>();
|
||||
filterChanged$ = this.filterChangeSource.asObservable();
|
||||
|
||||
changeFilter(input: FilterInput) {
|
||||
this.previousInputs.set(input.type, input.text)
|
||||
this.filterChangeSource.next(input);
|
||||
}
|
||||
}
|
20
newgui/src/app/services/folder.service.spec.ts
Normal file
20
newgui/src/app/services/folder.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FolderService } from './folder.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('FolderService', () => {
|
||||
let service: FolderService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [FolderService]
|
||||
});
|
||||
service = TestBed.inject(FolderService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
98
newgui/src/app/services/folder.service.ts
Normal file
98
newgui/src/app/services/folder.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { Observable, Subscriber, Subject, ReplaySubject } from 'rxjs';
|
||||
import Folder from '../folder';
|
||||
import { DbStatusService } from './db-status.service';
|
||||
import { ProgressService } from './progress.service';
|
||||
import { DbCompletionService } from './db-completion.service';
|
||||
import { StType } from '../type';
|
||||
import { DeviceService } from './device.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FolderService {
|
||||
private folders: Folder[];
|
||||
private foldersSubject: ReplaySubject<Folder[]> = new ReplaySubject(1);
|
||||
foldersUpdated$ = this.foldersSubject.asObservable();
|
||||
private folderAddedSource = new Subject<Folder>();
|
||||
folderAdded$ = this.folderAddedSource.asObservable();
|
||||
|
||||
constructor(
|
||||
private systemConfigService: SystemConfigService,
|
||||
private deviceService: DeviceService,
|
||||
private dbStatusService: DbStatusService,
|
||||
private dbCompletionService: DbCompletionService,
|
||||
private progressService: ProgressService,
|
||||
) { }
|
||||
|
||||
getFolderStatusInOrder(startIndex: number) {
|
||||
// Return if there aren't any folders at the index
|
||||
if (startIndex >= (this.folders.length)) {
|
||||
this.foldersSubject.next(this.folders);
|
||||
// this.folderAddedSource.complete();
|
||||
return;
|
||||
}
|
||||
const folder: Folder = this.folders[startIndex];
|
||||
startIndex = startIndex + 1;
|
||||
|
||||
// Folder devices array only has deviceID
|
||||
// and we want all the device info
|
||||
this.systemConfigService.getDevices().subscribe(
|
||||
devices => {
|
||||
devices.forEach(device => {
|
||||
// Update any device this folder
|
||||
// has reference to
|
||||
folder.devices.forEach((folderDevice, index) => {
|
||||
if (folderDevice.deviceID === device.deviceID) {
|
||||
console.log("find device match?", device.name)
|
||||
folder.devices[index] = device;
|
||||
|
||||
console.log("update?", folder.devices);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Gather the folder information from the status and
|
||||
// completion services
|
||||
this.dbStatusService.getFolderStatus(folder.id).subscribe(
|
||||
status => {
|
||||
folder.status = status;
|
||||
|
||||
this.dbCompletionService.getCompletion(StType.Folder, folder.id).subscribe(
|
||||
c => {
|
||||
folder.completion = c;
|
||||
folder.stateType = Folder.getStateType(folder);
|
||||
folder.state = Folder.stateTypeToString(folder.stateType);
|
||||
|
||||
this.folderAddedSource.next(folder);
|
||||
this.progressService.addToProgress(1);
|
||||
|
||||
// Now that we have all the folder information
|
||||
// recursively get the status of the next folder
|
||||
this.getFolderStatusInOrder(startIndex);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* requestFolders() requests each folder and uses db status service to
|
||||
* set all their statuses and db completion service to find
|
||||
* completion in order. Updating folderAdded$ and foldersUpdate$
|
||||
* observers
|
||||
*/
|
||||
requestFolders() {
|
||||
this.systemConfigService.getFolders().subscribe(
|
||||
folders => {
|
||||
this.folders = folders;
|
||||
|
||||
// Synchronously get the status of each folder
|
||||
this.getFolderStatusInOrder(0);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InMemoryConfigDataService } from './in-memory-config-data.service';
|
||||
|
||||
describe('InMemoryDataService', () => {
|
||||
let service: InMemoryConfigDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(InMemoryConfigDataService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
17
newgui/src/app/services/in-memory-config-data.service.ts
Normal file
17
newgui/src/app/services/in-memory-config-data.service.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { config } from '../mocks/mock-system-config';
|
||||
import { dbStatus } from '../mocks/mock-db-status';
|
||||
import { connections } from '../mocks/mock-system-connections';
|
||||
import { dbCompletion } from '../mocks/mock-db-completion';
|
||||
import { systemStatus } from '../mocks/mock-system-status';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class InMemoryConfigDataService {
|
||||
createDb() {
|
||||
return { config, dbStatus, connections, dbCompletion, systemStatus };
|
||||
}
|
||||
|
||||
constructor() { }
|
||||
}
|
16
newgui/src/app/services/message.service.spec.ts
Normal file
16
newgui/src/app/services/message.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MessageService } from './message.service';
|
||||
|
||||
describe('MessageService', () => {
|
||||
let service: MessageService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(MessageService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
20
newgui/src/app/services/message.service.ts
Normal file
20
newgui/src/app/services/message.service.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MessageService {
|
||||
messages: string[] = [];
|
||||
private messageAddedSource = new Subject<string>();
|
||||
messageAdded$ = this.messageAddedSource.asObservable();
|
||||
|
||||
add(message: string) {
|
||||
this.messages.push(message);
|
||||
this.messageAddedSource.next(message);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
40
newgui/src/app/services/progress.service.spec.ts
Normal file
40
newgui/src/app/services/progress.service.spec.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProgressService } from './progress.service';
|
||||
import { stringToKeyValue } from '@angular/flex-layout/extended/typings/style/style-transforms';
|
||||
|
||||
describe('ProgressService', () => {
|
||||
let service: ProgressService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ProgressService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('#percentValue should return 0 - 100', () => {
|
||||
interface iTest {
|
||||
total: number,
|
||||
progress: number,
|
||||
expected: number,
|
||||
}
|
||||
const tests: Map<string, iTest> = new Map([
|
||||
["default", { total: 0, progress: 0, expected: 0 }],
|
||||
["NaN return 0", { total: 0, progress: 100, expected: 0 }],
|
||||
["greater than 100 return 100", { total: 10, progress: 100, expected: 100 }],
|
||||
["valid", { total: 100, progress: 100, expected: 100 }],
|
||||
["valid", { total: 100, progress: 50, expected: 50 }],
|
||||
["test floor", { total: 133, progress: 41, expected: 30 }],
|
||||
]);
|
||||
|
||||
service = new ProgressService();
|
||||
for (let test of tests.values()) {
|
||||
service.total = test.total;
|
||||
service.updateProgress(test.progress);
|
||||
expect(service.percentValue).toBe(test.expected);
|
||||
}
|
||||
});
|
||||
});
|
50
newgui/src/app/services/progress.service.ts
Normal file
50
newgui/src/app/services/progress.service.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ProgressService {
|
||||
private progress: number = 0;
|
||||
private _total: number = 0;
|
||||
set total(t: number) {
|
||||
this._total = t;
|
||||
}
|
||||
|
||||
get percentValue(): number {
|
||||
let p: number = Math.floor((this.progress / this._total) * 100);
|
||||
if (p < 0 || isNaN(p) || p === Infinity) {
|
||||
p = 0;
|
||||
} else if (p > 100) {
|
||||
p = 100;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
constructor() { }
|
||||
|
||||
addToProgress(n: number) {
|
||||
if (n < 0 || isNaN(n) || n === Infinity) {
|
||||
n = 0;
|
||||
}
|
||||
|
||||
this.progress += n;
|
||||
}
|
||||
|
||||
updateProgress(n: number) {
|
||||
if (n < 0 || isNaN(n) || n === Infinity) {
|
||||
n = 0
|
||||
} else if (n > 100) {
|
||||
n = 100
|
||||
}
|
||||
|
||||
this.progress = n;
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
if (this.progress >= this._total && this.progress > 0 && this._total > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
16
newgui/src/app/services/request-cache.service.spec.ts
Normal file
16
newgui/src/app/services/request-cache.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RequestCacheService } from './request-cache.service';
|
||||
|
||||
describe('RequestCacheService', () => {
|
||||
let service: RequestCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(RequestCacheService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
50
newgui/src/app/services/request-cache.service.ts
Normal file
50
newgui/src/app/services/request-cache.service.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpResponse, HttpRequest } from '@angular/common/http';
|
||||
|
||||
export interface RequestCacheEntry {
|
||||
url: string;
|
||||
response: HttpResponse<any>;
|
||||
lastRead: number;
|
||||
}
|
||||
|
||||
const maxAge = 30000; // milliseconds
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RequestCacheService {
|
||||
private cache: Map<string, RequestCacheEntry> = new Map();
|
||||
|
||||
constructor() { }
|
||||
|
||||
get(req: HttpRequest<any>): HttpResponse<any> | undefined {
|
||||
const url = req.urlWithParams;
|
||||
const cached = this.cache.get(url);
|
||||
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isExpired = cached.lastRead < (Date.now() - maxAge);
|
||||
return isExpired ? undefined : cached.response;
|
||||
}
|
||||
|
||||
put(req: HttpRequest<any>, response: HttpResponse<any>): void {
|
||||
const url = req.urlWithParams;
|
||||
|
||||
const entry = { url, response, lastRead: Date.now() };
|
||||
this.cache.set(url, entry);
|
||||
|
||||
// Remove expired cache entries
|
||||
const expired = Date.now() - maxAge;
|
||||
this.cache.forEach(entry => {
|
||||
if (entry.lastRead < expired) {
|
||||
this.cache.delete(entry.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.cache = new Map();
|
||||
}
|
||||
}
|
20
newgui/src/app/services/system-config.service.spec.ts
Normal file
20
newgui/src/app/services/system-config.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('SystemConfigService', () => {
|
||||
let service: SystemConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [SystemConfigService]
|
||||
});
|
||||
service = TestBed.inject(SystemConfigService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
55
newgui/src/app/services/system-config.service.ts
Normal file
55
newgui/src/app/services/system-config.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable, ReplaySubject } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import Folder from '../folder';
|
||||
import Device from '../device';
|
||||
import { environment } from '../../environments/environment'
|
||||
import { apiURL } from '../api-utils'
|
||||
import { ProgressService } from './progress.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SystemConfigService {
|
||||
private folders: Folder[];
|
||||
private devices: Device[];
|
||||
private foldersSubject: ReplaySubject<Folder[]> = new ReplaySubject(1);
|
||||
private devicesSubject: ReplaySubject<Device[]> = new ReplaySubject(1);
|
||||
|
||||
private systemConfigUrl = environment.production ? apiURL + 'rest/system/config' : 'api/config';
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private progressService: ProgressService,
|
||||
) { }
|
||||
|
||||
getSystemConfig(): Observable<any> {
|
||||
return this.http
|
||||
.get(this.systemConfigUrl)
|
||||
.pipe(
|
||||
map(res => {
|
||||
this.folders = res['folders'];
|
||||
this.devices = res['devices'];
|
||||
|
||||
// Set the total for the progress service
|
||||
this.progressService.total = this.folders.length + this.devices.length;
|
||||
|
||||
this.foldersSubject.next(this.folders);
|
||||
this.devicesSubject.next(this.devices);
|
||||
|
||||
return res;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getFolders(): Observable<Folder[]> {
|
||||
return this.foldersSubject.asObservable();
|
||||
}
|
||||
|
||||
getDevices(): Observable<Device[]> {
|
||||
return this.devicesSubject.asObservable();
|
||||
}
|
||||
}
|
20
newgui/src/app/services/system-connections.service.spec.ts
Normal file
20
newgui/src/app/services/system-connections.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SystemConnectionsService } from './system-connections.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('SystemConnectionsService', () => {
|
||||
let service: SystemConnectionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [SystemConnectionsService]
|
||||
});
|
||||
service = TestBed.inject(SystemConnectionsService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
26
newgui/src/app/services/system-connections.service.ts
Normal file
26
newgui/src/app/services/system-connections.service.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { apiURL } from '../api-utils';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SystemConnections } from '../connections';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SystemConnectionsService {
|
||||
private systemConfigUrl = environment.production ? apiURL + 'rest/system/connections' : 'api/connections';
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getSystemConnections(): Observable<SystemConnections> {
|
||||
return this.http
|
||||
.get<SystemConnections>(this.systemConfigUrl)
|
||||
.pipe(
|
||||
map(res => {
|
||||
return res;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
20
newgui/src/app/services/system-status.service.spec.ts
Normal file
20
newgui/src/app/services/system-status.service.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SystemStatusService } from './system-status.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
describe('SystemStatusService', () => {
|
||||
let service: SystemStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
providers: [SystemStatusService]
|
||||
});
|
||||
service = TestBed.inject(SystemStatusService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user