Add Saleor Monitoring app (#189)
* initial commit * Remove pre-commit-config * Update gitignore * Update README * Add better config for monitoring app (#190) --------- Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
This commit is contained in:
parent
1c9b2c487a
commit
b33bfd35af
91 changed files with 13859 additions and 0 deletions
4
apps/monitoring/.eslintrc
Normal file
4
apps/monitoring/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
125
apps/monitoring/.gitignore
vendored
Normal file
125
apps/monitoring/.gitignore
vendored
Normal file
|
@ -0,0 +1,125 @@
|
|||
# Environments
|
||||
.venv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Ruff
|
||||
.ruff_cache
|
||||
|
||||
# Backend
|
||||
.fileApl.json
|
20
apps/monitoring/.graphqlrc.yml
Normal file
20
apps/monitoring/.graphqlrc.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
schema: backend/monitoring/schema.graphql
|
||||
documents: [src/**/*.tsx]
|
||||
extensions:
|
||||
codegen:
|
||||
overwrite: true
|
||||
generates:
|
||||
generated/graphql.ts:
|
||||
config:
|
||||
dedupeFragments: true
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- urql-introspection
|
||||
- typescript-urql:
|
||||
documentVariablePrefix: "Untyped"
|
||||
fragmentVariablePrefix: "Untyped"
|
||||
- typed-document-node
|
||||
generated/schema.graphql:
|
||||
plugins:
|
||||
- schema-ast
|
6
apps/monitoring/.prettierignore
Normal file
6
apps/monitoring/.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
.next
|
||||
saleor/api.tsx
|
||||
pnpm-lock.yaml
|
||||
graphql/schema.graphql
|
||||
generated
|
||||
backend
|
4
apps/monitoring/.prettierrc
Normal file
4
apps/monitoring/.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": false,
|
||||
"printWidth": 100
|
||||
}
|
64
apps/monitoring/README.md
Normal file
64
apps/monitoring/README.md
Normal file
|
@ -0,0 +1,64 @@
|
|||
# Saleor Monitoring app
|
||||
|
||||
❗️NOTE: This is Alpha version of the app.❗️
|
||||
|
||||
## Local development
|
||||
|
||||
### Start Monitoring backend
|
||||
Run:
|
||||
```shell
|
||||
docker-compose up
|
||||
```
|
||||
It is beneficial to run this command in a separate terminal tab to observe backend logs easily.
|
||||
|
||||
By default, backend will run at `localhost:5001` with:
|
||||
- Manifest at `/manifest`
|
||||
- Graphql Playground at `/graphql`
|
||||
- OpenApi viewer at `/docs`
|
||||
|
||||
### Develop frontend:
|
||||
|
||||
Installing dependencies with:
|
||||
```shell
|
||||
pnpm i
|
||||
```
|
||||
|
||||
Running dev server
|
||||
```shell
|
||||
pnpm dev
|
||||
```
|
||||
The frontend app will run at `localhost:3000`.
|
||||
By default, it acts as a proxy and redirects all unhandled requests to the backend (configured by `MONITORING_APP_API_URL` env).
|
||||
This way, all frontend and backend endpoints are accessible at `http://localhost:3000`
|
||||
|
||||
### Test with Saleor
|
||||
Expose `http://localhost:3000` using a tunnel and use `https://your.tunnel/manifest` manifest URL to install `Monitoring` app
|
||||
|
||||
### Graphql Playground
|
||||
To use Graphql Playground, `Monitoring` app needs to be installed in Saleor, and HTTP headers must be set:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization-bearer": "token",
|
||||
"saleor-api-url": "https://my-env.saleor.cloud/graphql/"
|
||||
}
|
||||
```
|
||||
|
||||
### Testing DataDog integration
|
||||
Use these credentials sets to test DataDog integration:
|
||||
|
||||
Working credentials:
|
||||
```json
|
||||
{
|
||||
"site": "US1",
|
||||
"apiKey": "156e22d50c4e8b6816e1fd4794d3fd8c"
|
||||
}
|
||||
```
|
||||
|
||||
Credentials that validate but generate an error while sending events
|
||||
```json
|
||||
{
|
||||
"site": "EU1",
|
||||
"apiKey": "156e22d50c4e8b6816e1fd4794d3fd8c"
|
||||
}
|
||||
```
|
1
apps/monitoring/backend/.dockerignore
Normal file
1
apps/monitoring/backend/.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
**/.fileApl.json
|
28
apps/monitoring/backend/Dockerfile
Normal file
28
apps/monitoring/backend/Dockerfile
Normal file
|
@ -0,0 +1,28 @@
|
|||
FROM python:3.10 as build
|
||||
|
||||
RUN pip install poetry'>=1.3.2,<1.4.0'
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml poetry.lock /app/
|
||||
|
||||
RUN POETRY_VIRTUALENVS_CREATE=false poetry install --no-cache --only main
|
||||
|
||||
FROM python:3.10-slim as prod
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
COPY --from=build /usr/local/bin/ /usr/local/bin/
|
||||
COPY --from=build /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY monitoring/ /app/monitoring/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["uvicorn", "monitoring.app:app", "--host", "0.0.0.0", "--port", "80", "--no-access-log", "--forwarded-allow-ips", "*"]
|
||||
|
||||
FROM prod as dev
|
||||
|
||||
COPY pyproject.toml poetry.lock /app/
|
||||
RUN POETRY_VIRTUALENVS_CREATE=false poetry install --no-cache --only dev
|
1
apps/monitoring/backend/monitoring/__init__.py
Normal file
1
apps/monitoring/backend/monitoring/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
15
apps/monitoring/backend/monitoring/__main__.py
Normal file
15
apps/monitoring/backend/monitoring/__main__.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
uvicorn.run(
|
||||
"monitoring.app:app",
|
||||
host="0.0.0.0",
|
||||
port=5001,
|
||||
reload=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
93
apps/monitoring/backend/monitoring/api.py
Normal file
93
apps/monitoring/backend/monitoring/api.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
from pathlib import Path
|
||||
|
||||
from ariadne import (
|
||||
MutationType,
|
||||
ObjectType,
|
||||
QueryType,
|
||||
convert_kwargs_to_snake_case,
|
||||
load_schema_from_path,
|
||||
make_executable_schema,
|
||||
snake_case_fallback_resolvers,
|
||||
)
|
||||
from ariadne.asgi import GraphQL
|
||||
from graphql import GraphQLResolveInfo
|
||||
|
||||
from .deps import ApiDependencies
|
||||
from .schema import DatadogConfig, DatadogCredentials
|
||||
from .settings import settings
|
||||
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
type_defs = load_schema_from_path(str(base_dir / "schema.graphql"))
|
||||
query = QueryType()
|
||||
datadog_config = ObjectType("DatadogConfig")
|
||||
mutation = MutationType()
|
||||
|
||||
|
||||
def get_api_context(info: GraphQLResolveInfo) -> ApiDependencies:
|
||||
return info.context["request"].state.api_context
|
||||
|
||||
|
||||
@datadog_config.field("credentials")
|
||||
def resolve_datadog_credentials(datadog: DatadogConfig, *_):
|
||||
return {
|
||||
"site": datadog.credentials.site,
|
||||
"api_key_last_4": datadog.credentials.api_key[-4:],
|
||||
}
|
||||
|
||||
|
||||
@query.field("integrations")
|
||||
async def resolve_integrations(_, info):
|
||||
context = get_api_context(info)
|
||||
metadata = await context.manager.get_metadata()
|
||||
return metadata
|
||||
|
||||
|
||||
@mutation.field("updateDatadogConfig")
|
||||
@convert_kwargs_to_snake_case
|
||||
async def resolve_update_datadog(_, info, input):
|
||||
context = get_api_context(info)
|
||||
metadata = await context.manager.get_metadata()
|
||||
|
||||
if creds := input.get("credentials", None):
|
||||
creds = DatadogCredentials(site=creds["site"], api_key=creds["api_key"])
|
||||
if not await context.datadog_client.validate_credentials(creds):
|
||||
return {
|
||||
"errors": [
|
||||
{
|
||||
"field": "credentials",
|
||||
"message": f"Invalid datadog apiKey for site {creds.site}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Create new configuration
|
||||
if metadata.datadog is None:
|
||||
if creds is None:
|
||||
return {"errors": [{"message": "No DataDog config to update"}]}
|
||||
metadata.datadog = DatadogConfig(credentials=creds)
|
||||
|
||||
if "active" in input:
|
||||
metadata.datadog.active = input["active"]
|
||||
if creds:
|
||||
metadata.datadog.credentials = creds
|
||||
metadata.datadog.error = None
|
||||
|
||||
await context.manager.save_private_metadata(metadata)
|
||||
return {"datadog": metadata.datadog, "errors": []}
|
||||
|
||||
|
||||
@mutation.field("deleteDatadogConfig")
|
||||
async def resolve_delete_datadog(_, info):
|
||||
context = get_api_context(info)
|
||||
metadata = await context.manager.get_metadata()
|
||||
if metadata.datadog is None:
|
||||
return {"errors": [{"message": "No DataDog config to delete"}]}
|
||||
await context.manager.delete_private_metadata("datadog")
|
||||
return {"datadog": metadata.datadog, "errors": []}
|
||||
|
||||
|
||||
schema = make_executable_schema(
|
||||
type_defs, query, datadog_config, mutation, snake_case_fallback_resolvers
|
||||
)
|
||||
graphQL = GraphQL(schema, debug=settings.debug)
|
||||
graphql_app = graphQL.http_handler
|
12
apps/monitoring/backend/monitoring/apl/__init__.py
Normal file
12
apps/monitoring/backend/monitoring/apl/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from .common import AplEntity, AplError, AplKeyError
|
||||
from .wrapper import AplClient
|
||||
|
||||
apl_client = AplClient()
|
||||
|
||||
__all__ = [
|
||||
"apl_client",
|
||||
"AplClient",
|
||||
"AplEntity",
|
||||
"AplError",
|
||||
"AplKeyError",
|
||||
]
|
|
@ -0,0 +1,6 @@
|
|||
from .base import AplBackend
|
||||
from .file import FileAplBackend
|
||||
from .mem import MemAplBackend
|
||||
from .rest import RestAplBackend
|
||||
|
||||
__all__ = ["AplBackend", "MemAplBackend", "FileAplBackend", "RestAplBackend"]
|
25
apps/monitoring/backend/monitoring/apl/backends/base.py
Normal file
25
apps/monitoring/backend/monitoring/apl/backends/base.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from typing import AsyncGenerator
|
||||
|
||||
from ..common import AplEntity
|
||||
|
||||
|
||||
class AplBackend:
|
||||
async def get(self, key: str) -> AplEntity:
|
||||
raise NotImplementedError(
|
||||
"subclasses of BaseAPLClient must provide a get() method"
|
||||
)
|
||||
|
||||
async def set(self, key: str, value: AplEntity):
|
||||
raise NotImplementedError(
|
||||
"subclasses of BaseAPLClient must provide a add() method"
|
||||
)
|
||||
|
||||
async def delete(self, key: str):
|
||||
raise NotImplementedError(
|
||||
"subclasses of BaseAPLClient must provide a delete() method"
|
||||
)
|
||||
|
||||
def get_all(self, page_size: int) -> AsyncGenerator[tuple[str, AplEntity], None]:
|
||||
raise NotImplementedError(
|
||||
"subclasses of BaseAPLClient must provide a delete() method"
|
||||
)
|
31
apps/monitoring/backend/monitoring/apl/backends/file.py
Normal file
31
apps/monitoring/backend/monitoring/apl/backends/file.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from json import dumps
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import parse_file_as
|
||||
|
||||
from ..common import AplEntity
|
||||
from .mem import MemAplBackend
|
||||
|
||||
|
||||
class FileAplBackend(MemAplBackend):
|
||||
def _load_file(self):
|
||||
self._apl = parse_file_as(dict[str, AplEntity], self.path)
|
||||
|
||||
def _save_file(self):
|
||||
data = dumps(jsonable_encoder(self._apl), indent=2)
|
||||
self.path.write_text(data)
|
||||
|
||||
def __init__(self, path: Path):
|
||||
super().__init__()
|
||||
self.path = path
|
||||
if self.path.exists():
|
||||
self._load_file()
|
||||
|
||||
async def set(self, *args, **kwargs):
|
||||
await super().set(*args, **kwargs)
|
||||
self._save_file()
|
||||
|
||||
async def delete(self, *args, **kwargs):
|
||||
await super().delete(*args, **kwargs)
|
||||
self._save_file()
|
26
apps/monitoring/backend/monitoring/apl/backends/mem.py
Normal file
26
apps/monitoring/backend/monitoring/apl/backends/mem.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from ..common import AplEntity, AplKeyError
|
||||
from .base import AplBackend
|
||||
|
||||
|
||||
class MemAplBackend(AplBackend):
|
||||
def __init__(self):
|
||||
self._apl: dict[str, AplEntity] = {}
|
||||
|
||||
async def get(self, key: str):
|
||||
try:
|
||||
return self._apl[key]
|
||||
except KeyError as err:
|
||||
raise AplKeyError(f"Key: {key} not found in MemApl") from err
|
||||
|
||||
async def set(self, key: str, value: AplEntity):
|
||||
self._apl[key] = value
|
||||
|
||||
async def delete(self, key: str):
|
||||
try:
|
||||
del self._apl[key]
|
||||
except KeyError as err:
|
||||
raise AplKeyError(f"Key: {key} not found in MemApl") from err
|
||||
|
||||
async def get_all(self, page_size: int):
|
||||
for key, val in self._apl.items():
|
||||
yield key, val
|
83
apps/monitoring/backend/monitoring/apl/backends/rest.py
Normal file
83
apps/monitoring/backend/monitoring/apl/backends/rest.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
import json
|
||||
from base64 import urlsafe_b64encode
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..common import AplEntity, AplError, AplKeyError
|
||||
from .base import AplBackend
|
||||
|
||||
|
||||
class AplPage(BaseModel):
|
||||
count: int
|
||||
next: str | None
|
||||
previous: str | None
|
||||
results: list[AplEntity]
|
||||
|
||||
|
||||
class RestAplBackend(AplBackend):
|
||||
def __init__(self, apl_url: str, token: str):
|
||||
self.apl_url = apl_url
|
||||
self.token = token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
@staticmethod
|
||||
def map_apl_entity(value: AplEntity):
|
||||
return {
|
||||
"saleor_app_id": value.app_id,
|
||||
"saleor_api_url": value.saleor_api_url,
|
||||
"jwks": json.dumps(value.jwks),
|
||||
"domain": urlparse(value.saleor_api_url).netloc,
|
||||
"token": value.app_token,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def b64_encode(key: str):
|
||||
return urlsafe_b64encode(key.encode()).decode()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self):
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
async with httpx.AsyncClient(base_url=self.apl_url, headers=headers) as client:
|
||||
try:
|
||||
yield client
|
||||
except httpx.HTTPError as exc:
|
||||
raise AplError("RestApl error") from exc
|
||||
|
||||
async def set(self, key: str, value: AplEntity):
|
||||
async with self._client() as client:
|
||||
resp = await client.post("/api/v1/apl", json=self.map_apl_entity(value))
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get(self, key: str):
|
||||
async with self._client() as client:
|
||||
resp = await client.get(f"/api/v1/apl/{self.b64_encode(key)}")
|
||||
if resp.status_code == 404:
|
||||
raise AplKeyError(f"Key: {key} not found in RestApl")
|
||||
resp.raise_for_status()
|
||||
return AplEntity.parse_raw(resp.content)
|
||||
|
||||
async def delete(self, key: str):
|
||||
async with self._client() as client:
|
||||
resp = await client.delete(f"/api/v1/apl/{self.b64_encode(key)}")
|
||||
if resp.status_code == 404:
|
||||
raise AplKeyError(f"Key: {key} not found in RestApl")
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_all(self, page_size: int):
|
||||
async with self._client() as client:
|
||||
offset, count = 0, 1
|
||||
while offset < count:
|
||||
params = {"limit": page_size, "offset": offset}
|
||||
resp = await client.get("/api/v1/apl", params=params)
|
||||
resp.raise_for_status()
|
||||
page = AplPage.parse_raw(resp.content)
|
||||
count = page.count
|
||||
offset += page_size
|
||||
for elem in page.results:
|
||||
yield elem.saleor_api_url, elem
|
32
apps/monitoring/backend/monitoring/apl/common.py
Normal file
32
apps/monitoring/backend/monitoring/apl/common.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field, validator
|
||||
|
||||
|
||||
class AplEntity(BaseModel):
|
||||
saleor_api_url: AnyHttpUrl
|
||||
app_id: str = Field(..., alias="saleor_app_id")
|
||||
app_token: str = Field(..., alias="token")
|
||||
jwks: dict[str, Any]
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@validator("jwks", pre=True)
|
||||
def parse_json(cls, v):
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
class AplError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AplKeyError(AplError):
|
||||
pass
|
||||
|
||||
|
||||
class NotConfiguredError(AplError):
|
||||
"""If apl client was not configured"""
|
0
apps/monitoring/backend/monitoring/apl/tests/__init__.py
Normal file
0
apps/monitoring/backend/monitoring/apl/tests/__init__.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
interactions:
|
||||
- request:
|
||||
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
|
||||
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:51:49 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: DELETE
|
||||
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
|
||||
response:
|
||||
content: ''
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '0'
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:51:49 GMT
|
||||
allow:
|
||||
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 204
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
|
||||
response:
|
||||
content: '{"detail":"Not found."}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '23'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:51:50 GMT
|
||||
allow:
|
||||
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 404
|
||||
version: 1
|
|
@ -0,0 +1,44 @@
|
|||
interactions:
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: DELETE
|
||||
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9ub24uZXhpc3RpbmcuZG9tYWlu
|
||||
response:
|
||||
content: '{"detail":"Not found."}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '23'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:59:52 GMT
|
||||
allow:
|
||||
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 404
|
||||
version: 1
|
|
@ -0,0 +1,98 @@
|
|||
interactions:
|
||||
- request:
|
||||
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
|
||||
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:48:47 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:48:48 GMT
|
||||
allow:
|
||||
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
version: 1
|
|
@ -0,0 +1,742 @@
|
|||
interactions:
|
||||
- request:
|
||||
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
|
||||
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:37 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "fzglnnohvi", "saleor_api_url": "https://msltedyydo.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "msltedyydo.saleor.cloud",
|
||||
"token": "etqfdiojfvufyfpylknharwbkkhcio"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://msltedyydo.saleor.cloud/graphql","token":"etqfdiojfvufyfpylknharwbkkhcio","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fzglnnohvi","domain":"msltedyydo.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:38 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "lcedtozpeo", "saleor_api_url": "https://rodnbaorcb.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "rodnbaorcb.saleor.cloud",
|
||||
"token": "nyqokxizwonkuqqexhktbvgussupaz"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://rodnbaorcb.saleor.cloud/graphql","token":"nyqokxizwonkuqqexhktbvgussupaz","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"lcedtozpeo","domain":"rodnbaorcb.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:38 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "vxrgitlduz", "saleor_api_url": "https://vjoxywqxno.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vjoxywqxno.saleor.cloud",
|
||||
"token": "uagntnmzugcynzcblilonyguxjowjq"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://vjoxywqxno.saleor.cloud/graphql","token":"uagntnmzugcynzcblilonyguxjowjq","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"vxrgitlduz","domain":"vjoxywqxno.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:39 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "mtljtfhfjd", "saleor_api_url": "https://txjaskukcx.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "txjaskukcx.saleor.cloud",
|
||||
"token": "rzbhsxcngssozsamgksgqzsyykbvws"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://txjaskukcx.saleor.cloud/graphql","token":"rzbhsxcngssozsamgksgqzsyykbvws","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"mtljtfhfjd","domain":"txjaskukcx.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:39 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "krqomgdnue", "saleor_api_url": "https://wptoxotfln.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "wptoxotfln.saleor.cloud",
|
||||
"token": "bfuugsnzlrmbtbfjhbsfduslfjbmty"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://wptoxotfln.saleor.cloud/graphql","token":"bfuugsnzlrmbtbfjhbsfduslfjbmty","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"krqomgdnue","domain":"wptoxotfln.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:39 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "qtpthrjavf", "saleor_api_url": "https://nxujxjrudl.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "nxujxjrudl.saleor.cloud",
|
||||
"token": "vmwhdquypdsmsgjlcgtrqacmzbaunk"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://nxujxjrudl.saleor.cloud/graphql","token":"vmwhdquypdsmsgjlcgtrqacmzbaunk","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"qtpthrjavf","domain":"nxujxjrudl.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:40 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "nxvftnbrfb", "saleor_api_url": "https://vyyfdwmcav.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vyyfdwmcav.saleor.cloud",
|
||||
"token": "wmsookfbmuopwwlvceiifcoaeicqje"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://vyyfdwmcav.saleor.cloud/graphql","token":"wmsookfbmuopwwlvceiifcoaeicqje","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"nxvftnbrfb","domain":"vyyfdwmcav.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:40 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "dpjoofueam", "saleor_api_url": "https://ejwnealqvw.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "ejwnealqvw.saleor.cloud",
|
||||
"token": "teovwyfkqvpebpsmfpwigpqlusyrme"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://ejwnealqvw.saleor.cloud/graphql","token":"teovwyfkqvpebpsmfpwigpqlusyrme","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"dpjoofueam","domain":"ejwnealqvw.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:41 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: '{"saleor_app_id": "fxeyumrwrg", "saleor_api_url": "https://vxcdclzonj.saleor.cloud/graphql",
|
||||
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vxcdclzonj.saleor.cloud",
|
||||
"token": "vxhxemgqtpvvmzgcrxxetsrecvmocg"}'
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '337'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: POST
|
||||
uri: https://apl.example.com/api/v1/apl
|
||||
response:
|
||||
content: '{"saleor_api_url":"https://vxcdclzonj.saleor.cloud/graphql","token":"vxhxemgqtpvvmzgcrxxetsrecvmocg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fxeyumrwrg","domain":"vxcdclzonj.saleor.cloud"}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '328'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:41 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 201
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl?limit=2&offset=0
|
||||
response:
|
||||
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=2","previous":null,"results":[{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"},{"saleor_api_url":"https://msltedyydo.saleor.cloud/graphql","token":"etqfdiojfvufyfpylknharwbkkhcio","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fzglnnohvi","domain":"msltedyydo.saleor.cloud"}]}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '779'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:41 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl?limit=2&offset=2
|
||||
response:
|
||||
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=4","previous":"http://apl.example.com/api/v1/apl?limit=2","results":[{"saleor_api_url":"https://rodnbaorcb.saleor.cloud/graphql","token":"nyqokxizwonkuqqexhktbvgussupaz","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"lcedtozpeo","domain":"rodnbaorcb.saleor.cloud"},{"saleor_api_url":"https://vjoxywqxno.saleor.cloud/graphql","token":"uagntnmzugcynzcblilonyguxjowjq","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"vxrgitlduz","domain":"vjoxywqxno.saleor.cloud"}]}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '839'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:42 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl?limit=2&offset=4
|
||||
response:
|
||||
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=6","previous":"http://apl.example.com/api/v1/apl?limit=2&offset=2","results":[{"saleor_api_url":"https://txjaskukcx.saleor.cloud/graphql","token":"rzbhsxcngssozsamgksgqzsyykbvws","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"mtljtfhfjd","domain":"txjaskukcx.saleor.cloud"},{"saleor_api_url":"https://wptoxotfln.saleor.cloud/graphql","token":"bfuugsnzlrmbtbfjhbsfduslfjbmty","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"krqomgdnue","domain":"wptoxotfln.saleor.cloud"}]}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '848'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:42 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl?limit=2&offset=6
|
||||
response:
|
||||
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=8","previous":"http://apl.example.com/api/v1/apl?limit=2&offset=4","results":[{"saleor_api_url":"https://nxujxjrudl.saleor.cloud/graphql","token":"vmwhdquypdsmsgjlcgtrqacmzbaunk","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"qtpthrjavf","domain":"nxujxjrudl.saleor.cloud"},{"saleor_api_url":"https://vyyfdwmcav.saleor.cloud/graphql","token":"wmsookfbmuopwwlvceiifcoaeicqje","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"nxvftnbrfb","domain":"vyyfdwmcav.saleor.cloud"}]}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '848'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:42 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl?limit=2&offset=8
|
||||
response:
|
||||
content: '{"count":10,"next":null,"previous":"http://apl.example.com/api/v1/apl?limit=2&offset=6","results":[{"saleor_api_url":"https://ejwnealqvw.saleor.cloud/graphql","token":"teovwyfkqvpebpsmfpwigpqlusyrme","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"dpjoofueam","domain":"ejwnealqvw.saleor.cloud"},{"saleor_api_url":"https://vxcdclzonj.saleor.cloud/graphql","token":"vxhxemgqtpvvmzgcrxxetsrecvmocg","jwks":"{\"keys\":
|
||||
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
|
||||
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fxeyumrwrg","domain":"vxcdclzonj.saleor.cloud"}]}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '779'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 17:07:43 GMT
|
||||
allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 200
|
||||
version: 1
|
|
@ -0,0 +1,44 @@
|
|||
interactions:
|
||||
- request:
|
||||
body: ''
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
authorization:
|
||||
- Bearer access.token
|
||||
connection:
|
||||
- keep-alive
|
||||
host:
|
||||
- apl.example.com
|
||||
user-agent:
|
||||
- python-httpx/0.23.3
|
||||
method: GET
|
||||
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9ub24uZXhpc3RpbmcuZG9tYWlu
|
||||
response:
|
||||
content: '{"detail":"Not found."}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '23'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Sun, 19 Feb 2023 16:59:27 GMT
|
||||
allow:
|
||||
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
referrer-policy:
|
||||
- same-origin
|
||||
server:
|
||||
- uvicorn
|
||||
vary:
|
||||
- Origin
|
||||
x-content-type-options:
|
||||
- nosniff
|
||||
x-frame-options:
|
||||
- DENY
|
||||
http_version: HTTP/1.1
|
||||
status_code: 404
|
||||
version: 1
|
72
apps/monitoring/backend/monitoring/apl/tests/conftest.py
Normal file
72
apps/monitoring/backend/monitoring/apl/tests/conftest.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
import random
|
||||
import string
|
||||
|
||||
import pytest
|
||||
|
||||
from ..common import AplEntity
|
||||
|
||||
_SEED = 10
|
||||
|
||||
|
||||
def _random_str(size: int, uppercase=False) -> str:
|
||||
letters = string.ascii_uppercase if uppercase else string.ascii_lowercase
|
||||
return "".join(random.choice(letters) for _ in range(size))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio", {"use_uvloop": True}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vcr_config():
|
||||
return {
|
||||
"match_on": [
|
||||
"method",
|
||||
"scheme",
|
||||
"host",
|
||||
"port",
|
||||
"path",
|
||||
"query",
|
||||
"headers",
|
||||
"body",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
jwks_template = """
|
||||
{{
|
||||
"keys": [
|
||||
{{
|
||||
"kty": "RSA",
|
||||
"key_ops": ["verify"],
|
||||
"n": "{key_n}",
|
||||
"e": "AQAB",
|
||||
"use": "sig",
|
||||
"kid": "1"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def entities_factory():
|
||||
def factory(size=10):
|
||||
random.seed(_SEED)
|
||||
return [
|
||||
AplEntity(
|
||||
saleor_api_url=f"https://{_random_str(10)}.saleor.cloud/graphql",
|
||||
app_id=_random_str(10),
|
||||
app_token=_random_str(30),
|
||||
jwks=jwks_template.format(key_n=_random_str(30)),
|
||||
)
|
||||
for _ in range(size)
|
||||
]
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def entity(entities_factory) -> AplEntity:
|
||||
return entities_factory()[0]
|
134
apps/monitoring/backend/monitoring/apl/tests/test_apl_client.py
Normal file
134
apps/monitoring/backend/monitoring/apl/tests/test_apl_client.py
Normal file
|
@ -0,0 +1,134 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ..backends import FileAplBackend, MemAplBackend, RestAplBackend
|
||||
from ..common import AplKeyError, NotConfiguredError
|
||||
from ..wrapper import AplClient
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mem_apl_client():
|
||||
client = AplClient()
|
||||
client.setup("mem://")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_apl_client():
|
||||
path_string = ".test_fileApl.json"
|
||||
apl_file = Path(path_string)
|
||||
client = AplClient()
|
||||
client.setup(f"file://{path_string}")
|
||||
yield client
|
||||
if apl_file.exists():
|
||||
apl_file.unlink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rest_apl_client():
|
||||
client = AplClient()
|
||||
client.setup("https://access.token@apl.example.com")
|
||||
return client
|
||||
|
||||
|
||||
apl_clients = ["mem_apl_client", "file_apl_client", "rest_apl_client"]
|
||||
|
||||
|
||||
def test_parse_settings_url_mem_backend():
|
||||
backend = AplClient.parse_settings_url("mem://")
|
||||
assert isinstance(backend, MemAplBackend)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,path",
|
||||
[
|
||||
("file://.fileApl.json", ".fileApl.json"),
|
||||
("file://path/to/fileApl.json", "path/to/fileApl.json"),
|
||||
],
|
||||
)
|
||||
def test_parse_settings_url_file_backend(url, path):
|
||||
backend = AplClient.parse_settings_url(url)
|
||||
assert isinstance(backend, FileAplBackend)
|
||||
assert backend.path == Path(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,apl_url,token",
|
||||
[
|
||||
(
|
||||
"https://86b0bb44ee7e488d9cc3949b78b0a3ac.6dnRhRlMhhtjHXwBh3f3lJkmz4opGX7EInHKvxlMyPq6T5Y7@apl.example.com",
|
||||
"https://apl.example.com",
|
||||
"86b0bb44ee7e488d9cc3949b78b0a3ac.6dnRhRlMhhtjHXwBh3f3lJkmz4opGX7EInHKvxlMyPq6T5Y7",
|
||||
),
|
||||
(
|
||||
"http://access.token@localhost:8000",
|
||||
"http://localhost:8000",
|
||||
"access.token",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_settings_url_rest_backend(url, apl_url, token):
|
||||
backend = AplClient.parse_settings_url(url)
|
||||
assert isinstance(backend, RestAplBackend)
|
||||
assert backend.apl_url == apl_url
|
||||
assert backend.token == token
|
||||
|
||||
|
||||
def test_parse_settings_url_not_supported_backend():
|
||||
with pytest.raises(NotImplementedError):
|
||||
AplClient.parse_settings_url("new-schema://path")
|
||||
|
||||
|
||||
async def test_apl_client_no_setup():
|
||||
client = AplClient()
|
||||
with pytest.raises(NotConfiguredError):
|
||||
await client.get("shop.saleor.cloud")
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@pytest.mark.parametrize("client_fixture", apl_clients)
|
||||
async def test_apl_get(client_fixture, entity, request):
|
||||
apl_client: AplClient = request.getfixturevalue(client_fixture)
|
||||
await apl_client.set(entity.saleor_api_url, entity)
|
||||
entity_from_client = await apl_client.get(entity.saleor_api_url)
|
||||
assert entity_from_client == entity.copy()
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@pytest.mark.parametrize("client_fixture", apl_clients)
|
||||
async def test_apl_key_error(client_fixture, request):
|
||||
apl_client: AplClient = request.getfixturevalue(client_fixture)
|
||||
with pytest.raises(AplKeyError):
|
||||
await apl_client.get("https://non.existing.domain")
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@pytest.mark.parametrize("client_fixture", apl_clients)
|
||||
async def test_apl_delete(client_fixture, entity, request):
|
||||
apl_client: AplClient = request.getfixturevalue(client_fixture)
|
||||
await apl_client.set(entity.saleor_api_url, entity)
|
||||
await apl_client.delete(entity.saleor_api_url)
|
||||
with pytest.raises(AplKeyError):
|
||||
await apl_client.get(entity.saleor_api_url)
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@pytest.mark.parametrize("client_fixture", apl_clients)
|
||||
async def test_apl_delete_non_existing(client_fixture, request):
|
||||
apl_client: AplClient = request.getfixturevalue(client_fixture)
|
||||
with pytest.raises(AplKeyError):
|
||||
await apl_client.delete("https://non.existing.domain")
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@pytest.mark.parametrize("client_fixture", apl_clients)
|
||||
async def test_apl_get_all(client_fixture, entities_factory, request):
|
||||
apl_client: AplClient = request.getfixturevalue(client_fixture)
|
||||
entities = entities_factory()
|
||||
for entity in entities:
|
||||
await apl_client.set(entity.saleor_api_url, entity)
|
||||
apl_entities = [entity async for entity in apl_client.get_all(page_size=2)]
|
||||
assert apl_entities == [(entity.saleor_api_url, entity) for entity in entities]
|
49
apps/monitoring/backend/monitoring/apl/wrapper.py
Normal file
49
apps/monitoring/backend/monitoring/apl/wrapper.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .backends import AplBackend, FileAplBackend, MemAplBackend, RestAplBackend
|
||||
from .common import AplEntity, NotConfiguredError
|
||||
|
||||
|
||||
class AplClient:
|
||||
def __init__(self):
|
||||
self._backend: AplBackend | None = None
|
||||
|
||||
@staticmethod
|
||||
def parse_settings_url(url: str) -> AplBackend:
|
||||
parts = urlparse(url)
|
||||
if parts.scheme == "file":
|
||||
path = Path(url.removeprefix("file://"))
|
||||
return FileAplBackend(path)
|
||||
elif parts.scheme == "mem":
|
||||
return MemAplBackend()
|
||||
elif parts.scheme in ["http", "https"]:
|
||||
token = parts.username or ""
|
||||
port = "" if parts.port is None else f":{parts.port}"
|
||||
apl_url = f"{parts.scheme}://{parts.hostname}{port}{parts.path}"
|
||||
return RestAplBackend(apl_url=apl_url, token=token)
|
||||
raise NotImplementedError()
|
||||
|
||||
def setup(self, url: str):
|
||||
self._backend = self.parse_settings_url(url)
|
||||
|
||||
@property
|
||||
def backend(self) -> AplBackend:
|
||||
if self._backend is None:
|
||||
raise NotConfiguredError(
|
||||
"Run `apl_client.setup(...)` before using apl_client"
|
||||
)
|
||||
return self._backend
|
||||
|
||||
async def get(self, key: str) -> AplEntity:
|
||||
return await self.backend.get(key)
|
||||
|
||||
async def set(self, key: str, value: AplEntity):
|
||||
return await self.backend.set(key, value)
|
||||
|
||||
async def delete(self, key: str):
|
||||
return await self.backend.delete(key)
|
||||
|
||||
async def get_all(self, page_size=100):
|
||||
async for item in self.backend.get_all(page_size=page_size):
|
||||
yield item
|
131
apps/monitoring/backend/monitoring/app.py
Normal file
131
apps/monitoring/backend/monitoring/app.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
import logging
|
||||
|
||||
from fastapi import Depends, FastAPI, Request, status
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from .api import graphql_app
|
||||
from .apl import AplEntity, apl_client
|
||||
from .deps import (
|
||||
ApiDependencies,
|
||||
WebhookDependencies,
|
||||
saleor_api_url_header,
|
||||
verify_saleor_api_url,
|
||||
)
|
||||
from .integrations.datadog import DataDogClientError
|
||||
from .logs import configure_logging
|
||||
from .payload import OBSERVABILITY_EVENTS
|
||||
from .saleor.client import GraphQLError, SaleorClient
|
||||
from .saleor.common import InstallData, LazyUrl
|
||||
from .settings import manifest, settings
|
||||
from .utils import get_base_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
configure_logging(settings.debug)
|
||||
|
||||
apl_client.setup(settings.apl_url)
|
||||
|
||||
|
||||
app = FastAPI(openapi_url="/api/openapi.json")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["OPTIONS", "GET", "POST", "DELETE"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/manifest", status_code=status.HTTP_200_OK, tags=["installation-handshake"])
|
||||
async def show_manifest(request: Request):
|
||||
manifest_copy = manifest.copy()
|
||||
for name, field in manifest_copy:
|
||||
if isinstance(field, LazyUrl):
|
||||
setattr(manifest_copy, name, field(request))
|
||||
for extension in manifest_copy.extensions:
|
||||
if isinstance(extension.url, LazyUrl):
|
||||
extension.url = extension.url(request)
|
||||
for webhook in manifest_copy.webhooks:
|
||||
if isinstance(webhook.target_url, LazyUrl):
|
||||
webhook.target_url = webhook.target_url(request)
|
||||
return manifest_copy
|
||||
|
||||
|
||||
@app.post(
|
||||
"/install",
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["installation-handshake"],
|
||||
name="install",
|
||||
)
|
||||
async def install(
|
||||
data: InstallData,
|
||||
request: Request,
|
||||
api_url=Depends(saleor_api_url_header),
|
||||
_valid=Depends(verify_saleor_api_url),
|
||||
):
|
||||
client = SaleorClient(api_url, "test", data.auth_token)
|
||||
try:
|
||||
app_info = await client.app_info()
|
||||
target_url = get_base_url(request).replace(path="webhooks")
|
||||
await client.create_webhook(
|
||||
target_url=str(target_url),
|
||||
events=["OBSERVABILITY"],
|
||||
name="OBSERVABILITY",
|
||||
)
|
||||
jwks = await client.get_jwks()
|
||||
entity = AplEntity(
|
||||
saleor_api_url=api_url,
|
||||
app_id=app_info.id,
|
||||
app_token=data.auth_token,
|
||||
jwks=jwks,
|
||||
)
|
||||
await apl_client.set(api_url, entity)
|
||||
except GraphQLError:
|
||||
return {"error": {"message": "Wrong app token"}}
|
||||
return {}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/webhooks",
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["webhooks"],
|
||||
name="webhooks",
|
||||
)
|
||||
async def handle_observability_events(
|
||||
payloads: list[OBSERVABILITY_EVENTS],
|
||||
commons: WebhookDependencies = Depends(),
|
||||
):
|
||||
metadata = await commons.manager.get_metadata()
|
||||
if not metadata.datadog or not metadata.datadog.active:
|
||||
logger.warning(
|
||||
"DataDog integration inactive or not configured. Dropping %s events",
|
||||
len(payloads),
|
||||
)
|
||||
return
|
||||
credentials = metadata.datadog.credentials
|
||||
try:
|
||||
logger.info("Sending %s events to DataDog", len(payloads))
|
||||
await commons.datadog_client.send_logs(
|
||||
commons.saleor_data.saleor_api_url, credentials, payloads
|
||||
)
|
||||
except DataDogClientError:
|
||||
logger.warning("Sending logs to DataDog failed. Deactivating integration")
|
||||
metadata.datadog.active = False
|
||||
metadata.datadog.error = "Wrong credentials. Integration deactivated"
|
||||
await commons.manager.save_private_metadata(metadata)
|
||||
|
||||
|
||||
@app.get("/health", status_code=status.HTTP_200_OK, tags=["health"], name="health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/graphql")
|
||||
async def graphiql(request: Request):
|
||||
return await graphql_app.render_playground(request)
|
||||
|
||||
|
||||
@app.post("/graphql")
|
||||
async def graphql(request: Request, commons: ApiDependencies = Depends()):
|
||||
request.state.api_context = commons
|
||||
return await graphql_app.graphql_http_server(request)
|
143
apps/monitoring/backend/monitoring/deps.py
Normal file
143
apps/monitoring/backend/monitoring/deps.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
import logging
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from jwt.api_jwk import PyJWKSet
|
||||
|
||||
from .apl import AplEntity, AplError, AplKeyError, apl_client
|
||||
from .integrations.datadog import DataDogApiClient, DataDogClient, TestDataDogClient
|
||||
from .saleor import (
|
||||
SALEOR_API_URL_HEADER,
|
||||
SALEOR_SIGNATURE_HEADER,
|
||||
SALEOR_TOKEN_HEADER,
|
||||
SaleorToken,
|
||||
)
|
||||
from .saleor.crypto import decode_webhook_payload
|
||||
from .saleor.manager import SaleorManager
|
||||
from .schema import Metadata
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def saleor_api_url_header(
|
||||
api_url: str | None = Header(None, alias=SALEOR_API_URL_HEADER)
|
||||
) -> str:
|
||||
if not api_url:
|
||||
msg = f"Missing {SALEOR_API_URL_HEADER.upper()} header."
|
||||
logger.warning(msg)
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return api_url
|
||||
|
||||
|
||||
async def verify_saleor_api_url(api_url: str = Depends(saleor_api_url_header)):
|
||||
# TODO implement
|
||||
if api_url:
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provided url is invalid or environment {api_url} not allowed.",
|
||||
)
|
||||
|
||||
|
||||
async def saleor_token_header(
|
||||
token: str | None = Header(None, alias=SALEOR_TOKEN_HEADER)
|
||||
) -> str:
|
||||
if not token:
|
||||
msg = f"Missing {SALEOR_TOKEN_HEADER.upper()} header."
|
||||
logger.warning(msg)
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return token
|
||||
|
||||
|
||||
async def apl_entity(
|
||||
api_url: str = Depends(saleor_api_url_header),
|
||||
_verify=Depends(verify_saleor_api_url),
|
||||
) -> AplEntity:
|
||||
try:
|
||||
return await apl_client.get(api_url)
|
||||
except AplKeyError:
|
||||
msg = f"App needs to be installed before use. First, install the app in Saleor at {api_url}."
|
||||
logger.warning(msg)
|
||||
raise HTTPException(status_code=400, detail=msg) # noqa: 904
|
||||
except AplError as error:
|
||||
logging.error("APL error %r", error)
|
||||
raise HTTPException( # noqa: 904
|
||||
status_code=500, detail="App error. Try again later."
|
||||
)
|
||||
|
||||
|
||||
async def saleor_token(
|
||||
token_str: str = Depends(saleor_token_header),
|
||||
saleor_data: AplEntity = Depends(apl_entity),
|
||||
) -> SaleorToken:
|
||||
token = SaleorToken(token_str, saleor_data.jwks)
|
||||
# TODO handle jwks reload
|
||||
return token
|
||||
|
||||
|
||||
async def verify_is_stuff_user(token: SaleorToken = Depends(saleor_token)) -> bool:
|
||||
if not token.is_staff_user():
|
||||
HTTPException(
|
||||
status_code=400, detail="Only staff user can perform this operation."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_saleor_signature(
|
||||
saleor_signature: str | None = Header(None, alias=SALEOR_SIGNATURE_HEADER)
|
||||
):
|
||||
return saleor_signature
|
||||
|
||||
|
||||
async def verify_webhook_signature(
|
||||
request: Request,
|
||||
jws: str = Depends(get_saleor_signature),
|
||||
saleor_data: AplEntity = Depends(apl_entity),
|
||||
):
|
||||
saleor_jwks = PyJWKSet.from_dict(saleor_data.jwks)
|
||||
return decode_webhook_payload(
|
||||
jws=jws, jwks=saleor_jwks, webhook_payload=await request.body()
|
||||
)
|
||||
|
||||
|
||||
async def saleor_manager(saleor_data: AplEntity = Depends(apl_entity)):
|
||||
return SaleorManager[Metadata](
|
||||
saleor_data.saleor_api_url,
|
||||
saleor_data.app_id,
|
||||
saleor_data.app_token,
|
||||
Metadata,
|
||||
)
|
||||
|
||||
|
||||
async def datadog_client() -> DataDogClient:
|
||||
if settings.mock_datadog_client:
|
||||
return TestDataDogClient()
|
||||
return DataDogApiClient()
|
||||
|
||||
|
||||
class ApiDependencies:
|
||||
def __init__(
|
||||
self,
|
||||
token: SaleorToken = Depends(saleor_token),
|
||||
saleor_data: AplEntity = Depends(apl_entity),
|
||||
manager: SaleorManager[Metadata] = Depends(saleor_manager),
|
||||
datadog_client: DataDogClient = Depends(datadog_client),
|
||||
_verify_is_stuff_user=Depends(verify_is_stuff_user),
|
||||
):
|
||||
self.saleor_data = saleor_data
|
||||
self.token = token
|
||||
self.manager = manager
|
||||
self.datadog_client = datadog_client
|
||||
|
||||
|
||||
class WebhookDependencies:
|
||||
def __init__(
|
||||
self,
|
||||
saleor_data: AplEntity = Depends(apl_entity),
|
||||
manager: SaleorManager[Metadata] = Depends(saleor_manager),
|
||||
datadog_client: DataDogClient = Depends(datadog_client),
|
||||
_verify_webhook_signature=Depends(verify_webhook_signature),
|
||||
):
|
||||
self.saleor_data = saleor_data
|
||||
self.manager = manager
|
||||
self.datadog_client = datadog_client
|
145
apps/monitoring/backend/monitoring/integrations/datadog.py
Normal file
145
apps/monitoring/backend/monitoring/integrations/datadog.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
import logging
|
||||
from typing import Any, Awaitable, cast
|
||||
|
||||
from datadog_api_client.exceptions import ApiException, ForbiddenException
|
||||
from datadog_api_client.v1.api.authentication_api import AuthenticationApi
|
||||
from datadog_api_client.v2 import AsyncApiClient, Configuration
|
||||
from datadog_api_client.v2.api.logs_api import LogsApi
|
||||
from datadog_api_client.v2.model.content_encoding import ContentEncoding
|
||||
from datadog_api_client.v2.model.http_log import HTTPLog
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..payload import OBSERVABILITY_EVENTS
|
||||
from .logs import generate_logs
|
||||
|
||||
LOGS_ENCODING = ContentEncoding("gzip")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOGS_ERRORS = {
|
||||
400: "Bad request (likely an issue in the payload formatting)",
|
||||
401: "Unauthorized (likely a missing API Key)",
|
||||
403: "Permission issue (likely using an invalid API Key)",
|
||||
408: "Request Timeout, request should be retried after some time",
|
||||
413: "Payload too large (batch is above 5MB uncompressed)",
|
||||
429: "Too Many Requests, request should be retried after some time",
|
||||
500: "Internal Server Error, the server encountered an unexpected condition that "
|
||||
"prevented it from fulfilling the request, request should be retried after some time",
|
||||
503: "Service Unavailable, the server is not ready to handle the request "
|
||||
"probably because it is overloaded, request should be retried after some time",
|
||||
}
|
||||
|
||||
|
||||
class DataDogClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DataDogCredsRejectedError(DataDogClientError):
|
||||
"""DataDog credentials were rejected"""
|
||||
|
||||
|
||||
class DatadogCredentials(BaseModel):
|
||||
site: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class DataDogClient:
|
||||
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def send_logs(
|
||||
self,
|
||||
saleor_api_url: str,
|
||||
credentials: DatadogCredentials,
|
||||
logs: list[OBSERVABILITY_EVENTS],
|
||||
):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class TestDataDogClient(DataDogClient):
|
||||
_good_creds = DatadogCredentials(
|
||||
site="US1", api_key="156e22d50c4e8b6816e1fd4794d3fd8c"
|
||||
)
|
||||
_failing_creds = DatadogCredentials(
|
||||
site="EU1", api_key="156e22d50c4e8b6816e1fd4794d3fd8c"
|
||||
)
|
||||
|
||||
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
|
||||
return credentials == self._good_creds or credentials == self._failing_creds
|
||||
|
||||
async def send_logs(
|
||||
self,
|
||||
saleor_api_url: str,
|
||||
credentials: DatadogCredentials,
|
||||
logs: list[OBSERVABILITY_EVENTS],
|
||||
):
|
||||
if credentials == self._good_creds:
|
||||
return
|
||||
raise DataDogCredsRejectedError()
|
||||
|
||||
|
||||
class DataDogApiClient(DataDogClient):
|
||||
_site_map = {
|
||||
"US1": "datadoghq.com",
|
||||
"US3": "us3.datadoghq.com",
|
||||
"US5": "us5.datadoghq.com",
|
||||
"EU1": "datadoghq.eu",
|
||||
"US1_FED": "ddog-gov.com",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_config(cls, credentials: DatadogCredentials) -> Configuration:
|
||||
configuration = Configuration()
|
||||
configuration.api_key["apiKeyAuth"] = credentials.api_key
|
||||
configuration.server_variables["site"] = cls._site_map[credentials.site]
|
||||
return configuration
|
||||
|
||||
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
|
||||
config = self._get_config(credentials)
|
||||
async with AsyncApiClient(config) as api_client:
|
||||
api_instance = AuthenticationApi(api_client)
|
||||
try:
|
||||
response = await cast(Awaitable[Any], api_instance.validate())
|
||||
return response.valid
|
||||
except ForbiddenException:
|
||||
pass
|
||||
except ApiException as exp:
|
||||
logger.error(
|
||||
"DataDog validate_credentials ApiException[%s]",
|
||||
exp.status,
|
||||
extra={
|
||||
"status_code": exp.status,
|
||||
"response_headers": dict(exp.headers) if exp.headers else None,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_logs(
|
||||
self,
|
||||
saleor_api_url: str,
|
||||
credentials: DatadogCredentials,
|
||||
logs: list[OBSERVABILITY_EVENTS],
|
||||
):
|
||||
config = self._get_config(credentials)
|
||||
logs = generate_logs(logs, saleor_api_url)
|
||||
async with AsyncApiClient(config) as api_client:
|
||||
api_instance = LogsApi(api_client)
|
||||
try:
|
||||
await cast(
|
||||
Awaitable[Any],
|
||||
api_instance.submit_log(
|
||||
body=HTTPLog(logs), content_encoding=LOGS_ENCODING
|
||||
),
|
||||
)
|
||||
except ApiException as exp:
|
||||
error_msg = LOGS_ERRORS.get(exp.status)
|
||||
logger.error(
|
||||
"DataDog send_logs ApiException[%s]",
|
||||
error_msg,
|
||||
extra={
|
||||
"status_code": exp.status,
|
||||
"response_headers": dict(exp.headers) if exp.headers else None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error("DataDog send_logs Unknown error")
|
291
apps/monitoring/backend/monitoring/integrations/logs.py
Normal file
291
apps/monitoring/backend/monitoring/integrations/logs.py
Normal file
|
@ -0,0 +1,291 @@
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from datadog_api_client.v2.model.http_log_item import HTTPLogItem
|
||||
from pydantic import BaseModel
|
||||
from ua_parser import user_agent_parser # type: ignore
|
||||
|
||||
from ..payload import (
|
||||
OBSERVABILITY_EVENTS,
|
||||
ApiCallPayload,
|
||||
EventDeliveryAttemptPayload,
|
||||
JsonTruncText,
|
||||
)
|
||||
from ..utils import parse_headers
|
||||
|
||||
|
||||
class EventDeliveryStatus(str, Enum):
|
||||
pending = "pending"
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class LogLevel(str, Enum):
|
||||
info = "INFO"
|
||||
warn = "WARN"
|
||||
error = "ERROR"
|
||||
|
||||
|
||||
log_level_map = {
|
||||
EventDeliveryStatus.pending.value: LogLevel.warn,
|
||||
EventDeliveryStatus.success.value: LogLevel.info,
|
||||
EventDeliveryStatus.failed.value: LogLevel.error,
|
||||
}
|
||||
|
||||
|
||||
class DataDogLogUseragentDetails(BaseModel):
|
||||
os: dict[str, str | None]
|
||||
browser: dict[str, str | None]
|
||||
device: dict[str, str | None]
|
||||
|
||||
|
||||
class DataDogLogNetwork(BaseModel):
|
||||
bytes_read: int | None
|
||||
bytes_written: int | None
|
||||
|
||||
|
||||
class DataDogLogEvent(BaseModel):
|
||||
name: str
|
||||
outcome: str | None
|
||||
|
||||
|
||||
class DataDogLogHttp(BaseModel):
|
||||
url: str
|
||||
request_id: str | None
|
||||
referer: str | None
|
||||
status_code: str | None
|
||||
method: str | None
|
||||
useragent: str | None
|
||||
useragent_details: DataDogLogUseragentDetails | None
|
||||
|
||||
|
||||
class DataDogLog(BaseModel):
|
||||
ddsource: str = "saleor"
|
||||
service: str
|
||||
timestamp: float
|
||||
level: LogLevel = LogLevel.info
|
||||
ddtags: str | None
|
||||
http: DataDogLogHttp
|
||||
network: DataDogLogNetwork
|
||||
evt: DataDogLogEvent
|
||||
duration: int | None
|
||||
|
||||
|
||||
class GraphQLOperation(BaseModel):
|
||||
name: str | None
|
||||
operation_type: str
|
||||
query: str | None
|
||||
result: str | None
|
||||
result_invalid: bool
|
||||
truncated: bool
|
||||
|
||||
|
||||
class EventRequest(BaseModel):
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
headers: dict[str, str] = {}
|
||||
body: JsonTruncText | None
|
||||
|
||||
|
||||
class App(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ApiCall(BaseModel):
|
||||
request_headers: dict[str, str] = {}
|
||||
response_headers: dict[str, str] = {}
|
||||
gql_operations: list[GraphQLOperation]
|
||||
app: App | None
|
||||
|
||||
|
||||
class ApiCallLog(DataDogLog):
|
||||
saleor: ApiCall
|
||||
|
||||
|
||||
class Webhook(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
target_url: str
|
||||
subscription_query: JsonTruncText | None
|
||||
|
||||
|
||||
class EventDeliveryPayload(BaseModel):
|
||||
content_length: int
|
||||
body: JsonTruncText
|
||||
|
||||
|
||||
class EventDelivery(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
event_type: str
|
||||
event_sync: bool
|
||||
payload: EventDeliveryPayload
|
||||
|
||||
|
||||
class EventDeliveryAttempt(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
next_retry: datetime | None
|
||||
request_headers: dict[str, str] = {}
|
||||
response_headers: dict[str, str] = {}
|
||||
response_body: JsonTruncText | None
|
||||
|
||||
|
||||
class EventDeliveryAttemptData(BaseModel):
|
||||
event_delivery_attempt: EventDeliveryAttempt
|
||||
event_delivery: EventDelivery
|
||||
webhook: Webhook
|
||||
app: App
|
||||
|
||||
|
||||
class EventDeliveryAttemptLog(DataDogLog):
|
||||
saleor: EventDeliveryAttemptData
|
||||
|
||||
|
||||
def http_status_ok(status_code: int | None):
|
||||
if status_code is None or status_code >= 400:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_useragent(useragent: str | None) -> DataDogLogUseragentDetails | None:
|
||||
if not useragent:
|
||||
return None
|
||||
parsed = user_agent_parser.Parse(useragent)
|
||||
return DataDogLogUseragentDetails(
|
||||
os=parsed["os"],
|
||||
browser=parsed["user_agent"],
|
||||
device=parsed["device"],
|
||||
)
|
||||
|
||||
|
||||
def convert_duration(duration: float | None) -> float | None:
|
||||
return duration * 1_000_000_000 if duration else None
|
||||
|
||||
|
||||
def generate_api_call_log(payload: ApiCallPayload, saleor_domain: str) -> HTTPLogItem:
|
||||
request_headers = parse_headers(payload.request.headers)
|
||||
response_headers = parse_headers(payload.response.headers)
|
||||
gql_operations: list[GraphQLOperation] = []
|
||||
for op in payload.gql_operations:
|
||||
gql_operations.append(
|
||||
GraphQLOperation(
|
||||
name=op.name.text if op.name else None,
|
||||
operation_type=op.operation_type,
|
||||
query=op.query.text if op.query else None,
|
||||
result=op.result.text if op.result else None,
|
||||
result_invalid=op.result_invalid,
|
||||
truncated=any(
|
||||
[
|
||||
op.name.truncated if op.name else False,
|
||||
op.query.truncated if op.query else False,
|
||||
op.result.truncated if op.result else False,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
level = (
|
||||
LogLevel.info
|
||||
if http_status_ok(payload.response.status_code)
|
||||
else LogLevel.error
|
||||
)
|
||||
message = ApiCallLog(
|
||||
service=saleor_domain,
|
||||
timestamp=payload.request.time.timestamp() * 1000,
|
||||
level=level,
|
||||
http=DataDogLogHttp(
|
||||
url=payload.request.url,
|
||||
request_id=payload.request.id,
|
||||
referer=request_headers.get("Referer"),
|
||||
status_code=str(payload.response.status_code),
|
||||
method=payload.request.method,
|
||||
useragent=request_headers.get("User-Agent"),
|
||||
useragent_details=parse_useragent(request_headers.get("User-Agent")),
|
||||
),
|
||||
network=DataDogLogNetwork(
|
||||
bytes_read=payload.request.content_length,
|
||||
bytes_written=payload.response.content_length,
|
||||
),
|
||||
evt=DataDogLogEvent(
|
||||
name=payload.event_type,
|
||||
outcome="success" if level == LogLevel.info else "failure",
|
||||
),
|
||||
saleor=ApiCall(
|
||||
gql_operations=gql_operations,
|
||||
app=App(id=payload.app.id, name=payload.app.name) if payload.app else None,
|
||||
request_headers=dict(request_headers),
|
||||
response_headers=dict(response_headers),
|
||||
),
|
||||
)
|
||||
return HTTPLogItem(message=message.json())
|
||||
|
||||
|
||||
def generate_event_delivery_attempt_log(
|
||||
payload: EventDeliveryAttemptPayload, saleor_domain: str
|
||||
) -> HTTPLogItem:
|
||||
request_headers = parse_headers(payload.request.headers)
|
||||
response_headers = parse_headers(payload.response.headers)
|
||||
message = EventDeliveryAttemptLog(
|
||||
service=saleor_domain,
|
||||
timestamp=payload.time.timestamp() * 1000,
|
||||
level=log_level_map.get(payload.event_delivery.status, LogLevel.error),
|
||||
duration=convert_duration(payload.duration),
|
||||
http=DataDogLogHttp(
|
||||
url=payload.webhook.target_url,
|
||||
request_id=payload.id,
|
||||
status_code=str(payload.response.status_code),
|
||||
),
|
||||
network=DataDogLogNetwork(
|
||||
bytes_read=payload.response.content_length,
|
||||
bytes_written=payload.event_delivery.payload.content_length,
|
||||
),
|
||||
evt=DataDogLogEvent(
|
||||
name=payload.event_type,
|
||||
outcome=payload.status,
|
||||
),
|
||||
saleor=EventDeliveryAttemptData(
|
||||
event_delivery_attempt=EventDeliveryAttempt(
|
||||
id=payload.id,
|
||||
status=payload.status,
|
||||
next_retry=payload.next_retry,
|
||||
request_headers=dict(request_headers),
|
||||
response_headers=dict(response_headers),
|
||||
response_body=payload.response.body,
|
||||
),
|
||||
app=App(id=payload.app.id, name=payload.app.name),
|
||||
event_delivery=EventDelivery(
|
||||
id=payload.event_delivery.id,
|
||||
status=payload.event_delivery.status,
|
||||
event_type=payload.event_delivery.event_type,
|
||||
event_sync=payload.event_delivery.event_sync,
|
||||
payload=EventDeliveryPayload(
|
||||
body=payload.event_delivery.payload.body,
|
||||
content_length=payload.event_delivery.payload.content_length,
|
||||
),
|
||||
),
|
||||
webhook=Webhook(
|
||||
id=payload.webhook.id,
|
||||
name=payload.webhook.name,
|
||||
target_url=payload.webhook.target_url,
|
||||
subscription_query=payload.webhook.subscription_query,
|
||||
),
|
||||
),
|
||||
)
|
||||
return HTTPLogItem(message=message.json())
|
||||
|
||||
|
||||
def generate_logs(
|
||||
payloads: list[OBSERVABILITY_EVENTS], saleor_domain: str
|
||||
) -> list[HTTPLogItem]:
|
||||
log_items: list[HTTPLogItem] = []
|
||||
for payload in payloads:
|
||||
if isinstance(payload, ApiCallPayload):
|
||||
log_items.append(generate_api_call_log(payload, saleor_domain))
|
||||
elif isinstance(payload, EventDeliveryAttemptPayload):
|
||||
log_items.append(
|
||||
generate_event_delivery_attempt_log(payload, saleor_domain)
|
||||
)
|
||||
return log_items
|
62
apps/monitoring/backend/monitoring/logs.py
Normal file
62
apps/monitoring/backend/monitoring/logs.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
import logging.config
|
||||
|
||||
|
||||
def configure_logging(debug=False):
|
||||
config = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["default"],
|
||||
},
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"datefmt": "%Y-%m-%dT%H:%M:%SZ",
|
||||
"format": (
|
||||
"%(asctime)s %(levelname)s %(lineno)s %(message)s %(name)s "
|
||||
+ "%(pathname)s %(process)d %(threadName)s"
|
||||
),
|
||||
},
|
||||
"verbose": {
|
||||
"format": (
|
||||
"%(levelname)s %(name)s %(message)s [PID:%(process)d:%(threadName)s]"
|
||||
)
|
||||
},
|
||||
"uvicorn": {
|
||||
"()": "uvicorn.logging.DefaultFormatter",
|
||||
"fmt": "%(levelprefix)s %(message)s [PID:%(process)d:%(threadName)s]",
|
||||
"use_colors": None,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose" if debug else "json",
|
||||
},
|
||||
"uvicorn": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "uvicorn" if debug else "json",
|
||||
},
|
||||
"null": {
|
||||
"class": "logging.NullHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {
|
||||
"propagate": False,
|
||||
"handlers": ["uvicorn"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"propagate": False,
|
||||
"handlers": ["null"],
|
||||
},
|
||||
"saleor_app_observability": {
|
||||
"level": "DEBUG" if debug else "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
logging.config.dictConfig(config)
|
93
apps/monitoring/backend/monitoring/payload.py
Normal file
93
apps/monitoring/backend/monitoring/payload.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from .utils import HttpHeaders, JsonBaseModel
|
||||
|
||||
|
||||
class App(JsonBaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ApiCallRequest(JsonBaseModel):
|
||||
id: str
|
||||
method: str
|
||||
url: str
|
||||
time: datetime
|
||||
headers: HttpHeaders
|
||||
content_length: int
|
||||
|
||||
|
||||
class ApiCallResponse(JsonBaseModel):
|
||||
headers: HttpHeaders
|
||||
status_code: int | None
|
||||
content_length: int
|
||||
|
||||
|
||||
class JsonTruncText(JsonBaseModel):
|
||||
text: str
|
||||
truncated: bool
|
||||
|
||||
|
||||
class GraphQLOperation(JsonBaseModel):
|
||||
name: JsonTruncText | None
|
||||
operation_type: str | None
|
||||
query: JsonTruncText | None
|
||||
result: JsonTruncText | None
|
||||
result_invalid: bool
|
||||
|
||||
|
||||
class ApiCallPayload(JsonBaseModel):
|
||||
event_type: Literal["api_call"]
|
||||
request: ApiCallRequest
|
||||
response: ApiCallResponse
|
||||
gql_operations: list[GraphQLOperation]
|
||||
app: App | None
|
||||
|
||||
|
||||
class EventDeliveryAttemptRequest(JsonBaseModel):
|
||||
headers: HttpHeaders
|
||||
|
||||
|
||||
class EventDeliveryAttemptResponse(JsonBaseModel):
|
||||
headers: HttpHeaders
|
||||
status_code: int | None
|
||||
content_length: int
|
||||
body: JsonTruncText
|
||||
|
||||
|
||||
class EventDeliveryPayload(JsonBaseModel):
|
||||
content_length: int
|
||||
body: JsonTruncText
|
||||
|
||||
|
||||
class EventDelivery(JsonBaseModel):
|
||||
id: str
|
||||
status: str
|
||||
event_type: str
|
||||
event_sync: bool
|
||||
payload: EventDeliveryPayload
|
||||
|
||||
|
||||
class Webhook(JsonBaseModel):
|
||||
id: str
|
||||
name: str
|
||||
target_url: str
|
||||
subscription_query: JsonTruncText | None
|
||||
|
||||
|
||||
class EventDeliveryAttemptPayload(JsonBaseModel):
|
||||
event_type: Literal["event_delivery_attempt"]
|
||||
id: str
|
||||
time: datetime
|
||||
duration: float | None
|
||||
status: str
|
||||
next_retry: datetime | None
|
||||
request: EventDeliveryAttemptRequest
|
||||
response: EventDeliveryAttemptResponse
|
||||
event_delivery: EventDelivery
|
||||
webhook: Webhook
|
||||
app: App
|
||||
|
||||
|
||||
OBSERVABILITY_EVENTS = ApiCallPayload | EventDeliveryAttemptPayload | dict[str, Any]
|
13
apps/monitoring/backend/monitoring/saleor/__init__.py
Normal file
13
apps/monitoring/backend/monitoring/saleor/__init__.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from .common import (
|
||||
SALEOR_API_URL_HEADER,
|
||||
SALEOR_SIGNATURE_HEADER,
|
||||
SALEOR_TOKEN_HEADER,
|
||||
SaleorToken,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SALEOR_TOKEN_HEADER",
|
||||
"SALEOR_API_URL_HEADER",
|
||||
"SALEOR_SIGNATURE_HEADER",
|
||||
"SaleorToken",
|
||||
]
|
103
apps/monitoring/backend/monitoring/saleor/client.py
Normal file
103
apps/monitoring/backend/monitoring/saleor/client.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
from typing import Any, Dict, Optional, Sequence
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
||||
from .graphql import (
|
||||
CREATE_WEBHOOK,
|
||||
DELETE_PRIVATE_METADATA,
|
||||
GET_APP_INFO,
|
||||
UPDATE_PRIVATE_METADATA,
|
||||
)
|
||||
from .utils import JsonBaseModel
|
||||
|
||||
|
||||
class WebhookInfo(JsonBaseModel):
|
||||
id: str
|
||||
target_url: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AppInfo(JsonBaseModel):
|
||||
id: str
|
||||
webhooks: list[WebhookInfo]
|
||||
private_metafields: dict[str, str]
|
||||
|
||||
|
||||
class GraphQLError(Exception):
|
||||
"""
|
||||
Raised on Saleor GraphQL errors
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
errors: Sequence[Dict[str, Any]],
|
||||
response_data: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.errors = errors
|
||||
self.response_data = response_data
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"GraphQLError: {', '.join([error['message'] for error in self.errors])}."
|
||||
)
|
||||
|
||||
|
||||
class SaleorClient:
|
||||
def __init__(self, saleor_api_url: str, user_agent, auth_token=None):
|
||||
self.saleor_api_url = saleor_api_url
|
||||
self.headers = {"User-Agent": user_agent}
|
||||
if auth_token:
|
||||
self.headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
async def get_jwks(self) -> dict[str, Any]:
|
||||
parts = urlparse(self.saleor_api_url)
|
||||
jwks_url = urlunparse(parts._replace(path="/.well-known/jwks.json"))
|
||||
async with httpx.AsyncClient() as client:
|
||||
res = await client.get(jwks_url)
|
||||
return res.json()
|
||||
|
||||
async def execute(self, query, variables=None):
|
||||
async with httpx.AsyncClient() as client:
|
||||
data = {"query": query, "variables": variables}
|
||||
res = await client.post(
|
||||
self.saleor_api_url,
|
||||
json=data,
|
||||
headers=self.headers,
|
||||
)
|
||||
res_data = res.json()
|
||||
if errors := res_data.get("errors"):
|
||||
raise GraphQLError(errors=errors, response_data=res_data.get("data"))
|
||||
return res_data["data"]
|
||||
|
||||
async def app_info(self) -> AppInfo:
|
||||
result = await self.execute(GET_APP_INFO)
|
||||
return AppInfo.parse_obj(result["app"])
|
||||
|
||||
async def update_private_metadata(self, app_id: str, metadata: dict[str, str]):
|
||||
metadata_input = [{"key": key, "value": val} for key, val in metadata.items()]
|
||||
await self.execute(
|
||||
UPDATE_PRIVATE_METADATA,
|
||||
variables={"appId": app_id, "metadata": metadata_input},
|
||||
)
|
||||
|
||||
async def delete_private_metadata(self, app_id: str, keys: set[str]):
|
||||
await self.execute(
|
||||
DELETE_PRIVATE_METADATA, variables={"appId": app_id, "keys": list(keys)}
|
||||
)
|
||||
|
||||
async def create_webhook(
|
||||
self,
|
||||
target_url: str,
|
||||
events: list[str],
|
||||
name: str,
|
||||
):
|
||||
webhook_input = {
|
||||
"targetUrl": target_url,
|
||||
"events": events,
|
||||
"name": name,
|
||||
}
|
||||
await self.execute(
|
||||
CREATE_WEBHOOK,
|
||||
variables={"input": webhook_input},
|
||||
)
|
150
apps/monitoring/backend/monitoring/saleor/common.py
Normal file
150
apps/monitoring/backend/monitoring/saleor/common.py
Normal file
|
@ -0,0 +1,150 @@
|
|||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from jwt.api_jwk import PyJWKSet
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
from starlette.routing import NoMatchFound
|
||||
|
||||
from ..utils import get_base_url
|
||||
from .crypto import decode_jwt
|
||||
|
||||
SALEOR_API_URL_HEADER = "saleor-api-url"
|
||||
SALEOR_TOKEN_HEADER = "authorization-bearer"
|
||||
SALEOR_SIGNATURE_HEADER = "saleor-signature"
|
||||
|
||||
|
||||
class SaleorAppError(Exception):
|
||||
"""Generic Saleor App Error, all framework errros inherit from this"""
|
||||
|
||||
|
||||
class InstallAppError(SaleorAppError):
|
||||
"""Install App error"""
|
||||
|
||||
|
||||
class ConfigurationError(SaleorAppError):
|
||||
"""App is misconfigured"""
|
||||
|
||||
|
||||
class InstallData(BaseModel):
|
||||
auth_token: str
|
||||
|
||||
|
||||
class SaleorPermissions(str, Enum):
|
||||
MANAGE_OBSERVABILITY = "MANAGE_OBSERVABILITY"
|
||||
|
||||
|
||||
class LazyUrl(str):
|
||||
"""
|
||||
Used to declare a fully qualified url that is to be resolved when the
|
||||
request is available.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
@classmethod
|
||||
def __get_validators__(cls):
|
||||
yield cls.validate
|
||||
|
||||
@classmethod
|
||||
def validate(cls, v):
|
||||
return v
|
||||
|
||||
def resolve(self):
|
||||
return self.request.url_for(self.name)
|
||||
|
||||
def __call__(self, request: Request):
|
||||
self.request = request
|
||||
try:
|
||||
return self.resolve()
|
||||
except NoMatchFound:
|
||||
raise ConfigurationError(
|
||||
f"Failed to resolve a lazy url, check if an endpoint named '{self.name}' is defined."
|
||||
) from None
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other.name
|
||||
|
||||
def __ne__(self, other):
|
||||
return not (self.name == other.name)
|
||||
|
||||
def __str__(self):
|
||||
return f"LazyURL('{self.name}')"
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
class LazyAbsoluteUrl(LazyUrl):
|
||||
def resolve(self):
|
||||
base_url = get_base_url(self.request)
|
||||
return str(base_url.replace(path=self.name))
|
||||
|
||||
|
||||
class LazyPath(LazyUrl):
|
||||
"""
|
||||
Much like LazyUrl but resolves only to the path part of an url.
|
||||
The lazy aspect of this class is very redundant but is built like so to
|
||||
maintain the same usage as the LazyUrl class.
|
||||
"""
|
||||
|
||||
def resolve(self):
|
||||
return self.request.app.url_path_for(self.name)
|
||||
|
||||
def __str__(self):
|
||||
return f"LazyPath('{self.name}')"
|
||||
|
||||
|
||||
class Webhook(BaseModel):
|
||||
name: str
|
||||
async_events: list[str] = Field(..., alias="asyncEvents")
|
||||
query: str
|
||||
target_url: AnyHttpUrl | LazyUrl = Field(..., alias="targetUrl")
|
||||
is_active: bool = Field(..., alias="isActive")
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
id: str
|
||||
permissions: list[str]
|
||||
name: str
|
||||
version: str
|
||||
about: str
|
||||
extensions: list[Any] = []
|
||||
webhooks: list[Webhook] = []
|
||||
data_privacy: str = Field(..., alias="dataPrivacy")
|
||||
data_privacy_url: AnyHttpUrl | LazyUrl = Field(..., alias="dataPrivacyUrl")
|
||||
homepage_url: AnyHttpUrl | LazyUrl = Field(..., alias="homepageUrl")
|
||||
support_url: AnyHttpUrl | LazyUrl = Field(..., alias="supportUrl")
|
||||
configuration_url: AnyHttpUrl | LazyUrl | None = Field(
|
||||
None, alias="configurationUrl"
|
||||
)
|
||||
app_url: AnyHttpUrl | LazyUrl = Field(..., alias="appUrl")
|
||||
token_target_url: AnyHttpUrl | LazyUrl = Field(
|
||||
LazyUrl("install"), alias="tokenTargetUrl"
|
||||
)
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class SaleorToken:
|
||||
def __init__(self, token_str: str, jwks: dict[str, Any]):
|
||||
self.token_str = token_str
|
||||
self.jwks = PyJWKSet.from_dict(jwks)
|
||||
self.jwt = decode_jwt(self.token_str, self.jwks)
|
||||
|
||||
def is_staff_user(self) -> bool:
|
||||
return self.jwt["is_staff"]
|
||||
|
||||
def validate_permission(self, permissions: str | list[str]) -> bool:
|
||||
return True
|
||||
|
||||
def validate_user_permission(self, permissions: str | list[str]) -> bool:
|
||||
return True
|
66
apps/monitoring/backend/monitoring/saleor/crypto.py
Normal file
66
apps/monitoring/backend/monitoring/saleor/crypto.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from typing import Dict
|
||||
|
||||
from jwt.api_jwk import PyJWKSet
|
||||
from jwt.api_jws import PyJWS
|
||||
from jwt.api_jwt import PyJWT
|
||||
|
||||
|
||||
class CryptoException(Exception):
|
||||
"""
|
||||
Base exception for the crypto module errors.
|
||||
"""
|
||||
|
||||
|
||||
class JWKSKeyMissing(CryptoException):
|
||||
"""
|
||||
Raised when a requested kid is missing from a keyset.
|
||||
"""
|
||||
|
||||
|
||||
class KeyIDMissing(CryptoException):
|
||||
"""
|
||||
Raised when a JWT without a 'kid' header is received.
|
||||
"""
|
||||
|
||||
|
||||
jwt_global_obj = PyJWT(options={"verify_signature": True})
|
||||
jws_global_obj = PyJWS(options={"verify_signature": True})
|
||||
get_unverified_header = jws_global_obj.get_unverified_header
|
||||
|
||||
|
||||
def get_kid(sig_header: Dict[str, str]):
|
||||
try:
|
||||
return sig_header["kid"]
|
||||
except KeyError as err:
|
||||
raise KeyIDMissing() from err
|
||||
|
||||
|
||||
def get_key_from_jwks(kid: str, jwks: PyJWKSet):
|
||||
try:
|
||||
jwks_key = jwks[kid]
|
||||
except KeyError as err:
|
||||
raise JWKSKeyMissing(f"The JWKS does not hold the key: {kid}") from err
|
||||
return jwks_key.key
|
||||
|
||||
|
||||
def decode_webhook_payload(jws: str, jwks: PyJWKSet, webhook_payload: bytes):
|
||||
sig_header = get_unverified_header(jws)
|
||||
key = get_key_from_jwks(kid=get_kid(sig_header), jwks=jwks)
|
||||
|
||||
return jws_global_obj.decode(
|
||||
jws,
|
||||
algorithms=[sig_header["alg"]],
|
||||
key=key,
|
||||
detached_payload=webhook_payload,
|
||||
)
|
||||
|
||||
|
||||
def decode_jwt(jwt: str, jwks: PyJWKSet):
|
||||
sig_header = get_unverified_header(jwt)
|
||||
key = get_key_from_jwks(kid=get_kid(sig_header), jwks=jwks)
|
||||
|
||||
return jwt_global_obj.decode(
|
||||
jwt,
|
||||
algorithms=[sig_header["alg"]],
|
||||
key=key,
|
||||
)
|
62
apps/monitoring/backend/monitoring/saleor/graphql.py
Normal file
62
apps/monitoring/backend/monitoring/saleor/graphql.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
GET_APP_INFO = """
|
||||
query GetAppInfo {
|
||||
app {
|
||||
id
|
||||
webhooks {
|
||||
id
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
privateMetafields
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
UPDATE_PRIVATE_METADATA = """
|
||||
mutation UpdatePrivateMetadata($appId: ID!, $metadata: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $appId, input: $metadata) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
item {
|
||||
privateMetafields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
DELETE_PRIVATE_METADATA = """
|
||||
mutation DeletePrivateMetadata($appId: ID!, $keys: [String!]!) {
|
||||
deletePrivateMetadata(id: $appId, keys: $keys) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
item {
|
||||
privateMetafields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
CREATE_WEBHOOK = """
|
||||
mutation WebhookCreate($input: WebhookCreateInput!) {
|
||||
webhookCreate(input: $input) {
|
||||
webhookErrors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
webhook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
40
apps/monitoring/backend/monitoring/saleor/manager.py
Normal file
40
apps/monitoring/backend/monitoring/saleor/manager.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from typing import Generic, Type, TypeVar
|
||||
|
||||
from .client import AppInfo, SaleorClient
|
||||
from .metadata import BaseMetadata
|
||||
|
||||
T = TypeVar("T", bound=BaseMetadata)
|
||||
|
||||
|
||||
class SaleorManager(Generic[T]):
|
||||
def __init__(
|
||||
self,
|
||||
saleor_api_url: str,
|
||||
app_id: str,
|
||||
auth_token: str,
|
||||
metadata_cls: Type[T],
|
||||
user_agent="test",
|
||||
):
|
||||
self.client = SaleorClient(saleor_api_url, user_agent, auth_token)
|
||||
self.app_id = app_id
|
||||
self.metadata_cls = metadata_cls
|
||||
|
||||
async def get_app_info(self) -> AppInfo:
|
||||
return await self.client.app_info()
|
||||
|
||||
async def get_metadata(self) -> T:
|
||||
app_info = await self.get_app_info()
|
||||
return self.metadata_cls.parse_obj(app_info.private_metafields)
|
||||
|
||||
async def save_private_metadata(self, metadata: T, include: set[str] | None = None):
|
||||
exported = metadata.export(include)
|
||||
await self.client.update_private_metadata(self.app_id, exported)
|
||||
|
||||
async def delete_private_metadata(self, keys: str | set[str]):
|
||||
await self.client.delete_private_metadata(self.app_id, {"datadog"})
|
||||
flatten_keys: set[str] = set()
|
||||
if isinstance(keys, str):
|
||||
keys = {keys}
|
||||
for key in keys:
|
||||
flatten_keys.update(self.metadata_cls.field_flatten(key))
|
||||
await self.client.delete_private_metadata(self.app_id, flatten_keys)
|
88
apps/monitoring/backend/monitoring/saleor/metadata.py
Normal file
88
apps/monitoring/backend/monitoring/saleor/metadata.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
from pydantic.fields import ModelField
|
||||
|
||||
|
||||
class JsonFieldsModel(BaseModel):
|
||||
@validator("*", pre=True)
|
||||
def parse_field(cls, v):
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
class FlatMetadata(JsonFieldsModel):
|
||||
pass
|
||||
|
||||
|
||||
class BaseMetadata(JsonFieldsModel):
|
||||
_flat_fields: dict[str, tuple[str, str]] = {}
|
||||
|
||||
@classmethod
|
||||
def field_flatten(cls, field: str | ModelField) -> set[str]:
|
||||
flatten_set: set[str] = set()
|
||||
if isinstance(field, str):
|
||||
field = cls.__fields__[field]
|
||||
if issubclass(field.type_, FlatMetadata):
|
||||
for sub_field in field.type_.__fields__.values():
|
||||
flat_key = f"{field.name}_{sub_field.name}"
|
||||
flatten_set.add(flat_key)
|
||||
else:
|
||||
flatten_set.add(field.name)
|
||||
return flatten_set
|
||||
|
||||
@classmethod
|
||||
def _get_flat_fields_map(cls) -> dict[str, tuple[str, str]]:
|
||||
flat_fields: dict[str, tuple[str, str]] = {}
|
||||
for field in cls.__fields__.values():
|
||||
if issubclass(field.type_, FlatMetadata):
|
||||
for sub_field in field.type_.__fields__.values():
|
||||
flat_key = f"{field.name}_{sub_field.name}"
|
||||
flat_fields[flat_key] = field.name, sub_field.name
|
||||
return flat_fields
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls._flat_fields = cls._get_flat_fields_map()
|
||||
for field_name in cls.__fields__.keys():
|
||||
if field_name in cls._flat_fields:
|
||||
parent, subfield = cls._flat_fields[field_name]
|
||||
raise ValueError(
|
||||
f"{cls!r}: field {field_name} name conflict with flatten {parent}.{subfield}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _encode_field(field, value):
|
||||
if isinstance(value, FlatMetadata):
|
||||
result = {}
|
||||
for key, val in value.dict().items():
|
||||
result[f"{field.name}_{key}"] = jsonable_encoder(val)
|
||||
return result
|
||||
return {field.name: jsonable_encoder(value)}
|
||||
|
||||
def export(self, include: set | None = None) -> dict[str, str]:
|
||||
metadata: dict[str, Any] = {}
|
||||
fields = set(self.__fields__.keys())
|
||||
if include is not None:
|
||||
fields = fields & include
|
||||
for field_name in fields:
|
||||
field, value = self.__fields__[field_name], getattr(self, field_name)
|
||||
metadata.update(self._encode_field(field, value))
|
||||
return {key: json.dumps(val) for key, val in metadata.items()}
|
||||
|
||||
@root_validator(pre=True)
|
||||
def unflatten(cls, values):
|
||||
unflatten_values: dict[str, Any] = {}
|
||||
for key, val in values.items():
|
||||
try:
|
||||
parent_name, field_name = cls._flat_fields[key]
|
||||
unflatten_values.setdefault(parent_name, {})[field_name] = val
|
||||
except KeyError:
|
||||
unflatten_values[key] = val
|
||||
return unflatten_values
|
|
@ -0,0 +1,81 @@
|
|||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..metadata import BaseMetadata, FlatMetadata
|
||||
|
||||
|
||||
class Foo(BaseModel):
|
||||
a: str
|
||||
b: float | None = None
|
||||
|
||||
|
||||
class Bar(BaseModel):
|
||||
a: int
|
||||
b: Foo | None = None
|
||||
c: list[Foo] = []
|
||||
|
||||
|
||||
class ExampleConfig(FlatMetadata):
|
||||
login: str
|
||||
password: str
|
||||
more: Bar | None = None
|
||||
|
||||
|
||||
class Metadata(BaseMetadata):
|
||||
a: str | None = None
|
||||
b: int | None = None
|
||||
c: list[str] | None = None
|
||||
d: str | None = None
|
||||
e: Bar | None = None
|
||||
f: ExampleConfig | None = None
|
||||
g: list[Bar] = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata():
|
||||
return Metadata(
|
||||
a="a",
|
||||
b=3,
|
||||
c=["a", "b", "c"],
|
||||
e=Bar(a=0, b=Foo(a="a"), c=[Foo(a="a"), Foo(a="b")]),
|
||||
f=ExampleConfig(
|
||||
login="admin",
|
||||
password="admin",
|
||||
more=Bar(
|
||||
a=1,
|
||||
b=Foo(
|
||||
a="a",
|
||||
),
|
||||
),
|
||||
),
|
||||
g=[Bar(a=0), Bar(a=1, b=Foo(a="a"))],
|
||||
)
|
||||
|
||||
|
||||
def test_to_metadata(metadata):
|
||||
assert metadata.export() == {
|
||||
"a": '"a"',
|
||||
"b": "3",
|
||||
"c": '["a", "b", "c"]',
|
||||
"d": "null",
|
||||
"e": '{"a": 0, "b": {"a": "a", "b": null}, "c": [{"a": "a", "b": null}, {"a": "b", "b": null}]}',
|
||||
"f_login": '"admin"',
|
||||
"f_password": '"admin"',
|
||||
"f_more": '{"a": 1, "b": {"a": "a", "b": null}, "c": []}',
|
||||
"g": '[{"a": 0, "b": null, "c": []}, {"a": 1, "b": {"a": "a", "b": null}, "c": []}]',
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_export_reversible(metadata):
|
||||
exported = metadata.export()
|
||||
assert Metadata.parse_obj(exported) == metadata
|
||||
|
||||
|
||||
def test_metadata_flatten_field(metadata):
|
||||
assert metadata.field_flatten("a") == {"a"}
|
||||
assert metadata.field_flatten("f") == {"f_login", "f_password", "f_more"}
|
||||
assert metadata.field_flatten(metadata.__fields__["f"]) == {
|
||||
"f_login",
|
||||
"f_password",
|
||||
"f_more",
|
||||
}
|
12
apps/monitoring/backend/monitoring/saleor/utils.py
Normal file
12
apps/monitoring/backend/monitoring/saleor/utils.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def to_camel(snake_str: str) -> str:
|
||||
components = snake_str.split("_")
|
||||
return components[0] + "".join(x.capitalize() if x else "_" for x in components[1:])
|
||||
|
||||
|
||||
class JsonBaseModel(BaseModel):
|
||||
class Config:
|
||||
alias_generator = to_camel
|
||||
allow_population_by_field_name = True
|
62
apps/monitoring/backend/monitoring/schema.graphql
Normal file
62
apps/monitoring/backend/monitoring/schema.graphql
Normal file
|
@ -0,0 +1,62 @@
|
|||
interface Configuration {
|
||||
active: Boolean!
|
||||
error: String
|
||||
}
|
||||
|
||||
type ConfigurationError {
|
||||
field: String
|
||||
message: String!
|
||||
}
|
||||
|
||||
# Integration specific
|
||||
|
||||
# Datadog
|
||||
enum DatadogSite{
|
||||
US1
|
||||
US3
|
||||
US5
|
||||
EU1
|
||||
US1_FED
|
||||
}
|
||||
|
||||
type DataDogCredentials {
|
||||
site: DatadogSite!
|
||||
apiKeyLast4: String!
|
||||
}
|
||||
|
||||
type DatadogConfig implements Configuration {
|
||||
active: Boolean!
|
||||
error: String
|
||||
credentials: DataDogCredentials!
|
||||
}
|
||||
|
||||
type Integrations {
|
||||
datadog: DatadogConfig
|
||||
}
|
||||
|
||||
input DataDogCredentialsInput {
|
||||
site: DatadogSite!
|
||||
apiKey: String!
|
||||
|
||||
}
|
||||
|
||||
input DatadogConfigInput {
|
||||
active: Boolean
|
||||
credentials: DataDogCredentialsInput
|
||||
}
|
||||
|
||||
type DataDogConfigMutationResult {
|
||||
errors: [ConfigurationError!]!
|
||||
datadog: DatadogConfig
|
||||
}
|
||||
|
||||
# End integrations
|
||||
|
||||
type Query {
|
||||
integrations: Integrations!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updateDatadogConfig(input: DatadogConfigInput!): DataDogConfigMutationResult!
|
||||
deleteDatadogConfig: DataDogConfigMutationResult!
|
||||
}
|
12
apps/monitoring/backend/monitoring/schema.py
Normal file
12
apps/monitoring/backend/monitoring/schema.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from .integrations.datadog import DatadogCredentials
|
||||
from .saleor.metadata import BaseMetadata, FlatMetadata
|
||||
|
||||
|
||||
class DatadogConfig(FlatMetadata):
|
||||
active: bool = False
|
||||
error: str | None = None
|
||||
credentials: DatadogCredentials
|
||||
|
||||
|
||||
class Metadata(BaseMetadata):
|
||||
datadog: DatadogConfig | None = None
|
41
apps/monitoring/backend/monitoring/settings.py
Normal file
41
apps/monitoring/backend/monitoring/settings.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
from . import __version__ as app_version
|
||||
from .saleor.common import LazyAbsoluteUrl, Manifest, SaleorPermissions
|
||||
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
debug: bool = True
|
||||
apl_url: str = f"file://{base_dir/'.fileApl.json'}"
|
||||
mock_datadog_client = False
|
||||
allowed_domains: set[str] = {"*"}
|
||||
forbidden_domains: set[str] = set()
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
|
||||
manifest = Manifest(
|
||||
id="saleor-app-monitoring",
|
||||
name="Monitoring",
|
||||
version=app_version,
|
||||
about="Saleor Monitoring app",
|
||||
data_privacy="",
|
||||
app_url=LazyAbsoluteUrl("/"),
|
||||
configuration_url=LazyAbsoluteUrl("/configuration"),
|
||||
data_privacy_url="https://saleor.io/legal/privacy", # noqa
|
||||
homepage_url="https://saleor.io/", # noqa
|
||||
support_url="https://github.com/saleor", # noqa
|
||||
token_target_url=LazyAbsoluteUrl("install"),
|
||||
permissions=[SaleorPermissions.MANAGE_OBSERVABILITY],
|
||||
extensions=[],
|
||||
webhooks=[],
|
||||
)
|
0
apps/monitoring/backend/monitoring/tests/__init__.py
Normal file
0
apps/monitoring/backend/monitoring/tests/__init__.py
Normal file
20
apps/monitoring/backend/monitoring/tests/conftest.py
Normal file
20
apps/monitoring/backend/monitoring/tests/conftest.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from ..app import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio", {"use_uvloop": True}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def observability_app():
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(observability_app):
|
||||
async with AsyncClient(app=observability_app, base_url="http://test") as client:
|
||||
yield client
|
8
apps/monitoring/backend/monitoring/tests/test_app.py
Normal file
8
apps/monitoring/backend/monitoring/tests/test_app.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health(client):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
28
apps/monitoring/backend/monitoring/tests/test_utils.py
Normal file
28
apps/monitoring/backend/monitoring/tests/test_utils.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
import pytest
|
||||
|
||||
from ..utils import domain_validation
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain,allowed,forbidden,is_allowed",
|
||||
[
|
||||
("localhost:8000", {"*"}, None, True),
|
||||
("wrong.com", {"example.com"}, None, False),
|
||||
("sub.example.com", {"example.com"}, None, False),
|
||||
("sub.example.com", {"*.example.com"}, None, True),
|
||||
("sub.sub.example.com", {"*.example.com"}, None, True),
|
||||
("example.com", {"*.example.com"}, None, False),
|
||||
("sub.example.com", {"*.sub.example.com"}, None, False),
|
||||
("sub.sub.example.com", {"*.sub.example.com"}, None, True),
|
||||
("example.com", {"python.org", "example.com"}, None, True),
|
||||
("any.com", {"python.org", "example.com", "*"}, None, True),
|
||||
("example.com", {"*.example.com"}, None, False),
|
||||
("sub.example.com", {"*"}, {"*.example.com"}, False),
|
||||
("sub.example.com", {"*.example.com"}, {"*.banned.com"}, True),
|
||||
("sub.example.com", {"*.example.com"}, {"x.example.com"}, True),
|
||||
("non-eu-sub.saleor.cloud", {"*.saleor.cloud"}, {"*.eu.saleor.cloud"}, True),
|
||||
("sub.eu.saleor.cloud", {"*.saleor.cloud"}, {"*.eu.saleor.cloud"}, False),
|
||||
],
|
||||
)
|
||||
def test_domain_validation(domain, allowed, forbidden, is_allowed):
|
||||
assert domain_validation(domain, allowed, forbidden) is is_allowed
|
48
apps/monitoring/backend/monitoring/utils.py
Normal file
48
apps/monitoring/backend/monitoring/utils.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import fnmatch
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel
|
||||
from starlette.datastructures import URL
|
||||
|
||||
HttpHeaders = list[tuple[str, str]]
|
||||
|
||||
|
||||
def domain_validation(
|
||||
domain_name: str, allowed: set[str], forbidden: set[str] | None = None
|
||||
):
|
||||
forbidden = set() if forbidden is None else forbidden
|
||||
for origin in allowed:
|
||||
if fnmatch.fnmatchcase(domain_name, origin):
|
||||
for disallowed in forbidden:
|
||||
if fnmatch.fnmatchcase(domain_name, disallowed):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def to_camel(snake_str: str) -> str:
|
||||
components = snake_str.split("_")
|
||||
return components[0] + "".join(x.capitalize() if x else "_" for x in components[1:])
|
||||
|
||||
|
||||
class JsonBaseModel(BaseModel):
|
||||
class Config:
|
||||
alias_generator = to_camel
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
def parse_headers(headers_list: HttpHeaders) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
for key, val in headers_list:
|
||||
headers[key] = val
|
||||
return headers
|
||||
|
||||
|
||||
def get_base_url(request: Request) -> URL:
|
||||
base_url, headers = request.base_url, request.headers
|
||||
scheme = headers.get("x-forwarded-proto", base_url.scheme).split(",")[0].strip()
|
||||
if forwarded_host := headers.get("x-forwarded-host"):
|
||||
parts = urllib.parse.urlsplit(f"//{forwarded_host}")
|
||||
base_url = base_url.replace(hostname=parts.hostname, port=parts.port)
|
||||
return base_url.replace(scheme=scheme)
|
1929
apps/monitoring/backend/poetry.lock
generated
Normal file
1929
apps/monitoring/backend/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
58
apps/monitoring/backend/pyproject.toml
Normal file
58
apps/monitoring/backend/pyproject.toml
Normal file
|
@ -0,0 +1,58 @@
|
|||
[tool.poetry]
|
||||
name = "saleor-app-monitoring"
|
||||
version = "0.1.0"
|
||||
description = "Saleor Monitoring app"
|
||||
authors = ["Przemysław Łada <przemyslaw.lada@saleor.io>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
fastapi = "^0.92.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.20.0"}
|
||||
python-dotenv = "^0.21.0"
|
||||
python-json-logger = "^2.0.4"
|
||||
datadog-api-client = {extras = ["async"], version = "^2.8.0"}
|
||||
ua-parser = "^0.16.1"
|
||||
ariadne = "^0.17.1"
|
||||
httpx = "^0.23.3"
|
||||
pyjwt = {extras = ["crypto"], version = "^2.6.0"}
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = {extras = ["d"], version = "^22.12.0"}
|
||||
pytest = "^7.2.0"
|
||||
pre-commit = "^2.21.0"
|
||||
ruff = "^0.0.215"
|
||||
mypy = "^0.991"
|
||||
anyio = "^3.6.2"
|
||||
pytest-recording = "^0.12.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I001", # isort
|
||||
"C", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long, handled by black
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex
|
||||
]
|
||||
fix = true
|
||||
fixable = ["I001"]
|
||||
src = ["monitoring"]
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["monitoring"]
|
||||
|
||||
[tool.mypy]
|
||||
allow_redefinition = true
|
||||
show_error_codes = true
|
||||
check_untyped_defs = true
|
16
apps/monitoring/docker-compose.yml
Normal file
16
apps/monitoring/docker-compose.yml
Normal file
|
@ -0,0 +1,16 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
- "5001:80"
|
||||
environment:
|
||||
- DEBUG=True
|
||||
volumes:
|
||||
- ./backend/monitoring/:/app/monitoring
|
5
apps/monitoring/next-env.d.ts
vendored
Normal file
5
apps/monitoring/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
21
apps/monitoring/next.config.js
Normal file
21
apps/monitoring/next.config.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
rewrites() {
|
||||
/**
|
||||
* For dev/preview Next.js can work as a proxy and redirect unknown paths to provided backend address
|
||||
*
|
||||
* In production, when env is not provided, frontend will call its relative path and reverse proxy will do the rest
|
||||
*/
|
||||
const backendPath = process.env.MONITORING_APP_API_URL ?? "";
|
||||
|
||||
return {
|
||||
fallback: [
|
||||
{
|
||||
source: "/:path*",
|
||||
destination: `${backendPath}/:path*`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
62
apps/monitoring/package.json
Normal file
62
apps/monitoring/package.json
Normal file
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "saleor-app-monitoring",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "pnpm generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"generate": "graphql-codegen",
|
||||
"test": "vitest"
|
||||
},
|
||||
"saleor": {
|
||||
"schemaVersion": "3.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-hook-form": "^7.42.1",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@saleor/app-sdk": "0.27.1",
|
||||
"@saleor/macaw-ui": "^0.7.2",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jsdom": "^20.0.3",
|
||||
"next": "13.1.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1",
|
||||
"clsx": "^1.2.1",
|
||||
"@saleor/apps-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "2.13.3",
|
||||
"@graphql-codegen/introspection": "2.2.1",
|
||||
"@graphql-codegen/typed-document-node": "^2.3.3",
|
||||
"@graphql-codegen/typescript": "2.7.3",
|
||||
"@graphql-codegen/typescript-operations": "2.5.3",
|
||||
"@graphql-codegen/typescript-urql": "^3.7.0",
|
||||
"@graphql-codegen/urql-introspection": "2.2.1",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "13.1.2",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"prettier": "^2.8.2",
|
||||
"typescript": "4.9.4",
|
||||
"eslint-config-saleor": "workspace:*"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
"*.{js,ts,tsx,css,md,json}": "prettier --write"
|
||||
}
|
||||
}
|
6784
apps/monitoring/pnpm-lock.yaml
Normal file
6784
apps/monitoring/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
64
apps/monitoring/src/assets/datadog/dd_logo_h_rgb.svg
Normal file
64
apps/monitoring/src/assets/datadog/dd_logo_h_rgb.svg
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 800.5 203.19" style="enable-background:new 0 0 800.5 203.19;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#632CA6;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M260.87,144.65h-37.4v-86.1h37.4c26.94,0,40.43,13.57,40.43,40.7C301.29,129.51,287.81,144.65,260.87,144.65z
|
||||
M239.45,130.79h19c17.9,0,26.84-10.51,26.84-31.55c0-17.91-8.95-26.87-26.84-26.87h-19L239.45,130.79L239.45,130.79z"/>
|
||||
<polygon class="st0" points="318.04,144.65 301.62,144.65 338.25,58.55 355.44,58.55 392.85,144.65 375.66,144.65 364.8,121.17
|
||||
337.17,121.17 342.66,107.32 360.58,107.32 346.46,74.98 "/>
|
||||
<polygon class="st0" points="383.82,58.55 449.28,58.55 449.28,72.39 424.55,72.39 424.55,144.65 408.57,144.65 408.57,72.39
|
||||
383.82,72.39 "/>
|
||||
<polygon class="st0" points="457.5,144.65 441.08,144.65 477.71,58.55 494.9,58.55 532.31,144.65 515.1,144.65 504.24,121.17
|
||||
476.61,121.17 482.1,107.32 500.02,107.32 485.91,74.98 "/>
|
||||
<path class="st0" d="M580.32,144.65h-37.4v-86.1h37.4c26.96,0,40.43,13.57,40.43,40.7C620.75,129.51,607.28,144.65,580.32,144.65z
|
||||
M558.91,130.79h19c17.89,0,26.86-10.51,26.86-31.55c0-17.91-8.96-26.87-26.86-26.87h-19V130.79z"/>
|
||||
<path class="st0" d="M631.58,101.72c0-29.2,14.45-43.79,43.33-43.79c28.44,0,42.64,14.59,42.64,43.79
|
||||
c0,29.03-14.21,43.55-42.64,43.55C647.31,145.27,632.87,130.75,631.58,101.72z M674.91,131.39c17.36,0,26.05-10.01,26.05-30.05
|
||||
c0-19.72-8.69-29.59-26.05-29.59c-17.82,0-26.73,9.87-26.73,29.59C648.18,121.38,657.09,131.39,674.91,131.39z"/>
|
||||
<path class="st0" d="M784.26,109.81v20.16c-3.69,0.96-6.99,1.44-9.9,1.44c-19.55,0-29.31-10.34-29.31-31.01
|
||||
c0-19.09,10.36-28.62,31.07-28.62c8.65,0,16.69,1.61,24.13,4.82V62.14c-7.44-2.8-15.89-4.21-25.34-4.21
|
||||
c-30.97,0-46.46,14.15-46.46,42.47c0,29.9,15.22,44.87,45.67,44.87c10.47,0,19.17-1.52,26.13-4.58V95.64h-25.82l-5.4,14.16
|
||||
L784.26,109.81L784.26,109.81z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M158.87,144.16L142,133.04l-14.07,23.5l-16.36-4.78l-14.41,21.99l0.74,6.92l78.33-14.43l-4.55-48.94
|
||||
L158.87,144.16z M85.82,123.07l12.57-1.73c2.03,0.91,3.45,1.26,5.89,1.88c3.8,0.99,8.19,1.94,14.7-1.34
|
||||
c1.51-0.75,4.67-3.64,5.94-5.28l51.49-9.34l5.25,63.57l-88.21,15.9L85.82,123.07z M181.46,100.16l-5.08,0.97L166.62,0.25
|
||||
L0.25,19.54l20.5,166.33l19.47-2.83c-1.55-2.22-3.98-4.91-8.11-8.35c-5.74-4.76-3.71-12.86-0.32-17.97
|
||||
c4.47-8.63,27.54-19.61,26.23-33.41c-0.47-5.02-1.27-11.55-5.93-16.03c-0.17,1.86,0.14,3.65,0.14,3.65s-1.91-2.44-2.87-5.77
|
||||
c-0.95-1.28-1.69-1.68-2.7-3.39c-0.72,1.97-0.62,4.26-0.62,4.26s-1.56-3.7-1.82-6.82c-0.93,1.4-1.16,4.05-1.16,4.05
|
||||
s-2.03-5.83-1.57-8.97c-0.93-2.73-3.68-8.15-2.9-20.47c5.08,3.56,16.26,2.71,20.61-3.71c1.45-2.13,2.44-7.93-0.72-19.36
|
||||
c-2.03-7.33-7.05-18.25-9.01-22.4l-0.23,0.17c1.03,3.34,3.16,10.33,3.98,13.73c2.47,10.29,3.13,13.87,1.97,18.61
|
||||
c-0.99,4.12-3.35,6.82-9.35,9.84c-6,3.03-13.96-4.34-14.47-4.74c-5.83-4.64-10.34-12.22-10.84-15.9
|
||||
c-0.52-4.03,2.32-6.45,3.76-9.74c-2.05,0.59-4.34,1.63-4.34,1.63s2.73-2.83,6.1-5.27c1.4-0.92,2.21-1.51,3.68-2.73
|
||||
c-2.13-0.03-3.86,0.02-3.86,0.02s3.55-1.92,7.23-3.31c-2.69-0.12-5.27-0.02-5.27-0.02S35.75,27.1,42,24.5
|
||||
c4.3-1.76,8.5-1.24,10.86,2.17c3.1,4.47,6.35,6.9,13.25,8.41c4.24-1.88,5.52-2.84,10.84-4.29c4.68-5.15,8.36-5.82,8.36-5.82
|
||||
s-1.82,1.67-2.31,4.3c2.66-2.09,5.57-3.84,5.57-3.84s-1.13,1.39-2.18,3.6l0.24,0.36c3.1-1.86,6.74-3.32,6.74-3.32
|
||||
s-1.04,1.32-2.26,3.02c2.34-0.02,7.08,0.1,8.91,0.31c10.86,0.24,13.11-11.6,17.28-13.08c5.22-1.86,7.55-2.99,16.44,5.74
|
||||
c7.63,7.5,13.59,20.91,10.63,23.92c-2.48,2.49-7.38-0.97-12.8-7.74c-2.87-3.58-5.03-7.81-6.05-13.19
|
||||
c-0.86-4.54-4.19-7.17-4.19-7.17s1.93,4.31,1.93,8.11c0,2.08,0.26,9.84,3.59,14.19c-0.33,0.64-0.48,3.15-0.85,3.63
|
||||
c-3.87-4.68-12.19-8.03-13.54-9.02c4.59,3.76,15.14,12.4,19.19,20.68c3.83,7.83,1.57,15.01,3.51,16.87
|
||||
c0.55,0.53,8.24,10.11,9.72,14.93c2.58,8.39,0.15,17.21-3.22,22.68l-9.43,1.47c-1.38-0.38-2.31-0.58-3.55-1.29
|
||||
c0.68-1.21,2.04-4.22,2.05-4.84l-0.53-0.93c-2.94,4.16-7.85,8.2-11.94,10.52c-5.35,3.03-11.51,2.56-15.52,1.32
|
||||
c-11.39-3.51-22.16-11.21-24.75-13.23c0,0-0.08,1.61,0.41,1.98c2.87,3.24,9.45,9.1,15.81,13.18l-13.55,1.49l6.41,49.89
|
||||
c-2.84,0.41-3.28,0.61-6.39,1.05c-2.74-9.68-7.98-16.01-13.71-19.69c-5.05-3.25-12.02-3.98-18.7-2.66l-0.43,0.5
|
||||
c4.64-0.48,10.12,0.19,15.74,3.75c5.52,3.49,9.97,12.51,11.61,17.94c2.1,6.94,3.55,14.36-2.1,22.23
|
||||
c-4.02,5.59-15.74,8.68-25.22,2c2.53,4.07,5.95,7.4,10.55,8.02c6.84,0.93,13.33-0.26,17.79-4.84c3.81-3.92,5.84-12.12,5.3-20.75
|
||||
l6.03-0.87l2.18,15.49l99.88-12.03L181.46,100.16z M120.69,58.08c-0.28,0.64-0.72,1.05-0.06,3.12l0.04,0.12l0.1,0.27l0.27,0.62
|
||||
c1.19,2.42,2.49,4.71,4.66,5.88c0.56-0.09,1.15-0.16,1.75-0.19c2.04-0.09,3.33,0.23,4.15,0.68c0.07-0.41,0.09-1,0.04-1.88
|
||||
c-0.16-3.07,0.61-8.29-5.29-11.04c-2.23-1.03-5.35-0.72-6.39,0.58c0.19,0.02,0.36,0.06,0.49,0.11
|
||||
C122.04,56.89,120.98,57.43,120.69,58.08 M137.23,86.73c-0.77-0.43-4.39-0.26-6.93,0.04c-4.84,0.57-10.07,2.25-11.22,3.14
|
||||
c-2.08,1.61-1.14,4.42,0.4,5.57c4.32,3.22,8.1,5.39,12.09,4.86c2.45-0.32,4.61-4.2,6.14-7.73
|
||||
C138.77,90.19,138.77,87.58,137.23,86.73 M94.36,61.88c1.37-1.3-6.8-3-13.14,1.32c-4.67,3.19-4.82,10.03-0.35,13.9
|
||||
c0.45,0.38,0.82,0.66,1.16,0.88c1.31-0.62,2.8-1.24,4.51-1.79c2.9-0.94,5.3-1.43,7.28-1.68c0.95-1.06,2.05-2.92,1.77-6.29
|
||||
C95.22,63.63,91.75,64.36,94.36,61.88"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.5 KiB |
52
apps/monitoring/src/assets/datadog/dd_logo_h_white.svg
Normal file
52
apps/monitoring/src/assets/datadog/dd_logo_h_white.svg
Normal file
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 800.5 196.2" style="enable-background:new 0 0 800.5 196.2;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M167.2,98.4l-54.9,10c-1.4,1.8-4.7,4.8-6.3,5.6c-6.9,3.5-11.6,2.5-15.7,1.4c-2.6-0.7-4.1-1-6.3-2l-13.4,1.8
|
||||
l8.1,67.9l94.1-17L167.2,98.4z M83.4,176.7l-0.8-7.4L98,145.9l17.4,5.1l15-25.1l18,11.9l13.7-28.7l4.9,52.2L83.4,176.7z M79.5,110
|
||||
c-6.8-4.4-13.8-10.6-16.9-14.1c-0.5-0.4-0.4-2.1-0.4-2.1c2.8,2.2,14.3,10.4,26.4,14.1c4.3,1.3,10.9,1.8,16.6-1.4
|
||||
c4.4-2.5,9.6-6.8,12.7-11.2l0.6,1c0,0.7-1.5,3.9-2.2,5.2c1.3,0.8,2.3,1,3.8,1.4l10.1-1.6c3.6-5.8,6.2-15.2,3.4-24.2
|
||||
c-1.6-5.1-9.8-15.4-10.4-15.9c-2.1-2,0.3-9.6-3.7-18c-4.3-8.8-15.6-18-20.5-22.1c1.4,1,10.3,4.6,14.4,9.6c0.4-0.5,0.6-3.2,0.9-3.9
|
||||
c-3.6-4.6-3.8-12.9-3.8-15.1c0-4.1-2.1-8.7-2.1-8.7s3.6,2.8,4.5,7.7c1.1,5.7,3.4,10.3,6.5,14.1c5.8,7.2,11,10.9,13.7,8.2
|
||||
c3.2-3.2-3.2-17.5-11.3-25.5c-9.5-9.3-12-8.1-17.5-6.1c-4.4,1.5-6.8,14.2-18.4,13.9c-2-0.2-7-0.4-9.5-0.3c1.3-1.8,2.4-3.2,2.4-3.2
|
||||
s-3.9,1.6-7.2,3.6l-0.3-0.4c1.1-2.4,2.3-3.8,2.3-3.8s-3.1,1.9-5.9,4.1c0.5-2.8,2.5-4.6,2.5-4.6s-3.9,0.7-8.9,6.2
|
||||
c-5.7,1.5-7,2.6-11.6,4.6c-7.4-1.6-10.8-4.2-14.1-9c-2.5-3.6-7-4.2-11.6-2.3c-6.7,2.8-15.1,6.5-15.1,6.5s2.8-0.1,5.6,0
|
||||
c-3.9,1.5-7.7,3.5-7.7,3.5s1.8-0.1,4.1,0c-1.6,1.3-2.4,1.9-3.9,2.9c-3.6,2.6-6.5,5.6-6.5,5.6s2.4-1.1,4.6-1.7
|
||||
c-1.5,3.5-4.6,6.1-4,10.4c0.5,3.9,5.3,12,11.6,17c0.5,0.4,9,8.3,15.4,5.1c6.4-3.2,8.9-6.1,10-10.5c1.2-5.1,0.5-8.9-2.1-19.9
|
||||
c-0.9-3.6-3.1-11.1-4.2-14.6l0.2-0.2c2.1,4.4,7.4,16.1,9.6,23.9c3.4,12.2,2.3,18.4,0.8,20.7c-4.7,6.8-16.6,7.8-22,4
|
||||
c-0.8,13.1,2.1,18.9,3.1,21.8c-0.5,3.3,1.7,9.6,1.7,9.6s0.2-2.8,1.2-4.3c0.3,3.3,1.9,7.3,1.9,7.3s-0.1-2.4,0.7-4.5
|
||||
c1.1,1.8,1.9,2.2,2.9,3.6c1,3.6,3,6.2,3,6.2s-0.3-1.9-0.2-3.9c5,4.8,5.8,11.8,6.3,17.1c1.4,14.7-23.2,26.4-28,35.6
|
||||
c-3.6,5.4-5.8,14.1,0.3,19.2c14.8,12.3,9.1,15.7,16.5,21.1c10.2,7.4,22.9,4.1,27.2-1.9c6-8.4,4.5-16.3,2.2-23.7
|
||||
c-1.8-5.8-6.5-15.4-12.4-19.1c-6-3.8-11.9-4.5-16.8-4l0.5-0.5c7.1-1.4,14.6-0.6,20,2.8c6.1,3.9,11.7,10.7,14.6,21
|
||||
c3.3-0.5,3.8-0.7,6.8-1.1L65,111.6L79.5,110z M113.8,43.3c6.3,2.9,5.5,8.5,5.6,11.8c0.1,0.9,0,1.6-0.1,2c-0.9-0.5-2.2-0.8-4.4-0.7
|
||||
c-0.6,0-1.3,0.1-1.9,0.2c-2.3-1.2-3.7-3.7-5-6.3c-0.1-0.2-0.2-0.5-0.3-0.7c0-0.1-0.1-0.2-0.1-0.3c0,0,0-0.1,0-0.1
|
||||
c-0.7-2.2-0.2-2.7,0.1-3.3s1.4-1.3-0.2-1.8c-0.1,0-0.3-0.1-0.5-0.1C108.1,42.6,111.4,42.2,113.8,43.3z M106,79.9
|
||||
c1.2-0.9,6.8-2.7,12-3.4c2.7-0.3,6.6-0.5,7.4,0c1.6,0.9,1.6,3.7,0.5,6.3c-1.6,3.8-3.9,7.9-6.6,8.2c-4.3,0.6-8.3-1.7-12.9-5.2
|
||||
C104.8,84.6,103.8,81.6,106,79.9z M65.6,51.4c6.8-4.6,15.5-2.8,14-1.4c-2.8,2.7,0.9,1.9,1.3,6.8c0.3,3.6-0.9,5.6-1.9,6.7
|
||||
c-2.1,0.3-4.7,0.8-7.8,1.8c-1.8,0.6-3.4,1.2-4.8,1.9c-0.4-0.2-0.8-0.5-1.2-0.9C60.5,62.1,60.7,54.8,65.6,51.4z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M249.2,141.8H211v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C290.5,126.3,276.8,141.8,249.2,141.8z M227.4,127.6
|
||||
h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.1-27.4-27.4-27.4h-19.4V127.6L227.4,127.6z"/>
|
||||
<polygon class="st1" points="307.6,141.8 290.9,141.8 328.3,53.8 345.9,53.8 384.1,141.8 366.5,141.8 355.4,117.8 327.2,117.8
|
||||
332.8,103.7 351.1,103.7 336.7,70.6 "/>
|
||||
<polygon class="st1" points="374.8,53.8 441.7,53.8 441.7,68 416.5,68 416.5,141.8 400.1,141.8 400.1,68 374.8,68 "/>
|
||||
<polygon class="st1" points="450.1,141.8 433.3,141.8 470.8,53.8 488.3,53.8 526.5,141.8 509,141.8 497.9,117.8 469.6,117.8
|
||||
475.2,103.7 493.5,103.7 479.1,70.6 "/>
|
||||
<path class="st1" d="M575.6,141.8h-38.2v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C616.9,126.3,603.1,141.8,575.6,141.8z M553.7,127.6
|
||||
h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.2-27.4-27.4-27.4h-19.4V127.6z"/>
|
||||
<path class="st1" d="M628,97.9c0-29.8,14.8-44.7,44.3-44.7c29,0,43.6,14.9,43.6,44.7c0,29.7-14.5,44.5-43.6,44.5
|
||||
C644,142.4,629.3,127.6,628,97.9z M672.2,128.2c17.7,0,26.6-10.2,26.6-30.7c0-20.2-8.9-30.2-26.6-30.2
|
||||
c-18.2,0-27.3,10.1-27.3,30.2C644.9,118,654,128.2,672.2,128.2z"/>
|
||||
<path class="st1" d="M783.9,106.2v20.6c-3.8,1-7.1,1.5-10.1,1.5c-20,0-30-10.6-30-31.7c0-19.5,10.6-29.2,31.7-29.2
|
||||
c8.8,0,17,1.6,24.7,4.9V57.5c-7.6-2.9-16.2-4.3-25.9-4.3c-31.6,0-47.5,14.4-47.5,43.4c0,30.6,15.5,45.8,46.7,45.8
|
||||
c10.7,0,19.6-1.6,26.7-4.7v-46h-26.4l-5.5,14.5H783.9z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
|
@ -0,0 +1,18 @@
|
|||
<svg width="409" height="137" viewBox="0 0 409 137" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M167.17 95.75C164.97 97.06 159.17 97.63 156.32 97.63C148.33 97.63 143.52 93.47 143.52 86.38V50.32L138.79 49.83V39.48H158.24V82.46C158.24 84.91 159.67 85.97 162.52 85.97H165.54L167.17 95.75Z" fill="#002E42"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M242.2 65.25H237.39C232.82 65.25 231.35 66.31 231.35 70.06C231.35 73.81 232.98 75.36 237.39 75.36C241.87 75.36 243.26 73.73 243.26 69.98C243.26 67.69 242.85 66.23 242.2 65.25ZM256.15 65.25C257.7 66.8 258.11 68.76 258.11 70.71C258.11 79.27 251.83 83.76 237.31 83.76C235.27 83.76 231.84 83.52 231.76 83.52C230.93 83.52 230.75 84 230.69 84.19C230.12 86.13 231.68 86.42 235.68 87.12L247.17 88.82C256.72 90.29 259.08 94.77 259.08 100.41C259.08 107.75 255.09 113.13 236.9 113.13C230.54 113.13 223.44 112.4 217.49 111.42V101.47C224.66 102.04 230.36 102.53 237.05 102.53C242.51 102.53 244.57 102.36 244.57 100.33C244.65 98.7 242.72 98.22 238.45 97.51L227.92 95.85C221.07 94.62 219.03 90.71 219.19 86.96C219.27 84.68 220.41 82.47 222.37 81.09C218.05 78.81 216.09 75.06 216.09 69.75C216.09 59.15 223.84 56.05 237.46 56.05H264.03V65.27H256.15V65.25Z" fill="#002E42"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M349.8 56.29H342.8H337.89V59.54V59.69V65.74V68.99H342.8V86.47V96.83H357.57V86.47V56.29H349.8Z" fill="#002E42"/>
|
||||
<path d="M348.42 36.24C353.01 36.24 356.23 39.38 356.23 43.8C356.23 48.13 353 51.27 348.42 51.27C343.92 51.27 340.69 48.13 340.69 43.8C340.69 39.38 343.92 36.24 348.42 36.24Z" fill="#002E42"/>
|
||||
<path d="M324.6 81.84C329.19 81.84 332.41 84.98 332.41 89.4C332.41 93.73 329.18 96.87 324.6 96.87C320.1 96.87 316.87 93.73 316.87 89.4C316.87 84.98 320.1 81.84 324.6 81.84Z" fill="#002E42"/>
|
||||
<path d="M190.07 54.89C177.3 54.89 168.14 63.8 168.14 76.34C168.14 88.63 177.3 97.55 190.07 97.55C203.08 97.55 212.24 88.63 212.24 76.34C212.24 63.8 203.08 54.89 190.07 54.89ZM190.14 85.16C184.79 85.16 180.95 81.42 180.95 76.27C180.95 71.02 184.79 67.28 190.14 67.28C195.59 67.28 199.43 71.02 199.43 76.27C199.43 81.42 195.59 85.16 190.14 85.16Z" fill="#002E42"/>
|
||||
<path d="M386 55.15C373.23 55.15 364.07 64.06 364.07 76.6C364.07 88.89 373.23 97.81 386 97.81C399.01 97.81 408.17 88.89 408.17 76.6C408.18 64.06 399.02 55.15 386 55.15ZM386.07 85.42C380.72 85.42 376.88 81.68 376.88 76.53C376.88 71.28 380.72 67.54 386.07 67.54C391.52 67.54 395.36 71.28 395.36 76.53C395.36 81.68 391.53 85.42 386.07 85.42Z" fill="#002E42"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M306.48 96.49H267.49V86.45L289.43 65.9H278.75H278.1H270.15V55.95H306.07V65.9L284.29 86.45L295.96 86.53V81.56H306.48V96.49Z" fill="#002E42"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 102.13L58.97 136.18L117.94 102.13V34.04L58.97 0L2.95 32.35C1.82 33 1.12 34.22 1.12 35.52C1.12 36.82 1.82 38.04 2.95 38.69L56.42 69.56V96.53L0 63.96V102.13ZM58.96 130.29L5.09 99.19V72.78L29.144 86.6631L29.14 86.67L58.77 104L89.86 86.06V50.17L58.78 32.23L30.3702 48.6309L7.64 35.51L58.97 5.88L112.83 36.99V99.19L58.96 130.29ZM35.4647 51.5717L58.97 65.14L82.2835 51.6749L58.78 38.11L35.4647 51.5717ZM84.77 56.1216L61.52 69.55V96.5348L84.77 83.12V56.1216Z" fill="#053447"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<path d="M0 0H408.18V136.18H0V0Z" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.3 KiB |
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 819 158.97"><defs><style>.cls-1{fill:#00ac69;}.cls-2{fill:#1ce783;}.cls-3{fill:#1d252c;}</style></defs><polygon class="cls-1" points="111.19 55.03 111.19 103.94 68.84 128.4 68.84 158.97 137.68 119.23 137.68 39.74 111.19 55.03"/><polygon class="cls-2" points="68.84 30.58 111.19 55.03 137.68 39.74 137.68 39.74 68.84 0 0 39.74 0 39.74 26.48 55.03 68.84 30.58"/><polygon class="cls-3" points="42.36 94.78 42.36 143.69 68.84 158.97 68.84 79.49 0 39.74 0 70.32 42.36 94.78"/><path class="cls-3" d="M242.17,50.14c-14.82,0-21.84,9.36-21.84,9.36h-.78L218,51.7H200.05v79.56h19.5v-46c0-10.14,7-17.16,17.16-17.16s17.16,7,17.16,17.16v46h19.5V83.68C273.37,63.4,260.11,50.14,242.17,50.14Z" transform="translate(-10 -10.51)"/><polygon class="cls-3" points="442.47 93.45 441.35 93.45 428.09 41.19 408.25 41.19 394.99 93.45 393.88 93.45 380.61 41.19 360.33 41.19 380.61 120.75 404.36 120.75 417.61 69.27 418.73 69.27 431.99 120.75 455.73 120.75 476.01 41.19 455.73 41.19 442.47 93.45"/><path class="cls-3" d="M545.72,58.72h-.78l-1.56-7H527v79.55h19.5v-46c0-10.14,4.68-14.82,14.82-14.82h10V51.71h-11.6A17.56,17.56,0,0,0,545.72,58.72Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M614.47,50.14c-23.39,0-40.55,17.16-40.55,41.34s16.19,41.34,40.55,41.34c19.73,0,31.61-11.61,36.56-20.15l-17.9-6.38c-1.77,3.24-8.91,9.47-18.66,9.47-11.37,0-19.49-7.12-21.05-18h59.27a35.38,35.38,0,0,0,.78-7.8C653.47,67.3,636.31,50.14,614.47,50.14ZM593.42,84.46c2.34-10.14,9.36-17.94,21.05-17.94,10.93,0,17.94,7.8,19.5,17.94Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M326.4,50.14c-23.4,0-40.56,17.16-40.56,41.34S302,132.82,326.4,132.82c19.73,0,31.6-11.61,36.55-20.15l-17.9-6.38c-1.77,3.24-8.9,9.47-18.65,9.47-11.37,0-19.5-7.12-21.06-18h59.28a35.38,35.38,0,0,0,.78-7.8C365.4,67.3,348.24,50.14,326.4,50.14ZM305.34,84.46c2.34-10.14,9.36-17.94,21.06-17.94,10.92,0,17.94,7.8,19.5,17.94Z" transform="translate(-10 -10.51)"/><rect class="cls-3" x="692.14" y="9.78" width="19.5" height="19.5"/><path class="cls-3" d="M775.45,114.88c-11.7,0-21.06-9.36-21.06-23.4s9.36-23.4,21.06-23.4,16.38,7.8,17.94,12.48l17.66-6.28c-4.28-11.11-14.78-24.14-35.6-24.14-23.4,0-40.56,17.16-40.56,41.34s17.16,41.34,40.56,41.34c21,0,31.5-13.24,35.7-24.88l-17.76-6.32C791.83,107.08,787.15,114.88,775.45,114.88Z" transform="translate(-10 -10.51)"/><polygon class="cls-3" points="645.63 27.11 656.7 27.11 656.7 120.75 676.2 120.75 676.2 9.78 645.63 9.78 645.63 27.11"/><rect class="cls-3" x="692.14" y="41.19" width="19.5" height="79.56"/><path class="cls-3" d="M821.59,116a7.52,7.52,0,1,0,7.41,7.52A7.28,7.28,0,0,0,821.59,116Zm0,13.89a6.37,6.37,0,1,1,6.26-6.37A6.12,6.12,0,0,1,821.59,129.85Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M824.82,122.13a2.64,2.64,0,0,0-2.82-2.62h-3.34v7.84h1.15v-2.72h1.05l2.71,2.72H825l-2.71-2.72A2.53,2.53,0,0,0,824.82,122.13Zm-5,1.35v-2.82H822a1.5,1.5,0,0,1,1.68,1.47c0,.83-.53,1.35-1.68,1.35Z" transform="translate(-10 -10.51)"/></svg>
|
After Width: | Height: | Size: 3 KiB |
17
apps/monitoring/src/datadog-urls.ts
Normal file
17
apps/monitoring/src/datadog-urls.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { DatadogSite } from "../generated/graphql";
|
||||
|
||||
export const API_KEYS_LINKS: { [key in DatadogSite]: string } = {
|
||||
[DatadogSite.Us1]: "https://app.datadoghq.com/organization-settings/api-keys",
|
||||
[DatadogSite.Us3]: "https://us3.datadoghq.com/organization-settings/api-keys",
|
||||
[DatadogSite.Us5]: "https://us5.datadoghq.com/organization-settings/api-keys",
|
||||
[DatadogSite.Eu1]: "https://app.datadoghq.eu/organization-settings/api-keys",
|
||||
[DatadogSite.Us1Fed]: "https://app.ddog-gov.com/organization-settings/api-keys",
|
||||
};
|
||||
|
||||
export const DATADOG_SITES_LINKS: { [key in DatadogSite]: string } = {
|
||||
[DatadogSite.Us1]: "datadoghq.com",
|
||||
[DatadogSite.Us3]: "us3.datadoghq.com",
|
||||
[DatadogSite.Us5]: "us5.datadoghq.com",
|
||||
[DatadogSite.Eu1]: "datadoghq.eu",
|
||||
[DatadogSite.Us1Fed]: "ddog-gov.com",
|
||||
};
|
18
apps/monitoring/src/graphql-provider.tsx
Normal file
18
apps/monitoring/src/graphql-provider.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Provider } from "urql";
|
||||
import { createClient } from "./lib/create-graphq-client";
|
||||
|
||||
function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
const client = createClient(
|
||||
`/graphql`,
|
||||
async () => Promise.resolve({ token: appBridgeState?.token! }),
|
||||
() => appBridgeState?.saleorApiUrl!
|
||||
);
|
||||
|
||||
return <Provider value={client} {...props} />;
|
||||
}
|
||||
|
||||
export default GraphQLProvider;
|
53
apps/monitoring/src/lib/create-graphq-client.ts
Normal file
53
apps/monitoring/src/lib/create-graphq-client.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||
import {
|
||||
cacheExchange,
|
||||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (
|
||||
url: string,
|
||||
getAuth: AuthConfig<IAuthState>["getAuth"],
|
||||
getSaleorApiUrl: () => string
|
||||
) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
if (!authState || !authState?.token) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === "function"
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
|
||||
return {
|
||||
...operation,
|
||||
context: {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
"Authorization-Bearer": authState.token,
|
||||
"Saleor-Api-Url": getSaleorApiUrl(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
7
apps/monitoring/src/lib/is-in-iframe.ts
Normal file
7
apps/monitoring/src/lib/is-in-iframe.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function isInIframe() {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
19
apps/monitoring/src/lib/no-ssr-wrapper.tsx
Normal file
19
apps/monitoring/src/lib/no-ssr-wrapper.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||
|
||||
/**
|
||||
* Saleor App can be rendered only as a Saleor Dashboard iframe.
|
||||
* All content is rendered after Dashboard exchanges auth with the app.
|
||||
* Hence, there is no reason to render app server side.
|
||||
*
|
||||
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
|
||||
* of using dynamic() calls
|
||||
*
|
||||
* You can use this wrapper selectively for some pages or remove it completely.
|
||||
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
|
||||
*/
|
||||
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||
ssr: false,
|
||||
});
|
48
apps/monitoring/src/lib/theme-synchronizer.test.tsx
Normal file
48
apps/monitoring/src/lib/theme-synchronizer.test.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { ThemeSynchronizer } from "./theme-synchronizer";
|
||||
|
||||
const appBridgeState: AppBridgeState = {
|
||||
ready: true,
|
||||
token: "token",
|
||||
domain: "some-domain.saleor.cloud",
|
||||
theme: "dark",
|
||||
path: "/",
|
||||
locale: "en",
|
||||
id: "app-id",
|
||||
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
|
||||
};
|
||||
|
||||
const mockThemeChange = vi.fn();
|
||||
|
||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
||||
return {
|
||||
useAppBridge() {
|
||||
return {
|
||||
appBridgeState: appBridgeState,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@saleor/macaw-ui", () => {
|
||||
return {
|
||||
useTheme() {
|
||||
return {
|
||||
setTheme: mockThemeChange,
|
||||
themeType: "light",
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("ThemeSynchronizer", () => {
|
||||
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
|
||||
render(<ThemeSynchronizer />);
|
||||
|
||||
return waitFor(() => {
|
||||
expect(mockThemeChange).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
});
|
||||
});
|
33
apps/monitoring/src/lib/theme-synchronizer.tsx
Normal file
33
apps/monitoring/src/lib/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||
* Macaw must be informed about this change from AppBridge.
|
||||
*
|
||||
* If you are not using Macaw, you can remove this.
|
||||
*/
|
||||
function _ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme, themeType } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeType !== appBridgeState?.theme) {
|
||||
setTheme(appBridgeState.theme);
|
||||
/**
|
||||
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
|
||||
* TODO Fix me when Macaw 2.0 is shipped
|
||||
*/
|
||||
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
30
apps/monitoring/src/lib/use-dashboard-notifications.ts
Normal file
30
apps/monitoring/src/lib/use-dashboard-notifications.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
export const useDashboardNotifications = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const showSuccessNotification = (title: string, text: string) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: title,
|
||||
text: text,
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const showErrorNotification = (title: string, text: string) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: title,
|
||||
text: text,
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
showSuccessNotification,
|
||||
showErrorNotification,
|
||||
};
|
||||
};
|
92
apps/monitoring/src/pages/_app.tsx
Normal file
92
apps/monitoring/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import "../styles/globals.css";
|
||||
|
||||
import { Theme } from "@material-ui/core/styles";
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import {
|
||||
dark,
|
||||
light,
|
||||
SaleorThemeColors,
|
||||
ThemeProvider as MacawUIThemeProvider,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React, { PropsWithChildren, useEffect } from "react";
|
||||
import { AppProps } from "next/app";
|
||||
|
||||
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||
import GraphQLProvider from "../graphql-provider";
|
||||
|
||||
const themeOverrides: Partial<Theme> = {
|
||||
/**
|
||||
* You can override MacawUI theme here
|
||||
*/
|
||||
};
|
||||
|
||||
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
|
||||
|
||||
/**
|
||||
* Temporary override of colors, to match new dashboard palette.
|
||||
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
|
||||
*/
|
||||
const palettes: PalettesOverride = {
|
||||
light: {
|
||||
...light,
|
||||
background: {
|
||||
default: "#fff",
|
||||
paper: "#fff",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
...dark,
|
||||
background: {
|
||||
default: "hsla(211, 42%, 14%, 1)",
|
||||
paper: "hsla(211, 42%, 14%, 1)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
const appBridgeInstance =
|
||||
typeof window !== "undefined"
|
||||
? new AppBridge({
|
||||
initialTheme: "light",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* That's a hack required by Macaw-UI incompatibility with React@18
|
||||
*/
|
||||
const ThemeProvider = MacawUIThemeProvider as React.FC<
|
||||
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
|
||||
>;
|
||||
|
||||
function NextApp({ Component, pageProps }: AppProps) {
|
||||
/**
|
||||
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
if (jssStyles) {
|
||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<GraphQLProvider>
|
||||
<ThemeProvider overrides={themeOverrides} ssr palettes={palettes}>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</GraphQLProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default NextApp;
|
116
apps/monitoring/src/pages/configuration/[[...path]].tsx
Normal file
116
apps/monitoring/src/pages/configuration/[[...path]].tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { NextPage } from "next";
|
||||
import { AppColumnsLayout } from "../../ui/app-columns-layout";
|
||||
import React, { useEffect } from "react";
|
||||
import { IntegrationsList } from "../../ui/providers-list";
|
||||
import { NoProvidersConfigured } from "../../ui/no-providers-configured";
|
||||
import { AppMainBar } from "../../ui/app-main-bar";
|
||||
import { useRouter } from "next/router";
|
||||
import { DatadogConfig } from "../../ui/datadog/datadog-config";
|
||||
import { DatadogSite, useConfigQuery } from "../../../generated/graphql";
|
||||
import { LinearProgress, Link, Typography } from "@material-ui/core";
|
||||
import { Section } from "../../ui/sections";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Done, Error } from "@material-ui/icons";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { DATADOG_SITES_LINKS } from "../../datadog-urls";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
wrapper: {},
|
||||
}));
|
||||
|
||||
const useActiveProvider = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const selectedProvider = router.query?.path && router.query.path[0];
|
||||
|
||||
return selectedProvider ?? null;
|
||||
};
|
||||
|
||||
const Content = () => {
|
||||
const [configuration, fetchConfiguration] = useConfigQuery();
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const datadogCredentials = configuration.data?.integrations.datadog?.credentials;
|
||||
const datadogError = configuration.data?.integrations.datadog?.error;
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfiguration();
|
||||
}, [fetchConfiguration]);
|
||||
|
||||
const selectedProvider = useActiveProvider();
|
||||
|
||||
if (configuration.fetching && !configuration.data) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
if (selectedProvider === "datadog") {
|
||||
return <DatadogConfig />;
|
||||
}
|
||||
|
||||
if (!configuration.data?.integrations.datadog) {
|
||||
return <NoProvidersConfigured />;
|
||||
}
|
||||
|
||||
// when configured and everything is fine
|
||||
if (datadogCredentials && !datadogError) {
|
||||
const site = configuration.data?.integrations.datadog?.credentials.site ?? DatadogSite.Us1;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Typography paragraph variant="h3">
|
||||
<Done style={{ verticalAlign: "middle", marginRight: 10 }} />
|
||||
App configured
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Visit{" "}
|
||||
<Link
|
||||
href="https://app.datadoghq.com/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: DATADOG_SITES_LINKS[site],
|
||||
newContext: true,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Datadog
|
||||
</Link>{" "}
|
||||
to access your logs
|
||||
</Typography>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (datadogError) {
|
||||
return (
|
||||
<Section>
|
||||
<Typography paragraph variant="h3">
|
||||
<Error style={{ verticalAlign: "middle", marginRight: 10 }} />
|
||||
Configuration Error
|
||||
</Typography>
|
||||
<Typography>{datadogError}</Typography>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ConfigurationPage: NextPage = () => {
|
||||
const styles = useStyles();
|
||||
const selectedProvider = useActiveProvider();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<AppMainBar />
|
||||
<AppColumnsLayout>
|
||||
<IntegrationsList activeProvider={selectedProvider} />
|
||||
<Content />
|
||||
</AppColumnsLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationPage;
|
34
apps/monitoring/src/pages/index.tsx
Normal file
34
apps/monitoring/src/pages/index.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { NextPage } from "next";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { MouseEventHandler, useEffect, useState } from "react";
|
||||
import { LinearProgress, Link } from "@material-ui/core";
|
||||
import { isInIframe } from "../lib/is-in-iframe";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
/**
|
||||
* This is page publicly accessible from your app.
|
||||
* You should probably remove it.
|
||||
*/
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
const { replace } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (appBridgeState?.ready) {
|
||||
replace("/configuration");
|
||||
}
|
||||
}, [appBridgeState?.ready, replace]);
|
||||
|
||||
if (isInIframe()) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Saleor Monitoring</h1>
|
||||
<p>Install App in Saleor to use it</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
BIN
apps/monitoring/src/public/favicon.ico
Normal file
BIN
apps/monitoring/src/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
apps/monitoring/src/public/vercel.svg
Normal file
4
apps/monitoring/src/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
27
apps/monitoring/src/saleor-app.ts
Normal file
27
apps/monitoring/src/saleor-app.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
import { APL, FileAPL, UpstashAPL, VercelAPL } from "@saleor/app-sdk/APL";
|
||||
|
||||
/**
|
||||
* By default auth data are stored in the `.auth-data.json` (FileAPL).
|
||||
* For multi-tenant applications and deployments please use UpstashAPL.
|
||||
*
|
||||
* To read more about storing auth data, read the
|
||||
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
||||
*/
|
||||
|
||||
export let apl: APL;
|
||||
switch (process.env.APL) {
|
||||
case "vercel":
|
||||
apl = new VercelAPL();
|
||||
break;
|
||||
case "upstash":
|
||||
// Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
|
||||
apl = new UpstashAPL();
|
||||
break;
|
||||
default:
|
||||
apl = new FileAPL();
|
||||
}
|
||||
|
||||
export const saleorApp = new SaleorApp({
|
||||
apl,
|
||||
});
|
6
apps/monitoring/src/setup-tests.ts
Normal file
6
apps/monitoring/src/setup-tests.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Add test setup logic here
|
||||
*
|
||||
* https://vitest.dev/config/#setupfiles
|
||||
*/
|
||||
export {}
|
13
apps/monitoring/src/styles/globals.css
Normal file
13
apps/monitoring/src/styles/globals.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 0.75rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
21
apps/monitoring/src/ui/app-columns-layout.tsx
Normal file
21
apps/monitoring/src/ui/app-columns-layout.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px auto 280px",
|
||||
alignItems: "start",
|
||||
gap: 32,
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
},
|
||||
});
|
||||
|
||||
type Props = PropsWithChildren<{}>;
|
||||
|
||||
export function AppColumnsLayout({ children }: Props) {
|
||||
const styles = useStyles();
|
||||
|
||||
return <div className={styles.root}>{children}</div>;
|
||||
}
|
28
apps/monitoring/src/ui/app-icon.tsx
Normal file
28
apps/monitoring/src/ui/app-icon.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Typography } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
appIconContainer: {
|
||||
background: "rgb(58, 86, 199)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: "50%",
|
||||
color: "#fff",
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
export function AppIcon() {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.appIconContainer}>
|
||||
<div>
|
||||
<Typography variant="h2">M</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
56
apps/monitoring/src/ui/app-main-bar.tsx
Normal file
56
apps/monitoring/src/ui/app-main-bar.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { MainBar } from "./main-bar";
|
||||
import { AppIcon } from "./app-icon";
|
||||
import React from "react";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import { GitHub, OfflineBoltOutlined } from "@material-ui/icons";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
buttonsGrid: { display: "flex", gap: 10 },
|
||||
});
|
||||
|
||||
export const AppMainBar = () => {
|
||||
const styles = useStyles();
|
||||
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: url,
|
||||
newContext: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainBar
|
||||
name="Monitoring"
|
||||
author="By Saleor Commerce"
|
||||
icon={<AppIcon />}
|
||||
bottomMargin
|
||||
rightColumnContent={
|
||||
<div className={styles.buttonsGrid}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
startIcon={<GitHub />}
|
||||
onClick={() => {
|
||||
openInNewTab("https://github.com/saleor/apps");
|
||||
}}
|
||||
>
|
||||
Repository
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<OfflineBoltOutlined />}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
openInNewTab("https://github.com/saleor/apps/discussions");
|
||||
}}
|
||||
>
|
||||
Request a feature
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
293
apps/monitoring/src/ui/datadog/datadog-config.tsx
Normal file
293
apps/monitoring/src/ui/datadog/datadog-config.tsx
Normal file
|
@ -0,0 +1,293 @@
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
DataDogCredentialsInput,
|
||||
DatadogSite,
|
||||
useConfigQuery,
|
||||
Mutation,
|
||||
useUpdateCredentialsMutation,
|
||||
useDeleteDatadogCredentialsMutation,
|
||||
} from "../../../generated/graphql";
|
||||
import { Section } from "../sections";
|
||||
import {
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Link,
|
||||
} from "@material-ui/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, makeStyles, Backlink, IconButton } from "@saleor/macaw-ui";
|
||||
import Image from "next/image";
|
||||
import DatadogLogo from "../../assets/datadog/dd_logo_h_rgb.svg";
|
||||
import { gql, useMutation } from "urql";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { ArrowBack } from "@material-ui/icons";
|
||||
import { useRouter } from "next/router";
|
||||
import { useDashboardNotifications } from "../../lib/use-dashboard-notifications";
|
||||
import { API_KEYS_LINKS } from "../../datadog-urls";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
form: {
|
||||
marginTop: 50,
|
||||
display: "grid",
|
||||
gridAutoFlow: "row",
|
||||
gap: 30,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
headline: {
|
||||
marginRight: "auto",
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
|
||||
gql`
|
||||
query Config {
|
||||
integrations {
|
||||
datadog {
|
||||
error
|
||||
active
|
||||
credentials {
|
||||
apiKeyLast4
|
||||
site
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
gql`
|
||||
mutation UpdateCredentials($input: DatadogConfigInput!) {
|
||||
updateDatadogConfig(input: $input) {
|
||||
datadog {
|
||||
error
|
||||
active
|
||||
credentials {
|
||||
apiKeyLast4
|
||||
site
|
||||
}
|
||||
}
|
||||
errors {
|
||||
message
|
||||
field
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
gql`
|
||||
mutation DeleteDatadogCredentials {
|
||||
deleteDatadogConfig {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
datadog {
|
||||
credentials {
|
||||
site
|
||||
apiKeyLast4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const buildMaskedKey = (keyLastChars: string) => `************${keyLastChars}`;
|
||||
|
||||
const ApiKeyHelperText = ({ site }: { site: DatadogSite }) => {
|
||||
const url = API_KEYS_LINKS[site];
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return (
|
||||
<span>
|
||||
Get one{" "}
|
||||
<Link
|
||||
href={url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: url,
|
||||
newContext: true,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const DatadogConfig = () => {
|
||||
const styles = useStyles();
|
||||
const [queryData, fetchConfig] = useConfigQuery();
|
||||
const [, mutateCredentials] = useUpdateCredentialsMutation();
|
||||
const [, deleteCredentials] = useDeleteDatadogCredentialsMutation();
|
||||
const router = useRouter();
|
||||
const { showSuccessNotification, showErrorNotification } = useDashboardNotifications();
|
||||
|
||||
const { register, handleSubmit, setValue, control, reset, watch } = useForm<
|
||||
DataDogCredentialsInput & {
|
||||
active: boolean;
|
||||
}
|
||||
>({
|
||||
defaultValues: {
|
||||
site: DatadogSite.Us1,
|
||||
apiKey: "",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const activeSite = watch("site");
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const updateValuesToCurrentConfig = () => {
|
||||
const datadogConfig = queryData.data?.integrations.datadog;
|
||||
|
||||
if (!datadogConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("active", datadogConfig.active);
|
||||
setValue("apiKey", buildMaskedKey(datadogConfig.credentials.apiKeyLast4));
|
||||
setValue("site", datadogConfig.credentials.site);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const datadogConfig = queryData.data?.integrations.datadog;
|
||||
|
||||
if (datadogConfig) {
|
||||
updateValuesToCurrentConfig();
|
||||
}
|
||||
}, [queryData.data, setValue]);
|
||||
|
||||
if (queryData.fetching && !queryData.data) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className={styles.header}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
router.push("/configuration");
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography className={styles.headline} variant="h3">
|
||||
Configuration
|
||||
</Typography>
|
||||
<Image width={100} src={DatadogLogo} alt="DataDog" />
|
||||
</div>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={handleSubmit((values) => {
|
||||
return mutateCredentials({
|
||||
input: {
|
||||
active: values.active,
|
||||
credentials: {
|
||||
apiKey: values.apiKey,
|
||||
site: values.site,
|
||||
},
|
||||
},
|
||||
}).then((res) => {
|
||||
const updatedConfig = res.data?.updateDatadogConfig.datadog;
|
||||
const errors = res.data?.updateDatadogConfig.errors;
|
||||
|
||||
if (updatedConfig) {
|
||||
setValue("active", updatedConfig.active);
|
||||
setValue("apiKey", buildMaskedKey(updatedConfig.credentials.apiKeyLast4));
|
||||
setValue("site", updatedConfig.credentials.site);
|
||||
|
||||
showSuccessNotification(
|
||||
"Configuration updated",
|
||||
"Successfully updated Datadog settings"
|
||||
);
|
||||
}
|
||||
|
||||
if (errors?.length) {
|
||||
showErrorNotification("Error configuring Datadog", errors[0].message);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Controller
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormGroup row>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={field.value} {...field} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}}
|
||||
name="active"
|
||||
control={control}
|
||||
/>
|
||||
<InputLabel id="datadog-site-label">Datadog Site</InputLabel>
|
||||
<Select
|
||||
defaultValue={DatadogSite.Us1}
|
||||
fullWidth
|
||||
labelId="datadog-site-label"
|
||||
{...register("site")}
|
||||
>
|
||||
{Object.values(DatadogSite).map((v) => (
|
||||
<MenuItem value={v} key={v}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="standard"
|
||||
label="Api Key"
|
||||
defaultValue=""
|
||||
helperText={<ApiKeyHelperText site={activeSite} />}
|
||||
{...register("apiKey")}
|
||||
/>
|
||||
{queryData.data?.integrations.datadog?.error && (
|
||||
<Typography color="error">{queryData.data?.integrations.datadog?.error}</Typography>
|
||||
)}
|
||||
<Button type="submit" variant="primary" fullWidth>
|
||||
Save configuration
|
||||
</Button>
|
||||
<Button
|
||||
type="reset"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
deleteCredentials({}).then(() => {
|
||||
fetchConfig();
|
||||
reset();
|
||||
showSuccessNotification(
|
||||
"Configuration updated",
|
||||
"Successfully deleted Datadog settings"
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete configuration
|
||||
</Button>
|
||||
</form>
|
||||
</Section>
|
||||
);
|
||||
};
|
69
apps/monitoring/src/ui/main-bar.tsx
Normal file
69
apps/monitoring/src/ui/main-bar.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Paper, PaperProps } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
height: 96,
|
||||
padding: "0 32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${theme.palette.grey.A100} `,
|
||||
},
|
||||
leftColumn: {
|
||||
marginRight: "auto",
|
||||
},
|
||||
rightColumn: {},
|
||||
iconColumn: {
|
||||
marginRight: 24,
|
||||
},
|
||||
appName: { fontSize: 24, margin: 0 },
|
||||
appAuthor: {
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
},
|
||||
bottomMargin: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
author: string;
|
||||
rightColumnContent?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
bottomMargin?: boolean;
|
||||
} & PaperProps;
|
||||
|
||||
export function MainBar({
|
||||
name,
|
||||
author,
|
||||
rightColumnContent,
|
||||
className,
|
||||
icon,
|
||||
bottomMargin,
|
||||
}: Props) {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
square
|
||||
elevation={0}
|
||||
className={clsx(styles.root, className, {
|
||||
[styles.bottomMargin]: bottomMargin,
|
||||
})}
|
||||
>
|
||||
{icon && <div className={styles.iconColumn}>{icon}</div>}
|
||||
<div className={styles.leftColumn}>
|
||||
<h1 className={styles.appName}>{name}</h1>
|
||||
<h1 className={styles.appAuthor}>{author}</h1>
|
||||
</div>
|
||||
<div className={styles.rightColumn}>{rightColumnContent}</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
13
apps/monitoring/src/ui/no-providers-configured.tsx
Normal file
13
apps/monitoring/src/ui/no-providers-configured.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Section } from "./sections";
|
||||
import { Typography } from "@material-ui/core";
|
||||
|
||||
export const NoProvidersConfigured = () => (
|
||||
<Section>
|
||||
<Typography paragraph variant="h3">
|
||||
No providers configured
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Chose one of providers on the left and configure it to use the app
|
||||
</Typography>
|
||||
</Section>
|
||||
);
|
99
apps/monitoring/src/ui/providers-list.tsx
Normal file
99
apps/monitoring/src/ui/providers-list.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import Image from "next/image";
|
||||
import DatadogLogo from "../assets/datadog/dd_logo_h_rgb.svg";
|
||||
import NewRelicLogo from "../assets/new-relic/new_relic_logo_horizontal.svg";
|
||||
import LogzLogo from "../assets/logzio/1584985593-blue-horizontal.svg";
|
||||
import React from "react";
|
||||
import { Section } from "./sections";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/router";
|
||||
import { Done, Error } from "@material-ui/icons";
|
||||
import { useConfigQuery } from "../../generated/graphql";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
item: {
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
marginBottom: 20,
|
||||
padding: "10px",
|
||||
justifyContent: "space-between",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
disabledItem: {
|
||||
filter: "grayscale(1)",
|
||||
opacity: 0.7,
|
||||
pointerEvents: "none",
|
||||
marginBottom: 20,
|
||||
padding: "10px",
|
||||
},
|
||||
selected: {
|
||||
border: `1px solid ${theme.palette.divider} !important`,
|
||||
borderRadius: 4,
|
||||
},
|
||||
list: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: "none",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
activeProvider: "datadog" | string | null;
|
||||
};
|
||||
|
||||
export const IntegrationsList = ({ activeProvider }: Props) => {
|
||||
const styles = useStyles();
|
||||
const router = useRouter();
|
||||
const [queryData] = useConfigQuery();
|
||||
|
||||
const isDatadogConfigured = queryData.data?.integrations.datadog?.credentials;
|
||||
const isDatadogError = queryData.data?.integrations.datadog?.error;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<ul className={styles.list}>
|
||||
<li
|
||||
onClick={() => {
|
||||
router.push("/configuration/datadog");
|
||||
}}
|
||||
className={clsx(styles.item, {
|
||||
[styles.selected]: activeProvider === "datadog",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Image alt="Datadog" width={100} src={DatadogLogo} />
|
||||
</div>
|
||||
{isDatadogConfigured && !isDatadogError && (
|
||||
<div>
|
||||
<Done color="secondary" />
|
||||
</div>
|
||||
)}
|
||||
{isDatadogError && (
|
||||
<div>
|
||||
<Error color="error" />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
<li className={styles.disabledItem}>
|
||||
<div>
|
||||
<Typography variant="caption">Coming Soon</Typography>
|
||||
</div>
|
||||
</li>
|
||||
<li className={styles.disabledItem}>
|
||||
<div>
|
||||
<Image alt="New Relic" width={100} src={NewRelicLogo} />
|
||||
</div>
|
||||
</li>
|
||||
<li className={styles.disabledItem}>
|
||||
<div>
|
||||
<Image alt="Logz.io" width={100} src={LogzLogo} />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
};
|
6
apps/monitoring/src/ui/sections.tsx
Normal file
6
apps/monitoring/src/ui/sections.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { Paper, PaperProps } from "@material-ui/core";
|
||||
import React from "react";
|
||||
|
||||
export const Section = (props: PaperProps) => (
|
||||
<Paper {...props} elevation={0} style={{ padding: 20 }} />
|
||||
);
|
20
apps/monitoring/tsconfig.json
Normal file
20
apps/monitoring/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
13
apps/monitoring/vitest.config.ts
Normal file
13
apps/monitoring/vitest.config.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setup-tests.ts",
|
||||
css: false,
|
||||
},
|
||||
});
|
102
pnpm-lock.yaml
102
pnpm-lock.yaml
|
@ -287,6 +287,87 @@ importers:
|
|||
pretty-quick: 3.1.3_prettier@2.8.3
|
||||
typescript: 4.9.5
|
||||
|
||||
apps/monitoring:
|
||||
specifiers:
|
||||
'@graphql-codegen/cli': 2.13.3
|
||||
'@graphql-codegen/introspection': 2.2.1
|
||||
'@graphql-codegen/typed-document-node': ^2.3.3
|
||||
'@graphql-codegen/typescript': 2.7.3
|
||||
'@graphql-codegen/typescript-operations': 2.5.3
|
||||
'@graphql-codegen/typescript-urql': ^3.7.0
|
||||
'@graphql-codegen/urql-introspection': 2.2.1
|
||||
'@graphql-typed-document-node/core': ^3.1.1
|
||||
'@material-ui/core': ^4.12.4
|
||||
'@material-ui/icons': ^4.11.3
|
||||
'@material-ui/lab': 4.0.0-alpha.61
|
||||
'@saleor/app-sdk': 0.27.1
|
||||
'@saleor/apps-shared': workspace:*
|
||||
'@saleor/macaw-ui': ^0.7.2
|
||||
'@testing-library/react': ^13.4.0
|
||||
'@testing-library/react-hooks': ^8.0.1
|
||||
'@types/node': ^18.11.18
|
||||
'@types/react': ^18.0.26
|
||||
'@types/react-dom': ^18.0.10
|
||||
'@urql/exchange-auth': ^1.0.0
|
||||
'@vitejs/plugin-react': ^3.0.1
|
||||
clsx: ^1.2.1
|
||||
eslint: 8.31.0
|
||||
eslint-config-next: 13.1.2
|
||||
eslint-config-prettier: ^8.6.0
|
||||
eslint-config-saleor: workspace:*
|
||||
graphql: ^16.6.0
|
||||
graphql-tag: ^2.12.6
|
||||
jsdom: ^20.0.3
|
||||
next: 13.1.2
|
||||
prettier: ^2.8.2
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0
|
||||
react-hook-form: ^7.42.1
|
||||
typescript: 4.9.4
|
||||
urql: ^3.0.3
|
||||
vite: ^4.0.4
|
||||
vitest: ^0.27.1
|
||||
dependencies:
|
||||
'@material-ui/core': 4.12.4_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
|
||||
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te
|
||||
'@saleor/app-sdk': 0.27.1_7jnwqgtpcnwg4nzft4b6xlzlfi
|
||||
'@saleor/apps-shared': link:../../packages/shared
|
||||
'@saleor/macaw-ui': 0.7.2_pmlnlm755hlzzzocw2qhf3a34e
|
||||
'@urql/exchange-auth': 1.0.0_graphql@16.6.0
|
||||
'@vitejs/plugin-react': 3.1.0_vite@4.1.1
|
||||
clsx: 1.2.1
|
||||
graphql: 16.6.0
|
||||
graphql-tag: 2.12.6_graphql@16.6.0
|
||||
jsdom: 20.0.3
|
||||
next: 13.1.2_biqbaboplfbrettd7655fr4n2y
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
react-hook-form: 7.43.1_react@18.2.0
|
||||
urql: 3.0.3_onqnqwb3ubg5opvemcqf7c2qhy
|
||||
vite: 4.1.1_@types+node@18.13.0
|
||||
vitest: 0.27.3_jsdom@20.0.3
|
||||
devDependencies:
|
||||
'@graphql-codegen/cli': 2.13.3_d3dx4krdt4fsynqrp5lqxelwe4
|
||||
'@graphql-codegen/introspection': 2.2.1_graphql@16.6.0
|
||||
'@graphql-codegen/typed-document-node': 2.3.13_graphql@16.6.0
|
||||
'@graphql-codegen/typescript': 2.7.3_graphql@16.6.0
|
||||
'@graphql-codegen/typescript-operations': 2.5.3_graphql@16.6.0
|
||||
'@graphql-codegen/typescript-urql': 3.7.3_sy4knu3obj4ys7pjcqbyfxmqle
|
||||
'@graphql-codegen/urql-introspection': 2.2.1_graphql@16.6.0
|
||||
'@graphql-typed-document-node/core': 3.1.1_graphql@16.6.0
|
||||
'@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
|
||||
'@testing-library/react-hooks': 8.0.1_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||
'@types/node': 18.13.0
|
||||
'@types/react': 18.0.27
|
||||
'@types/react-dom': 18.0.10
|
||||
eslint: 8.31.0
|
||||
eslint-config-next: 13.1.2_iukboom6ndih5an6iafl45j2fe
|
||||
eslint-config-prettier: 8.6.0_eslint@8.31.0
|
||||
eslint-config-saleor: link:../../packages/eslint-config-saleor
|
||||
prettier: 2.8.3
|
||||
typescript: 4.9.4
|
||||
|
||||
apps/products-feed:
|
||||
specifiers:
|
||||
'@graphql-codegen/cli': 2.13.3
|
||||
|
@ -3884,6 +3965,27 @@ packages:
|
|||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@saleor/app-sdk/0.27.1_7jnwqgtpcnwg4nzft4b6xlzlfi:
|
||||
resolution: {integrity: sha512-ZNbucokKCdBE1qa+YLHvjBVazYcRuUExBdaPW9aNxfeYyXgQNCdHqJx9oA/S1lMEVSbZSIRcn8Sx1+X/eEV8BA==}
|
||||
peerDependencies:
|
||||
next: '>=12'
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
fast-glob: 3.2.12
|
||||
graphql: 16.6.0
|
||||
jose: 4.11.4
|
||||
next: 13.1.2_biqbaboplfbrettd7655fr4n2y
|
||||
raw-body: 2.5.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
retes: 0.33.0
|
||||
uuid: 8.3.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@saleor/app-sdk/0.29.0_3vryta7zmbcsw4rrqf4axjqggm:
|
||||
resolution: {integrity: sha512-GWVLv1E8JPP8ieq9PQz5vfR5ZZ1Bo8RAkc5WaMHP/XxbPH7R8WwIGZJEkYD/kaLus3xk3DZKmA7tpzXtHRPwgw==}
|
||||
peerDependencies:
|
||||
|
|
19
turbo.json
19
turbo.json
|
@ -90,6 +90,25 @@
|
|||
"NEXT_PUBLIC_VERCEL_ENV"
|
||||
]
|
||||
},
|
||||
"build#saleor-app-monitoring": {
|
||||
"env": [
|
||||
"APL",
|
||||
"APP_DEBUG",
|
||||
"NODE_ENV",
|
||||
"SECRET_KEY",
|
||||
"ALLOWED_DOMAIN_PATTERN",
|
||||
"REST_APL_ENDPOINT",
|
||||
"REST_APL_TOKEN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_ORG",
|
||||
"SENTRY_PROJECT",
|
||||
"SENTRY_AUTH_TOKEN",
|
||||
"NEXT_PUBLIC_VERCEL_ENV",
|
||||
"MONITORING_APP_API_URL"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue