Compare commits

..

54 Commits

Author SHA1 Message Date
Paweł Jastrzębski
5e48e2dcf5 Use POST to api token refresh 2022-08-29 08:44:17 +02:00
R. Miles McCain
b286c80754 Remove unneeded views 2022-08-28 15:07:05 -07:00
R. Miles McCain
c23f44d7b7 Merge commit '77cb1fb37c0da5bad39b3905f7a48cd3f176bac7' into api 2022-08-28 15:01:04 -07:00
Paweł Jastrzębski
b7f2e9cfe6 Remove basic option from api view 2022-08-28 13:35:22 +02:00
R. Miles McCain
77cb1fb37c Improve language 2022-08-27 14:52:02 -07:00
Paweł Jastrzębski
d9bbeea892 Remove basic option from API
For simplicity
2022-05-12 12:10:44 +02:00
Paweł Jastrzębski
ca97453c3e Return 400 if date format is invalid 2022-04-26 10:13:52 +02:00
Paweł Jastrzębski
b87b158aab Fix typo 2022-04-22 08:28:09 +02:00
Paweł Jastrzębski
4a6af18765 Add django-cors-headers 2022-04-14 19:41:14 +02:00
Paweł Jastrzębski
6d84f63130 Add API documentation to GUIDE.md 2022-01-05 10:27:14 +01:00
Paweł Jastrzębski
ba91ed561d Add uuid validation 2022-01-05 09:47:14 +01:00
Paweł Jastrzębski
2aaadfe81c Display api urls on service management page 2022-01-05 09:47:05 +01:00
Paweł Jastrzębski
7f60b3abff Rename minimal parameter to basic 2022-01-05 08:53:46 +01:00
Paweł Jastrzębski
069b218828 Move api token info to security tab 2022-01-04 08:53:00 +01:00
Paweł Jastrzębski
80647d960a Merge branch 'master' into api 2022-01-01 19:56:55 +01:00
Paweł Jastrzębski
364ef115c9 Merge branch 'api' of https://github.com/haaavk/shynet into api 2022-01-01 19:50:10 +01:00
R. Miles McCain
71ec196ec4 Use :edge in default kubernetes deployment 2022-01-01 00:11:43 -05:00
R. Miles McCain
4834c5722f Bump version to v0.12.0 2021-12-31 23:47:05 -05:00
R. Miles McCain
4b4c8f207e Fix dates in the new year 2021-12-31 23:44:48 -05:00
R. Miles McCain
aed88b7c9a Use Alpine 3.14 2021-12-31 13:26:57 -05:00
Paweł Jastrzębski
bcf94147c9 Fix problem with whitespaces in copied token 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
66b841fd86 Move token to User model + add API setting view 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
d809ec82d9 Add uuid filter and service uuid filter 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
e577aa4997 Add minimal argument to get_core_stats 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
5966ea2f84 Add DashboardApiView 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
a7248cd54b Add ApiTokenRequiredMixin 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
1dec03c724 Add ApiToken to admin 2021-12-31 12:11:42 -05:00
Paweł Jastrzębski
ff6933b4de Add api app with ApiToken model 2021-12-31 12:11:42 -05:00
R. Miles McCain
1fd46b019c Lessen priorities on field buttons (#182)
* Lessen priorities on field buttons

* Use latest alpine
2021-12-31 12:11:03 -05:00
R. Miles McCain
e534269c77 Provide initial contributing guide 2021-12-21 00:23:29 -05:00
R. Miles McCain
0d64ef33b0 Remove installation with SSL from guide TOC 2021-12-21 00:15:15 -05:00
R. Miles McCain
56c82e7d23 Remove HTTPS without reverse proxy from the GUIDE 2021-12-21 00:14:18 -05:00
R. Miles McCain
c71d934c67 Remove armv7 support 2021-12-21 00:10:12 -05:00
R. Miles McCain
85ae56fcdb Add swap space to GitHub Actions runners 2021-12-18 15:34:11 -05:00
R. Miles McCain
cd422ffd71 Add note about ALLOWED_HOSTS to GUIDE.md 2021-12-18 15:14:11 -05:00
R. Miles McCain
060a9b2a96 Update Python dependencies 2021-12-18 14:38:31 -05:00
R. Miles McCain
8d13ccd0fd Update dependencies 2021-12-18 13:56:57 -05:00
lionep
0d46e6d1f4 Fix typo in GUIDE.md (#180) 2021-12-04 09:50:26 -08:00
R. Miles McCain
81ae84efb3 Add office hours link 2021-11-21 00:12:24 -08:00
Paweł Jastrzębski
8302aedaa7 Fix problem with whitespaces in copied token 2021-11-17 11:46:35 +01:00
Paweł Jastrzębski
e2d438134a Merge branch 'master' into api 2021-11-17 11:24:34 +01:00
Paweł Jastrzębski
787ce1775f Move token to User model + add API setting view 2021-11-17 11:00:52 +01:00
R. Miles McCain
ea5f58fbd3 Update lock 2021-11-13 21:11:52 -08:00
dependabot[bot]
4079a8494a Bump sqlparse from 0.4.1 to 0.4.2 (#178)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.1 to 0.4.2.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.1...0.4.2)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-13 21:10:06 -08:00
Sérgio
780b71083a Add first factories and first dashboard tests (#172)
* Add factories and first dashboard tests

* Code cleanup

Co-authored-by: R. Miles McCain <github@sendmiles.email>
2021-11-13 21:09:55 -08:00
Sérgio
62fbb014e7 Testing setup (dependencies and github actions) (#169)
* Add testing dependencies

* Add draft github action

* Fix testing env variables

* Newline lint

* Run tests on every pull request and push
2021-11-13 21:03:06 -08:00
Paweł Jastrzębski
d62d48c7b4 Add uuid filter and service uuid filter 2021-10-14 07:38:21 +02:00
Paweł Jastrzębski
2f8891a843 Add minimal argument to get_core_stats 2021-10-13 19:52:36 +02:00
Paweł Jastrzębski
a963694fd0 Add DashboardApiView 2021-10-13 19:21:52 +02:00
Paweł Jastrzębski
90b2896ded Add ApiTokenRequiredMixin 2021-10-13 16:01:31 +02:00
Paweł Jastrzębski
bec4b19366 Add ApiToken to admin 2021-10-11 12:37:01 +02:00
Paweł Jastrzębski
32adb64dc0 Add api app with ApiToken model 2021-10-11 11:33:18 +02:00
JuniorJPDJ
53bc690435 fix(docker): healthcheck (#175)
fixes healthcheck for $ALLOWED_HOSTS longer than 2 domains
2021-09-30 16:12:57 -07:00
JuniorJPDJ
04120323a6 feat(docker): healthcheck (#166) 2021-09-16 13:35:38 -07:00
38 changed files with 1328 additions and 407 deletions

View File

@@ -9,6 +9,11 @@ jobs:
publish_to_docker_hub: publish_to_docker_hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set swap space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 5
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -38,6 +43,6 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.prep.outputs.tags }} tags: ${{ steps.prep.outputs.tags }}

View File

@@ -9,6 +9,11 @@ jobs:
publish_to_docker_hub: publish_to_docker_hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set swap space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 5
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -38,6 +43,6 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.prep.outputs.tags }} tags: ${{ steps.prep.outputs.tags }}

View File

@@ -9,6 +9,11 @@ jobs:
publish_to_docker_hub: publish_to_docker_hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set swap space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 5
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -39,6 +44,6 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.prep.outputs.tags }} tags: ${{ steps.prep.outputs.tags }}

37
.github/workflows/run-tests.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Run tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:12.3-alpine
env:
POSTGRES_USER: shynet_db_user
POSTGRES_PASSWORD: shynet_db_user_password
POSTGRES_DB: shynet_db
ports:
- 5432:5432
strategy:
max-parallel: 4
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Run image
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: 1.1.6
- name: Install dependencies
run: poetry install
- name: Django Testing project
run: |
cp TEMPLATE.env .env
poetry run ./shynet/manage.py test

15
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,15 @@
# Contributing
This document provides an overview of how to contribute to Shynet. Currently, it focuses on the more technical elements of contributing --- for example, setting up your development environment. Eventually, we will expand this guide to cover the social and governance oriented side of contributing as well.
## Setting up your development environment
To contribute to Shynet, you must have a reliable development environment. Because Shynet is intended to be run inside containers, we strongly encourage you to run Shynet in a container in development as well. The development setup described in this guide will use Docker and Docker Compose.
To begin, clone the Shynet repository to your computer, and ensure that you have Docker and Docker Compose installed.
Copy `TEMPLATE.env` to a new file called `.env`. This `.env` file will be used in your development environment. Paste `DEBUG=True` into the end of your new `.env` file so that Shynet will know to run in development mode.
Finally, follow the steps in [GUIDE.md](GUIDE.md) on setting up a Shynet instance with Docker Compose. This is where you'll setup an admin user.
_Did you have to perform additional steps to setup your environment? Document them here and submit a pull request!_

View File

@@ -47,4 +47,5 @@ RUN python manage.py collectstatic --noinput && \
# Launch # Launch
USER appuser USER appuser
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK CMD bash -c 'wget -o /dev/null -O /dev/null --header "Host: ${ALLOWED_HOSTS%%,*}" "http://127.0.0.1:$PORT/healthz/?format=json"'
CMD [ "./entrypoint.sh" ] CMD [ "./entrypoint.sh" ]

View File

@@ -7,7 +7,6 @@
- [Render](#render) - [Render](#render)
- [Updating Your Configuration](#updating-your-configuration) - [Updating Your Configuration](#updating-your-configuration)
- [Advanced Usage](#advanced-usage) - [Advanced Usage](#advanced-usage)
* [Installation with SSL](#installation-with-ssl)
* [Configuring a Reverse Proxy](#configuring-a-reverse-proxy) * [Configuring a Reverse Proxy](#configuring-a-reverse-proxy)
+ [Cloudflare](#cloudflare) + [Cloudflare](#cloudflare)
+ [Nginx](#nginx) + [Nginx](#nginx)
@@ -23,7 +22,7 @@
## Installation ## Installation
Installation of Shynet is easy! Follow the [Basic Installation](#basic-installation) guide or the [Basic Installation with Docker Compose](#basic-installation-with-docker-compose) below for a minimal installation, or if you are going to be running Shynet over HTTPS through a reverse proxy. If you'd like to run Shynet over HTTPS without a reverse proxy, skip ahead to [Installation with SSL](#installation-with-ssl) instead. Installation of Shynet is easy! Follow the [Basic Installation](#basic-installation) guide or the [Basic Installation with Docker Compose](#basic-installation-with-docker-compose) below for a minimal installation, or if you are going to be running Shynet over HTTPS through a reverse proxy.
> **These commands assume Ubuntu.** If you're installing Shynet on a different platform, the process will be different. > **These commands assume Ubuntu.** If you're installing Shynet on a different platform, the process will be different.
@@ -35,7 +34,7 @@ Before continuing, please be sure to have the latest version of Docker installed
2. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port. (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)). 2. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port. (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)).
3. Configure an environment file for Shynet, using [this file](/TEMPLATE.env) as a template. (This file is typically named `.env`.) Make sure you set the database settings, or Shynet won't be able to run. 3. Configure an environment file for Shynet, using [this file](/TEMPLATE.env) as a template. (This file is typically named `.env`.) Make sure you set the database settings, or Shynet won't be able to run. Also consider setting `ALLOWED_HOSTS` inside the environment file to your deployment's domain for better security.
4. Launch the Shynet server for the first time by running `docker run --env-file=<your env file> milesmcc/shynet:latest`. Provided you're using the default environment information (i.e., `PERFORM_CHECKS_AND_SETUP` is `True`), you'll see a few warnings about not having an admin user or host setup; these are normal. Don't worry — we'll do this in the next step. You only need to stop if you see a stacktrace about being unable to connect to the database. 4. Launch the Shynet server for the first time by running `docker run --env-file=<your env file> milesmcc/shynet:latest`. Provided you're using the default environment information (i.e., `PERFORM_CHECKS_AND_SETUP` is `True`), you'll see a few warnings about not having an admin user or host setup; these are normal. Don't worry — we'll do this in the next step. You only need to stop if you see a stacktrace about being unable to connect to the database.
@@ -56,7 +55,7 @@ Before continuing, please be sure to have the latest version of Docker installed
1. Clone the repository. 1. Clone the repository.
2. Using [TEMPLATE.env](/TEMPLATE.env) as a template, confiure the environment for your Shynet instance and place the modified config in a file called `.env` in the root of the repository. Do _not_ change the port number at the end; you can set the public facing port in the next step. 2. Using [TEMPLATE.env](/TEMPLATE.env) as a template, configure the environment for your Shynet instance and place the modified config in a file called `.env` in the root of the repository. Do _not_ change the port number at the end; you can set the public facing port in the next step.
3. On line 2 of the `nginx.conf` file located in the root of the repository, replace `example.com` with your hostname. Then, in the `docker-compose.yml` file, set the port number by replacing `8080` in line 38 ( `- 8080:80` ) with whatever local port you want to bind it to. For example, set the port number to `- 80:80` if you want your site will be available via HTTP (port 80) at `http://<your hostname>`. 3. On line 2 of the `nginx.conf` file located in the root of the repository, replace `example.com` with your hostname. Then, in the `docker-compose.yml` file, set the port number by replacing `8080` in line 38 ( `- 8080:80` ) with whatever local port you want to bind it to. For example, set the port number to `- 80:80` if you want your site will be available via HTTP (port 80) at `http://<your hostname>`.
@@ -96,40 +95,6 @@ See the [Render docs](https://render.com/docs/deploy-shynet) for more informatio
## Advanced Usage ## Advanced Usage
### Installation with SSL
If you are going to be running Shynet through a reverse proxy, please see [Configuring a Reverse Proxy](#configuring-a-reverse-proxy) instead.
0. We'll be cloning this into the home directory to make this installation easier, so run `cd ~/` if you need to.
1. Instead of pulling from Docker, we will be pulling from GitHub and building using Docker in order to easily add SSL certificates. You will want to run `git clone https://github.com/milesmcc/shynet.git` to clone the GitHub repo to your current working directory.
2. To install `certbot` follow [the guide here](https://certbot.eff.org/instructions) or follow along below
* Ubuntu 18.04
* `sudo apt-get update`
* `sudo apt-get install software-properties-common`
* `sudo add-apt-repository universe`
* `sudo add-apt-repository ppa:certbot/certbot`
* `sudo apt-get update`
* `sudo apt-get install certbot`
3. Run `sudo certbot certonly --standalone` and follow the instructions to generate your SSL certificate.
* If you registering the certificate to a domain name like `example.com`, please be sure to point your DNS records to your current server before running `certbot`.
4. We are going to move the SSL certificates to the Shynet repo with with command below. Replace `<domain>` with the domain name you used in step 3.
* `cp /etc/letsencrypt/live/<domain>/{cert,privkey}.pem ~/shynet/shynet/`
5. With that, we are going to replace the `webserver.sh` with `ssl.webserver.sh` to enable the use of SSL certificates. The original `webserver.sh` will be backed up to `backup.webserver.sh`
* `mv ~/shynet/shynet/webserver.sh ~/shynet/shynet/backup.webserver.sh`
* `mv ~/shynet/shynet/ssl.webserver.sh ~/shynet/shynet/webserver.sh`
6. Now we build the image!
* `docker image build shynet -t shynet-ssl:latest`
7. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port (default is `5432`). (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)).
8. Follow the [Basic Installation](#basic-installation) guide with just one modification: in step #4, change the local bind port from `80` to `443`, and use `shynet-ssl:latest` as your Docker image instead of `milesmcc/shynet:latest`.
### Configuring a Reverse Proxy ### Configuring a Reverse Proxy
A reverse proxy has many benefits. It can be used for DDoS protection, caching files to reduce server load, routing HTTPS and/or HTTP connections, hosting multiple services on a single server, [and more](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/)! A reverse proxy has many benefits. It can be used for DDoS protection, caching files to reduce server load, routing HTTPS and/or HTTP connections, hosting multiple services on a single server, [and more](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/)!
@@ -243,6 +208,22 @@ In a single-page application, the page never reloads. (That's the entire point o
Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go. Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go.
### API
All the information displayed on the dashboard can be obtained via API on url ```//shynet.example.com/api/v1/dashboard/```. By default this endpoint will return the full data from all services over the last last 30 days. The `Authentication` header should be set to use user's parsonal API token (```'Authorization: Token <user API token>'```).
There are 3 optional query parameters:
* `uuid` - to get data only from one service
* `startDate` - to set start date in format YYYY-MM-DD
* `endDate` - to set end date in format YYYY-MM-DD
Example in HTTPie:
```http get '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01' 'Authorization:Token {{user_api_token}}'```
Example in cURL:
```curl -H 'Authorization:Token {{user_api_token}}' '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01'```
--- ---
## Troubleshooting ## Troubleshooting

View File

@@ -8,7 +8,7 @@
<br> <br>
<strong><a href="#installation">Getting started »</a></strong> <strong><a href="#installation">Getting started »</a></strong>
</p> </p>
<p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#features">Features</a> &bull; <a href="https://github.com/milesmcc/a17t">Design</a></p> <p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#features">Features</a> &bull; <a href="https://miles.land/officehours/">Office Hours</a></p>
</p> </p>
<br> <br>

View File

@@ -17,7 +17,7 @@ spec:
spec: spec:
containers: containers:
- name: "shynet-webserver" - name: "shynet-webserver"
image: "milesmcc/shynet:latest" image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest)
imagePullPolicy: Always imagePullPolicy: Always
envFrom: envFrom:
- secretRef: - secretRef:
@@ -42,7 +42,7 @@ spec:
spec: spec:
containers: containers:
- name: "shynet-celeryworker" - name: "shynet-celeryworker"
image: "milesmcc/shynet:latest" image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest)
command: ["./celeryworker.sh"] command: ["./celeryworker.sh"]
imagePullPolicy: Always imagePullPolicy: Always
envFrom: envFrom:
@@ -95,7 +95,7 @@ spec:
selector: selector:
app: shynet-webserver app: shynet-webserver
--- ---
apiVersion: networking.k8s.io/v1beta1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: shynet-webserver-ingress name: shynet-webserver-ingress

19
package-lock.json generated
View File

@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shynet",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1", "@fortawesome/fontawesome-free": "^5.15.1",
@@ -160,8 +161,7 @@
"esprima": "^3.1.3", "esprima": "^3.1.3",
"estraverse": "^4.2.0", "estraverse": "^4.2.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"optionator": "^0.8.1", "optionator": "^0.8.1"
"source-map": "~0.6.1"
}, },
"bin": { "bin": {
"escodegen": "bin/escodegen.js", "escodegen": "bin/escodegen.js",
@@ -367,9 +367,9 @@
} }
}, },
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
}, },
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.1.2", "version": "1.1.2",
@@ -480,8 +480,7 @@
"esprima": "^4.0.1", "esprima": "^4.0.1",
"estraverse": "^4.2.0", "estraverse": "^4.2.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"optionator": "^0.8.1", "optionator": "^0.8.1"
"source-map": "~0.6.1"
}, },
"bin": { "bin": {
"escodegen": "bin/escodegen.js", "escodegen": "bin/escodegen.js",
@@ -993,9 +992,9 @@
} }
}, },
"path-parse": { "path-parse": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
}, },
"prelude-ls": { "prelude-ls": {
"version": "1.1.2", "version": "1.1.2",

1303
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ PyYAML = "^5.4.1"
user-agents = "^2.2.0" user-agents = "^2.2.0"
rules = "^3.0" rules = "^3.0"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
psycopg2-binary = "^2.9.1" psycopg2-binary = "^2.9.2"
redis = "^3.5.3" redis = "^3.5.3"
django-redis-cache = "^3.0.0" django-redis-cache = "^3.0.0"
pycountry = "^20.7.3" pycountry = "^20.7.3"
@@ -26,6 +26,15 @@ django-health-check = "^3.16.4"
django-npm = "^1.0.0" django-npm = "^1.0.0"
python-dotenv = "^0.18.0" python-dotenv = "^0.18.0"
django-debug-toolbar = "^3.2.1" django-debug-toolbar = "^3.2.1"
django-cors-headers = "^3.11.0"
[tool.poetry.dev-dependencies]
pytest-sugar = "^0.9.4"
factory-boy = "^3.2.0"
pytest-django = "^4.4.0"
django-coverage-plugin = "^2.0.0"
django-stubs = "^1.8.0"
mypy = "^0.910"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@@ -1,23 +1,23 @@
<nav class="flex w-full flex-wrap items-center justify-between" role="navigation" aria-label="pagination"> <nav class="flex w-full flex-wrap items-center justify-between" role="navigation" aria-label="pagination">
<div class="w-full md:w-auto mb-2"> <div class="w-full md:w-auto mb-2">
{% if page.has_previous %} {% if page.has_previous %}
<a href="?page={{ page.previous_page_number }}&{{url_parameters}}" class="button field bg-neutral-000 w-auto mr-1">Previous</a> <a href="?page={{ page.previous_page_number }}&{{url_parameters}}" class="button field !low bg-neutral-000 w-auto mr-1">Previous</a>
{% else %} {% else %}
<a class="button field bg-neutral-000 w-auto mr-1" disabled>Previous</a> <a class="button field !low bg-neutral-000 w-auto mr-1" disabled>Previous</a>
{% endif %} {% endif %}
{% if page.has_next %} {% if page.has_next %}
<a href="?page={{ page.next_page_number }}&{{url_parameters}}" class="button field bg-neutral-000 w-auto">Next</a> <a href="?page={{ page.next_page_number }}&{{url_parameters}}" class="button field !low bg-neutral-000 w-auto">Next</a>
{% else %} {% else %}
<a class="button field bg-neutral-000 w-auto" disabled>Next</a> <a class="button field !low bg-neutral-000 w-auto" disabled>Next</a>
{% endif %} {% endif %}
</div> </div>
<ul class="pagination-list w-full md:w-auto mb-2 flex"> <ul class="pagination-list w-full md:w-auto mb-2 flex">
{% for pnum in begin %} {% for pnum in begin %}
{% ifequal page.number pnum %} {% ifequal page.number pnum %}
<li><a class="button field w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li> <li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
{% else %} {% else %}
<li><a class="button field bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li> <li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
{% endifequal %} {% endifequal %}
{% endfor %} {% endfor %}
@@ -25,9 +25,9 @@
<li><span class="pagination-ellipsis">&hellip;</span></li> <li><span class="pagination-ellipsis">&hellip;</span></li>
{% for pnum in middle %} {% for pnum in middle %}
{% ifequal page.number pnum %} {% ifequal page.number pnum %}
<li><a class="button field w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li> <li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
{% else %} {% else %}
<li><a class="button field bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li> <li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
{% endifequal %} {% endifequal %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -36,9 +36,9 @@
<li><span class="pagination-ellipsis">&hellip;</span></li> <li><span class="pagination-ellipsis">&hellip;</span></li>
{% for pnum in end %} {% for pnum in end %}
{% ifequal page.number pnum %} {% ifequal page.number pnum %}
<li><a class="button field w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li> <li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
{% else %} {% else %}
<li><a class="button field bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li> <li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
{% endifequal %} {% endifequal %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}

0
shynet/api/__init__.py Normal file
View File

1
shynet/api/admin.py Normal file
View File

@@ -0,0 +1 @@
# from django.contrib import admin

6
shynet/api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View File

23
shynet/api/mixins.py Normal file
View File

@@ -0,0 +1,23 @@
from django.http import JsonResponse
from django.contrib.auth.models import AnonymousUser
from core.models import User
class ApiTokenRequiredMixin:
def _get_user_by_token(self, request):
token = request.headers.get('Authorization')
if not token or not token.startswith('Token '):
return AnonymousUser()
token = token.split(' ')[1]
user = User.objects.filter(api_token=token).first()
return user if user else AnonymousUser()
def dispatch(self, request, *args, **kwargs):
request.user = self._get_user_by_token(request)
if not request.user.is_authenticated:
return JsonResponse(data={}, status=403)
return super().dispatch(request, *args, **kwargs)

1
shynet/api/models.py Normal file
View File

@@ -0,0 +1 @@
# from django.db import models

3
shynet/api/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

7
shynet/api/urls.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("dashboard/", views.DashboardApiView.as_view(), name="services"),
]

60
shynet/api/views.py Normal file
View File

@@ -0,0 +1,60 @@
import uuid
from django.http import JsonResponse
from django.db.models import Q
from django.db.models.query import QuerySet
from django.views.generic import View
from dashboard.mixins import DateRangeMixin
from core.models import Service
from .mixins import ApiTokenRequiredMixin
def is_valid_uuid(value):
try:
uuid.UUID(value)
return True
except ValueError:
return False
class DashboardApiView(ApiTokenRequiredMixin, DateRangeMixin, View):
def get(self, request, *args, **kwargs):
services = Service.objects.filter(
Q(owner=request.user) | Q(collaborators__in=[request.user])
).distinct()
uuid = request.GET.get('uuid')
if uuid and is_valid_uuid(uuid):
services = services.filter(uuid=uuid)
try:
start = self.get_start_date()
end = self.get_end_date()
except ValueError:
return JsonResponse(status=400, data={'error': 'Invalid date format'})
services_data = [
{
'name': s.name,
'uuid': s.uuid,
'link': s.link,
'stats': s.get_core_stats(start, end),
}
for s in services
]
services_data = self._convert_querysets_to_lists(services_data)
return JsonResponse(data={'services': services_data})
def _convert_querysets_to_lists(self, services_data):
for service_data in services_data:
for key, value in service_data['stats'].items():
if isinstance(value, QuerySet):
service_data['stats'][key] = list(value)
for key, value in service_data['stats']['compare'].items():
if isinstance(value, QuerySet):
service_data['stats']['compare'][key] = list(value)
return services_data

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.2.5 on 2021-11-17 07:17
import core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_auto_20200628_1403'),
]
operations = [
migrations.AddField(
model_name='user',
name='api_token',
field=models.TextField(default=core.models._default_api_token, unique=True),
),
migrations.AlterField(
model_name='user',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -1,8 +1,9 @@
import ipaddress import ipaddress
import json
import re import re
import uuid import uuid
from secrets import token_urlsafe
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
@@ -43,9 +44,14 @@ def _parse_network_list(networks: str):
return [ipaddress.ip_network(network.strip()) for network in networks.split(",")] return [ipaddress.ip_network(network.strip()) for network in networks.split(",")]
def _default_api_token():
return token_urlsafe(32)
class User(AbstractUser): class User(AbstractUser):
username = models.TextField(default=_default_uuid, unique=True) username = models.TextField(default=_default_uuid, unique=True)
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
api_token = models.TextField(default=_default_api_token, unique=True)
def __str__(self): def __str__(self):
return self.email return self.email
@@ -203,6 +209,7 @@ class Service(models.Model):
chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data( chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data(
sessions, hits, start_time, end_time, tz_now sessions, hits, start_time, end_time, tz_now
) )
return { return {
"currently_online": currently_online, "currently_online": currently_online,
"session_count": session_count, "session_count": session_count,

View File

@@ -45,11 +45,6 @@ class DateRangeMixin:
"start": now.replace(day=1), "start": now.replace(day=1),
"end": now, "end": now,
}, },
{
"name": "Last month",
"start": now.replace(day=1, month=now.month - 1),
"end": now.replace(day=1, month=now.month) - timezone.timedelta(days=1),
},
{ {
"name": "This year", "name": "This year",
"start": now.replace(day=1, month=1), "start": now.replace(day=1, month=1),

View File

@@ -2,8 +2,8 @@
{% load i18n a17t_tags %} {% load i18n a17t_tags %}
{% block head_title %}{% trans "Change Password" %}{% endblock %} {% block head_title %}{% trans "Change authentication info" %}{% endblock %}
{% block page_title %}{% trans "Change Password" %}{% endblock %} {% block page_title %}{% trans "Change authentication info" %}{% endblock %}
{% block card %} {% block card %}
<form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg"> <form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg">
@@ -11,4 +11,17 @@
{{ form|a17t }} {{ form|a17t }}
<button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button> <button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button>
</form> </form>
<hr class="sep">
<div>
<p class="label mb-1">Personal API token</p>
<div class="flex justify-between">
<span class='chip ~info !normal'>{{request.user.api_token}}</span>
<form method="POST" action="{% url 'dashboard:api_token_refresh' %}">
{% csrf_token %}
<button type="submit" name="action" class="button ~neutral @high">{% trans "Refresh token" %}</button>
</form>
</div>
<p class="support mt-1">To learn more about the API, see our <a href="https://github.com/milesmcc/shynet/blob/master/GUIDE.md#api">API guide</a>.</p>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -2,7 +2,7 @@
<input type="hidden" name="startDate" value="{{start_date.isoformat}}" id="startDate"> <input type="hidden" name="startDate" value="{{start_date.isoformat}}" id="startDate">
<input type="hidden" name="endDate" value="{{end_date.isoformat}}" id="endDate"> <input type="hidden" name="endDate" value="{{end_date.isoformat}}" id="endDate">
</form> </form>
<input type="input" id="rangePicker" placeholder="Date range" class="input ~neutral bg-neutral-000 cursor-pointer" style="max-width: 200px;" readonly> <input type="input" id="rangePicker" placeholder="Date range" class="input ~neutral !low bg-neutral-000 cursor-pointer" style="max-width: 200px;" readonly>
<style> <style>
:root { :root {
--litepicker-button-prev-month-color-hover: var(--color-urge); --litepicker-button-prev-month-color-hover: var(--color-urge);

View File

@@ -13,7 +13,7 @@
</div> </div>
{% has_perm "core.create_service" user as can_create %} {% has_perm "core.create_service" user as can_create %}
{% if can_create %} {% if can_create %}
<a href="{% url 'dashboard:service_create' %}" class="button field bg-neutral-000 w-auto">+ New Service</a> <a href="{% url 'dashboard:service_create' %}" class="button field !low bg-neutral-000 w-auto">+ New Service</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div> <div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
{% has_perm 'core.change_service' user object as can_update %} {% has_perm 'core.change_service' user object as can_update %}
{% if can_update %} {% if can_update %}
<a href="{% contextual_url 'dashboard:service_update' service.uuid %}" class="button field bg-neutral-000 w-auto">Manage &rarr;</a> <a href="{% contextual_url 'dashboard:service_update' service.uuid %}" class="button field !low bg-neutral-000 w-auto">Manage &rarr;</a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
{% block head_title %}{{object.name}} Session{% endblock %} {% block head_title %}{{object.name}} Session{% endblock %}
{% block service_actions %} {% block service_actions %}
<a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field bg-neutral-000 w-auto">Analytics &rarr;</a> <a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field !low bg-neutral-000 w-auto">Analytics &rarr;</a>
{% endblock %} {% endblock %}
{% block service_content %} {% block service_content %}

View File

@@ -6,7 +6,7 @@
{% block service_actions %} {% block service_actions %}
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div> <div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
<a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field ~neutral bg-neutral-000 w-auto">Analytics &rarr;</a> <a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field ~neutral !low bg-neutral-000 w-auto">Analytics &rarr;</a>
{% endblock %} {% endblock %}
{% block service_content %} {% block service_content %}

View File

@@ -5,7 +5,7 @@
{% block head_title %}{{object.name}} Management{% endblock %} {% block head_title %}{{object.name}} Management{% endblock %}
{% block service_actions %} {% block service_actions %}
<a href="{% url 'dashboard:service' object.uuid %}" class="button field bg-neutral-000 w-auto">View &rarr;</a> <a href="{% url 'dashboard:service' object.uuid %}" class="button field !low bg-neutral-000 w-auto">View &rarr;</a>
{% endblock %} {% endblock %}
{% block service_content %} {% block service_content %}
@@ -30,5 +30,20 @@
</div> </div>
</div> </div>
</form> </form>
<hr class="sep h-4">
<h5>API</h5>
<div class="card ~neutral !low content">
<p>Shynet provides a simple API that you can use to pull data programmatically. You can access this data via this URL:</p>
<code class="text-sm">{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}</code>
<p>
There are 2 optional query parameters:
<ul>
<li><code class="text-sm">startDate</code> &mdash; to set the start date (in format YYYY-MM-DD)</li>
<li><code class="text-sm">endDate</code> &mdash; to set the end date (in format YYYY-MM-DD)</li>
</ul>
</p>
<p>Example using cURL:</p>
<code class="text-sm">curl -H 'Authorization: Token {{request.user.api_token}}' '{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}&startDate=2021-01-01&endDate=2050-01-01'</code>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,4 @@
from django.contrib import admin from django.urls import path
from django.urls import include, path
from django.views.generic import RedirectView
from . import views from . import views
@@ -28,4 +26,9 @@ urlpatterns = [
views.ServiceSessionView.as_view(), views.ServiceSessionView.as_view(),
name="service_session", name="service_session",
), ),
path(
"api-token-refresh/",
views.RefreshApiTokenView.as_view(),
name="api_token_refresh",
),
] ]

View File

@@ -3,8 +3,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404, reverse from django.shortcuts import get_object_or_404, reverse, redirect
from django.utils import timezone
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
@@ -12,11 +11,12 @@ from django.views.generic import (
ListView, ListView,
TemplateView, TemplateView,
UpdateView, UpdateView,
View,
) )
from rules.contrib.views import PermissionRequiredMixin from rules.contrib.views import PermissionRequiredMixin
from analytics.models import Session from analytics.models import Session
from core.models import Service from core.models import Service, _default_api_token
from .forms import ServiceForm from .forms import ServiceForm
from .mixins import DateRangeMixin from .mixins import DateRangeMixin
@@ -155,3 +155,10 @@ class ServiceSessionView(LoginRequiredMixin, PermissionRequiredMixin, DetailView
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data["object"] = get_object_or_404(Service, pk=self.kwargs.get("pk")) data["object"] = get_object_or_404(Service, pk=self.kwargs.get("pk"))
return data return data
class RefreshApiTokenView(LoginRequiredMixin, View):
def post(self, request):
request.user.api_token = _default_api_token()
request.user.save()
return redirect('account_change_password')

View File

@@ -22,7 +22,7 @@ from django.contrib.messages import constants as messages
load_dotenv() load_dotenv()
# Increment on new releases # Increment on new releases
VERSION = "0.11.0" VERSION = "0.12.0"
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -59,16 +59,19 @@ INSTALLED_APPS = [
"core", "core",
"dashboard.apps.DashboardConfig", "dashboard.apps.DashboardConfig",
"analytics", "analytics",
"api",
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount", "allauth.socialaccount",
"debug_toolbar", "debug_toolbar",
"corsheaders",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
@@ -370,3 +373,6 @@ DASHBOARD_PAGE_SIZE = int(os.getenv("DASHBOARD_PAGE_SIZE", "5"))
USE_RELATIVE_MAX_IN_BAR_VISUALIZATION = ( USE_RELATIVE_MAX_IN_BAR_VISUALIZATION = (
os.getenv("USE_RELATIVE_MAX_IN_BAR_VISUALIZATION", "True") == "True" os.getenv("USE_RELATIVE_MAX_IN_BAR_VISUALIZATION", "True") == "True"
) )
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_METHODS = ["GET", "OPTIONS"]

View File

@@ -25,4 +25,5 @@ urlpatterns = [
path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")), path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")),
path("healthz/", include("health_check.urls")), path("healthz/", include("health_check.urls")),
path("", include(("core.urls", "core"), namespace="core")), path("", include(("core.urls", "core"), namespace="core")),
path("api/v1/", include(("api.urls", "api"), namespace="api")),
] ]

16
tests/js.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>JS test</title>
</head>
<body>
<noscript>
<img src="http://localhost:8000/ingress/0ca733e8-c41f-462b-a11a-4ba0cea29948/pixel.gif">
</noscript>
<script defer src="http://localhost:8000/ingress/0ca733e8-c41f-462b-a11a-4ba0cea29948/script.js"></script>
</body>
</html>

13
tests/pixel.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Pixel test</title>
</head>
<body>
<img src="http://localhost:8000/ingress/9b2c4e2f-8d29-4418-82d4-b68e06795025/pixel.gif">
</body>
</html>