This commit is contained in:
parent
6e635c32e4
commit
d8d9a34db5
12 changed files with 1246 additions and 19 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
NEAR_INTENTS_API_KEY=replace_me
|
NEAR_INTENTS_API_KEY=replace_me
|
||||||
NEAR_INTENTS_WS_URL=wss://solver-relay-v2.chaindefuser.com/ws
|
NEAR_INTENTS_WS_URL=wss://solver-relay-v2.chaindefuser.com/ws
|
||||||
NEAR_INTENTS_PAIR_FILTER=nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
|
NEAR_INTENTS_PAIR_FILTER=nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
|
||||||
|
NEAR_INTENTS_CONTROL_API_ENABLED=true
|
||||||
|
NEAR_INTENTS_CONTROL_HOST=0.0.0.0
|
||||||
|
NEAR_INTENTS_CONTROL_PORT=8081
|
||||||
KAFKA_BROKERS=redpanda:9092
|
KAFKA_BROKERS=redpanda:9092
|
||||||
KAFKA_CLIENT_ID=unrip
|
KAFKA_CLIENT_ID=unrip
|
||||||
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE=raw.near_intents.quote
|
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE=raw.near_intents.quote
|
||||||
|
|
|
||||||
77
README.md
77
README.md
|
|
@ -108,3 +108,80 @@ KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout sta
|
||||||
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-executor
|
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-executor
|
||||||
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-consumer
|
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-consumer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Auxiliary ops scripts
|
||||||
|
|
||||||
|
These scripts default to the same adjacent platform checkout as the deployment
|
||||||
|
bootstrap: `../unrip3/.state/hetzner/kubeconfig.yaml`. Override with
|
||||||
|
`KUBECONFIG_PATH`, `KUBECONFIG`, or `PLATFORM_REPO_DIR` if needed.
|
||||||
|
|
||||||
|
`scripts/ops/deployment_status.py`
|
||||||
|
- Shows current deployment readiness, pod uptime, restart counts, and mounted storage usage.
|
||||||
|
- By default it shows the live deployment pods only.
|
||||||
|
- Use `--include-completed` to include completed Job pods.
|
||||||
|
- Use `--include-rootfs` if you also want a probe of `/` for pods without PVC-backed mounts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/ops/deployment_status.py
|
||||||
|
python3 scripts/ops/deployment_status.py --include-completed
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/ops/redpanda_storage.py`
|
||||||
|
- Shows how much data Redpanda is currently storing for the unrip topics.
|
||||||
|
- Prints per-topic local bytes, total bytes, segment counts, and the overall Redpanda data-path usage.
|
||||||
|
- Use `--all-topics` to inspect every visible topic, or `--topic` multiple times for a subset.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/ops/redpanda_storage.py
|
||||||
|
python3 scripts/ops/redpanda_storage.py --all-topics
|
||||||
|
python3 scripts/ops/redpanda_storage.py --topic raw.near_intents.quote --topic norm.swap_demand
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/ops/live_near_intents.py`
|
||||||
|
- Live-inspects the raw NEAR quote stream entering Redpanda.
|
||||||
|
- Defaults to the configured raw topic, `--offset end`, and an unbounded live tail.
|
||||||
|
- Use `--num` for a bounded sample, `--offset start` to read from the beginning, `--value-only` for payload-only output, or `--rpk-json` for the full record metadata emitted by `rpk`.
|
||||||
|
- Use `--timeout` when you want the script to stop automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/ops/live_near_intents.py
|
||||||
|
python3 scripts/ops/live_near_intents.py --num 10 --offset start
|
||||||
|
python3 scripts/ops/live_near_intents.py --value-only --timeout 30
|
||||||
|
python3 scripts/ops/live_near_intents.py --rpk-json --num 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Near Intents control API
|
||||||
|
|
||||||
|
`near-intents-ingest` exposes a small in-process control API on port `8081` by
|
||||||
|
default. It is meant for ad hoc inspection and runtime filter changes without a
|
||||||
|
redeploy.
|
||||||
|
|
||||||
|
Port-forward the deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip port-forward deploy/near-intents-ingest 8081:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect current state, including the active pair filter and ingest counters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8081/state
|
||||||
|
```
|
||||||
|
|
||||||
|
Set or disable the runtime pair filter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT http://127.0.0.1:8081/pair-filter \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"pair":"nep141:btc.omft.near->nep141:eth.omft.near"}'
|
||||||
|
|
||||||
|
curl -s -X PUT http://127.0.0.1:8081/pair-filter \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"pair":null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset the runtime filter back to the configured env/file/default state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8081/pair-filter/reset
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ metadata:
|
||||||
data:
|
data:
|
||||||
NEAR_INTENTS_WS_URL: wss://solver-relay-v2.chaindefuser.com/ws
|
NEAR_INTENTS_WS_URL: wss://solver-relay-v2.chaindefuser.com/ws
|
||||||
NEAR_INTENTS_PAIR_FILTER: nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
|
NEAR_INTENTS_PAIR_FILTER: nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
|
||||||
|
NEAR_INTENTS_CONTROL_API_ENABLED: "true"
|
||||||
|
NEAR_INTENTS_CONTROL_HOST: 0.0.0.0
|
||||||
|
NEAR_INTENTS_CONTROL_PORT: "8081"
|
||||||
KAFKA_BROKERS: redpanda.unrip.svc.cluster.local:9092
|
KAFKA_BROKERS: redpanda.unrip.svc.cluster.local:9092
|
||||||
KAFKA_CLIENT_ID: unrip
|
KAFKA_CLIENT_ID: unrip
|
||||||
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote
|
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote
|
||||||
|
|
@ -52,6 +55,9 @@ spec:
|
||||||
image: ghcr.io/example/unrip:bootstrap
|
image: ghcr.io/example/unrip:bootstrap
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["node", "src/apps/near-intents-ingest.mjs"]
|
command: ["node", "src/apps/near-intents-ingest.mjs"]
|
||||||
|
ports:
|
||||||
|
- name: control-api
|
||||||
|
containerPort: 8081
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: unrip-config
|
name: unrip-config
|
||||||
|
|
|
||||||
301
scripts/ops/common.py
Executable file
301
scripts/ops/common.py
Executable file
|
|
@ -0,0 +1,301 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
PLATFORM_REPO_DIR = Path(
|
||||||
|
os.environ.get("PLATFORM_REPO_DIR", str(ROOT_DIR / "../unrip3"))
|
||||||
|
).resolve()
|
||||||
|
DEFAULT_KUBECONFIG_PATH = PLATFORM_REPO_DIR / ".state/hetzner/kubeconfig.yaml"
|
||||||
|
|
||||||
|
DEFAULT_NAMESPACE = os.environ.get("PROJECT_NAMESPACE", "unrip")
|
||||||
|
DEFAULT_CONFIGMAP_NAME = os.environ.get("CONFIGMAP_NAME", "unrip-config")
|
||||||
|
DEFAULT_REDPANDA_DEPLOYMENT = os.environ.get("REDPANDA_DEPLOYMENT", "redpanda")
|
||||||
|
DEFAULT_REDPANDA_LABEL_SELECTOR = os.environ.get("REDPANDA_LABEL_SELECTOR", "app=redpanda")
|
||||||
|
DEFAULT_REDPANDA_DATA_PATH = "/var/lib/redpanda/data"
|
||||||
|
|
||||||
|
APP_TOPIC_KEYS = (
|
||||||
|
"KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE",
|
||||||
|
"KAFKA_TOPIC_NORM_SWAP_DEMAND",
|
||||||
|
"KAFKA_TOPIC_CMD_EXECUTE_TRADE",
|
||||||
|
"KAFKA_TOPIC_EXEC_TRADE_RESULT",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_kubeconfig_path() -> str:
|
||||||
|
return os.environ.get("KUBECONFIG_PATH") or os.environ.get(
|
||||||
|
"KUBECONFIG", str(DEFAULT_KUBECONFIG_PATH)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_env() -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
kubeconfig_path = resolve_kubeconfig_path()
|
||||||
|
if not Path(kubeconfig_path).exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"missing kubeconfig: {kubeconfig_path}\n"
|
||||||
|
"set KUBECONFIG_PATH, KUBECONFIG, or PLATFORM_REPO_DIR"
|
||||||
|
)
|
||||||
|
env["KUBECONFIG"] = kubeconfig_path
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
input_text: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
args,
|
||||||
|
env=build_env(),
|
||||||
|
text=True,
|
||||||
|
capture_output=capture_output,
|
||||||
|
input=input_text,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kubectl(
|
||||||
|
*args: str,
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
input_text: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return run(
|
||||||
|
["kubectl", *args],
|
||||||
|
capture_output=capture_output,
|
||||||
|
check=check,
|
||||||
|
input_text=input_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kubectl_json(*args: str) -> dict:
|
||||||
|
return json.loads(kubectl(*args).stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def config_value(key: str, namespace: str = DEFAULT_NAMESPACE, configmap_name: str = DEFAULT_CONFIGMAP_NAME) -> str:
|
||||||
|
return kubectl(
|
||||||
|
"get",
|
||||||
|
"configmap",
|
||||||
|
configmap_name,
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
"-o",
|
||||||
|
f"jsonpath={{.data.{key}}}",
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def app_topics(namespace: str = DEFAULT_NAMESPACE, configmap_name: str = DEFAULT_CONFIGMAP_NAME) -> list[str]:
|
||||||
|
topics: list[str] = []
|
||||||
|
for key in APP_TOPIC_KEYS:
|
||||||
|
value = config_value(key, namespace=namespace, configmap_name=configmap_name).strip()
|
||||||
|
if value:
|
||||||
|
topics.append(value)
|
||||||
|
return topics
|
||||||
|
|
||||||
|
|
||||||
|
def kafka_brokers(namespace: str = DEFAULT_NAMESPACE, configmap_name: str = DEFAULT_CONFIGMAP_NAME) -> str:
|
||||||
|
return config_value("KAFKA_BROKERS", namespace=namespace, configmap_name=configmap_name)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_near_topic(namespace: str = DEFAULT_NAMESPACE, configmap_name: str = DEFAULT_CONFIGMAP_NAME) -> str:
|
||||||
|
return config_value(
|
||||||
|
"KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE",
|
||||||
|
namespace=namespace,
|
||||||
|
configmap_name=configmap_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redpanda_pod_name(namespace: str = DEFAULT_NAMESPACE) -> str:
|
||||||
|
payload = kubectl_json(
|
||||||
|
"get",
|
||||||
|
"pods",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
"-l",
|
||||||
|
DEFAULT_REDPANDA_LABEL_SELECTOR,
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
)
|
||||||
|
items = payload.get("items", [])
|
||||||
|
if not items:
|
||||||
|
raise SystemExit(f"no Redpanda pod found in namespace {namespace}")
|
||||||
|
return items[0]["metadata"]["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def redpanda_exec(
|
||||||
|
*args: str,
|
||||||
|
namespace: str = DEFAULT_NAMESPACE,
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return kubectl(
|
||||||
|
"exec",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
f"deploy/{DEFAULT_REDPANDA_DEPLOYMENT}",
|
||||||
|
"--",
|
||||||
|
*args,
|
||||||
|
capture_output=capture_output,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pod_exec(
|
||||||
|
pod_name: str,
|
||||||
|
*args: str,
|
||||||
|
namespace: str = DEFAULT_NAMESPACE,
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return kubectl(
|
||||||
|
"exec",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
pod_name,
|
||||||
|
"--",
|
||||||
|
*args,
|
||||||
|
capture_output=capture_output,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_path_usage(
|
||||||
|
pod_name: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
namespace: str = DEFAULT_NAMESPACE,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
shell_path = shlex.quote(path)
|
||||||
|
probe = "\n".join(
|
||||||
|
(
|
||||||
|
f"df -B1 {shell_path} | awk 'NR==2 {{print $2, $3, $4, $5}}'",
|
||||||
|
f"du -sb {shell_path} 2>/dev/null | awk 'END {{print $1}}'",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = pod_exec(
|
||||||
|
pod_name,
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
probe,
|
||||||
|
namespace=namespace,
|
||||||
|
).stdout.strip().splitlines()
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError(f"no usage output for {pod_name}:{path}")
|
||||||
|
|
||||||
|
fs_fields = result[0].split()
|
||||||
|
if len(fs_fields) != 4:
|
||||||
|
raise RuntimeError(f"unexpected df output for {pod_name}:{path}: {result[0]}")
|
||||||
|
|
||||||
|
du_bytes = None
|
||||||
|
if len(result) > 1 and result[1].strip():
|
||||||
|
try:
|
||||||
|
du_bytes = int(result[1].strip())
|
||||||
|
except ValueError:
|
||||||
|
du_bytes = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filesystem_total_bytes": int(fs_fields[0]),
|
||||||
|
"filesystem_used_bytes": int(fs_fields[1]),
|
||||||
|
"filesystem_available_bytes": int(fs_fields[2]),
|
||||||
|
"filesystem_use_percent": fs_fields[3],
|
||||||
|
"path_bytes": du_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_k8s_timestamp(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def human_duration(seconds: float | int | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return "-"
|
||||||
|
seconds = int(max(0, seconds))
|
||||||
|
chunks = []
|
||||||
|
for unit_seconds, suffix in (
|
||||||
|
(86400, "d"),
|
||||||
|
(3600, "h"),
|
||||||
|
(60, "m"),
|
||||||
|
(1, "s"),
|
||||||
|
):
|
||||||
|
if seconds >= unit_seconds or (suffix == "s" and not chunks):
|
||||||
|
value, seconds = divmod(seconds, unit_seconds)
|
||||||
|
if value or suffix == "s":
|
||||||
|
chunks.append(f"{value}{suffix}")
|
||||||
|
if len(chunks) == 2:
|
||||||
|
break
|
||||||
|
return " ".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def human_bytes(value: int | float | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
value = float(value)
|
||||||
|
units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
||||||
|
unit_index = 0
|
||||||
|
while value >= 1024 and unit_index < len(units) - 1:
|
||||||
|
value /= 1024
|
||||||
|
unit_index += 1
|
||||||
|
if unit_index == 0:
|
||||||
|
return f"{int(value)} {units[unit_index]}"
|
||||||
|
return f"{value:.1f} {units[unit_index]}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_storage_quantity(value: str | None) -> int | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
binary_units = {
|
||||||
|
"Ki": 1024**1,
|
||||||
|
"Mi": 1024**2,
|
||||||
|
"Gi": 1024**3,
|
||||||
|
"Ti": 1024**4,
|
||||||
|
"Pi": 1024**5,
|
||||||
|
}
|
||||||
|
decimal_units = {
|
||||||
|
"K": 1000**1,
|
||||||
|
"M": 1000**2,
|
||||||
|
"G": 1000**3,
|
||||||
|
"T": 1000**4,
|
||||||
|
"P": 1000**5,
|
||||||
|
}
|
||||||
|
|
||||||
|
for suffix, factor in binary_units.items():
|
||||||
|
if value.endswith(suffix):
|
||||||
|
return int(float(value[: -len(suffix)]) * factor)
|
||||||
|
for suffix, factor in decimal_units.items():
|
||||||
|
if value.endswith(suffix):
|
||||||
|
return int(float(value[: -len(suffix)]) * factor)
|
||||||
|
return int(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
||||||
|
widths = [len(header) for header in headers]
|
||||||
|
for row in rows:
|
||||||
|
for index, cell in enumerate(row):
|
||||||
|
widths[index] = max(widths[index], len(str(cell)))
|
||||||
|
|
||||||
|
template = " ".join(f"{{:{width}}}" for width in widths)
|
||||||
|
print(template.format(*headers))
|
||||||
|
print(template.format(*["-" * width for width in widths]))
|
||||||
|
for row in rows:
|
||||||
|
print(template.format(*row))
|
||||||
|
|
||||||
|
|
||||||
|
def stderr(message: str) -> None:
|
||||||
|
print(message, file=sys.stderr)
|
||||||
184
scripts/ops/deployment_status.py
Executable file
184
scripts/ops/deployment_status.py
Executable file
|
|
@ -0,0 +1,184 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
DEFAULT_NAMESPACE,
|
||||||
|
human_bytes,
|
||||||
|
human_duration,
|
||||||
|
kubectl_json,
|
||||||
|
now_utc,
|
||||||
|
parse_k8s_timestamp,
|
||||||
|
parse_storage_quantity,
|
||||||
|
print_table,
|
||||||
|
probe_path_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Show current unrip deployment state, pod uptime, and mounted storage usage."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
default=DEFAULT_NAMESPACE,
|
||||||
|
help=f"Kubernetes namespace to inspect (default: {DEFAULT_NAMESPACE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-rootfs",
|
||||||
|
action="store_true",
|
||||||
|
help="Also probe '/' for pods that do not have PVC-backed mounts.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-completed",
|
||||||
|
action="store_true",
|
||||||
|
help="Include completed Job pods in the pod list.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def ready_string(pod: dict) -> str:
|
||||||
|
statuses = pod.get("status", {}).get("containerStatuses", [])
|
||||||
|
ready = sum(1 for status in statuses if status.get("ready"))
|
||||||
|
total = len(statuses)
|
||||||
|
return f"{ready}/{total}"
|
||||||
|
|
||||||
|
|
||||||
|
def restart_count(pod: dict) -> int:
|
||||||
|
return sum(status.get("restartCount", 0) for status in pod.get("status", {}).get("containerStatuses", []))
|
||||||
|
|
||||||
|
|
||||||
|
def pvc_mounts_for_pod(pod: dict) -> list[dict[str, str]]:
|
||||||
|
volumes = {
|
||||||
|
volume["name"]: volume.get("persistentVolumeClaim", {}).get("claimName")
|
||||||
|
for volume in pod.get("spec", {}).get("volumes", [])
|
||||||
|
}
|
||||||
|
mounts: list[dict[str, str]] = []
|
||||||
|
for container in pod.get("spec", {}).get("containers", []):
|
||||||
|
for mount in container.get("volumeMounts", []):
|
||||||
|
claim_name = volumes.get(mount["name"])
|
||||||
|
if claim_name:
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"container": container.get("name", "app"),
|
||||||
|
"claim_name": claim_name,
|
||||||
|
"mount_path": mount["mountPath"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
def is_completed_job_pod(pod: dict) -> bool:
|
||||||
|
owners = pod.get("metadata", {}).get("ownerReferences", [])
|
||||||
|
if not any(owner.get("kind") == "Job" for owner in owners):
|
||||||
|
return False
|
||||||
|
return pod.get("status", {}).get("phase") in {"Succeeded", "Failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
namespace = args.namespace
|
||||||
|
|
||||||
|
deployment_payload = kubectl_json("get", "deploy", "-n", namespace, "-o", "json")
|
||||||
|
pod_payload = kubectl_json("get", "pods", "-n", namespace, "-o", "json")
|
||||||
|
pvc_payload = kubectl_json("get", "pvc", "-n", namespace, "-o", "json")
|
||||||
|
|
||||||
|
pvc_requests = {
|
||||||
|
item["metadata"]["name"]: item.get("spec", {})
|
||||||
|
.get("resources", {})
|
||||||
|
.get("requests", {})
|
||||||
|
.get("storage", "")
|
||||||
|
for item in pvc_payload.get("items", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment_rows: list[list[str]] = []
|
||||||
|
for item in sorted(deployment_payload.get("items", []), key=lambda value: value["metadata"]["name"]):
|
||||||
|
container = item.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [{}])[0]
|
||||||
|
deployment_rows.append(
|
||||||
|
[
|
||||||
|
item["metadata"]["name"],
|
||||||
|
f"{item.get('status', {}).get('readyReplicas', 0)}/{item.get('spec', {}).get('replicas', 0)}",
|
||||||
|
str(item.get("status", {}).get("availableReplicas", 0)),
|
||||||
|
container.get("image", "-"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
pod_rows: list[list[str]] = []
|
||||||
|
storage_rows: list[list[str]] = []
|
||||||
|
|
||||||
|
for pod in sorted(pod_payload.get("items", []), key=lambda value: value["metadata"]["name"]):
|
||||||
|
if not args.include_completed and is_completed_job_pod(pod):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pod_name = pod["metadata"]["name"]
|
||||||
|
start_time = parse_k8s_timestamp(
|
||||||
|
pod.get("status", {}).get("startTime")
|
||||||
|
or pod.get("status", {}).get("containerStatuses", [{}])[0]
|
||||||
|
.get("state", {})
|
||||||
|
.get("running", {})
|
||||||
|
.get("startedAt")
|
||||||
|
)
|
||||||
|
uptime = human_duration((now_utc() - start_time).total_seconds() if start_time else None)
|
||||||
|
image = pod.get("spec", {}).get("containers", [{}])[0].get("image", "-")
|
||||||
|
pod_rows.append(
|
||||||
|
[
|
||||||
|
pod_name,
|
||||||
|
pod.get("status", {}).get("phase", "-"),
|
||||||
|
ready_string(pod),
|
||||||
|
str(restart_count(pod)),
|
||||||
|
uptime,
|
||||||
|
pod.get("spec", {}).get("nodeName", "-"),
|
||||||
|
image,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = pvc_mounts_for_pod(pod)
|
||||||
|
if not mounts and args.include_rootfs:
|
||||||
|
mounts = [{"claim_name": "-", "mount_path": "/", "container": "app"}]
|
||||||
|
|
||||||
|
if not mounts:
|
||||||
|
storage_rows.append([pod_name, "-", "no pvc mounts", "-", "-", "-", "-", "-"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
for mount in mounts:
|
||||||
|
usage = probe_path_usage(pod_name, mount["mount_path"], namespace=namespace)
|
||||||
|
requested_raw = pvc_requests.get(mount["claim_name"], "")
|
||||||
|
requested_bytes = parse_storage_quantity(requested_raw)
|
||||||
|
storage_rows.append(
|
||||||
|
[
|
||||||
|
pod_name,
|
||||||
|
mount["mount_path"],
|
||||||
|
mount["claim_name"],
|
||||||
|
human_bytes(requested_bytes) if requested_bytes is not None else (requested_raw or "-"),
|
||||||
|
human_bytes(usage["path_bytes"]),
|
||||||
|
human_bytes(usage["filesystem_used_bytes"]),
|
||||||
|
human_bytes(usage["filesystem_available_bytes"]),
|
||||||
|
str(usage["filesystem_use_percent"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Namespace: {namespace}")
|
||||||
|
print()
|
||||||
|
print("Deployments")
|
||||||
|
print_table(["NAME", "READY", "AVAILABLE", "IMAGE"], deployment_rows)
|
||||||
|
print()
|
||||||
|
print("Pods")
|
||||||
|
print_table(["POD", "PHASE", "READY", "RESTARTS", "UPTIME", "NODE", "IMAGE"], pod_rows)
|
||||||
|
print()
|
||||||
|
print("Storage")
|
||||||
|
print_table(
|
||||||
|
["POD", "PATH", "CLAIM", "REQUESTED", "PATH_BYTES", "FS_USED", "FS_AVAIL", "USE%"],
|
||||||
|
storage_rows,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print("Note: storage figures come from container-mounted paths. Pods without PVC-backed mounts report no dedicated storage.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
127
scripts/ops/live_near_intents.py
Executable file
127
scripts/ops/live_near_intents.py
Executable file
|
|
@ -0,0 +1,127 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from common import DEFAULT_NAMESPACE, build_env, kafka_brokers, raw_near_topic, stderr
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT_FORMAT = (
|
||||||
|
"%d{go[2006-01-02T15:04:05Z07:00]} topic=%t partition=%p offset=%o\n"
|
||||||
|
"%v\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Live-inspect raw NEAR intents arriving in Redpanda."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
default=DEFAULT_NAMESPACE,
|
||||||
|
help=f"Kubernetes namespace to inspect (default: {DEFAULT_NAMESPACE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--brokers",
|
||||||
|
default="",
|
||||||
|
help="Override Kafka brokers instead of reading them from unrip-config.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--topic",
|
||||||
|
default="",
|
||||||
|
help="Topic to consume. Defaults to KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE from unrip-config.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--offset",
|
||||||
|
default="end",
|
||||||
|
help="Kafka offset selector passed to rpk (default: end)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--num",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Number of records to read before exiting. 0 means unbounded (default: 0).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=0,
|
||||||
|
help="Stop after this many seconds even if the stream is still open.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--value-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Print only the record value without topic/partition/offset metadata.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rpk-json",
|
||||||
|
action="store_true",
|
||||||
|
help="Use rpk's built-in JSON record formatter instead of the default metadata+value output.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(args: argparse.Namespace, brokers: str, topic: str) -> list[str]:
|
||||||
|
command = [
|
||||||
|
"kubectl",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
"-n",
|
||||||
|
args.namespace,
|
||||||
|
"deploy/redpanda",
|
||||||
|
"--",
|
||||||
|
"rpk",
|
||||||
|
"topic",
|
||||||
|
"consume",
|
||||||
|
topic,
|
||||||
|
"--brokers",
|
||||||
|
brokers,
|
||||||
|
"--offset",
|
||||||
|
args.offset,
|
||||||
|
"--num",
|
||||||
|
str(args.num),
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.rpk_json:
|
||||||
|
command.extend(["--format", "json", "--pretty-print=false"])
|
||||||
|
elif args.value_only:
|
||||||
|
command.extend(["--format", "%v\n"])
|
||||||
|
else:
|
||||||
|
command.extend(["--format", DEFAULT_OUTPUT_FORMAT])
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
brokers = args.brokers or kafka_brokers(namespace=args.namespace)
|
||||||
|
topic = args.topic or raw_near_topic(namespace=args.namespace)
|
||||||
|
command = build_command(args, brokers, topic)
|
||||||
|
|
||||||
|
stderr(f"namespace={args.namespace} brokers={brokers} topic={topic} offset={args.offset} num={args.num}")
|
||||||
|
|
||||||
|
process = subprocess.Popen(command, env=build_env())
|
||||||
|
try:
|
||||||
|
process.wait(timeout=args.timeout if args.timeout and args.timeout > 0 else None)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.send_signal(signal.SIGINT)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
return 124
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
process.send_signal(signal.SIGINT)
|
||||||
|
return process.wait()
|
||||||
|
return process.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
196
scripts/ops/redpanda_storage.py
Executable file
196
scripts/ops/redpanda_storage.py
Executable file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
DEFAULT_NAMESPACE,
|
||||||
|
DEFAULT_REDPANDA_DATA_PATH,
|
||||||
|
app_topics,
|
||||||
|
human_bytes,
|
||||||
|
kafka_brokers,
|
||||||
|
print_table,
|
||||||
|
redpanda_exec,
|
||||||
|
redpanda_pod_name,
|
||||||
|
probe_path_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Show how much data Redpanda is currently storing for unrip topics."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
default=DEFAULT_NAMESPACE,
|
||||||
|
help=f"Kubernetes namespace to inspect (default: {DEFAULT_NAMESPACE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--brokers",
|
||||||
|
default="",
|
||||||
|
help="Override Kafka brokers instead of reading them from unrip-config.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--topic",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Specific topic to inspect. Can be passed multiple times.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all-topics",
|
||||||
|
action="store_true",
|
||||||
|
help="Inspect every topic visible to Redpanda instead of the app topics from config.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def list_topics(namespace: str, brokers: str) -> list[str]:
|
||||||
|
output = redpanda_exec(
|
||||||
|
"rpk",
|
||||||
|
"topic",
|
||||||
|
"list",
|
||||||
|
"--brokers",
|
||||||
|
brokers,
|
||||||
|
namespace=namespace,
|
||||||
|
).stdout.splitlines()
|
||||||
|
topics: list[str] = []
|
||||||
|
for line in output[1:]:
|
||||||
|
fields = line.split()
|
||||||
|
if fields:
|
||||||
|
topics.append(fields[0])
|
||||||
|
return topics
|
||||||
|
|
||||||
|
|
||||||
|
def parse_storage(output: str) -> dict[str, int | str]:
|
||||||
|
summary: dict[str, str] = {}
|
||||||
|
size_rows: list[dict[str, int]] = []
|
||||||
|
section = ""
|
||||||
|
size_header_seen = False
|
||||||
|
|
||||||
|
for raw_line in output.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or set(line) == {"="}:
|
||||||
|
continue
|
||||||
|
if line == "SUMMARY":
|
||||||
|
section = "summary"
|
||||||
|
continue
|
||||||
|
if line == "SIZE":
|
||||||
|
section = "size"
|
||||||
|
size_header_seen = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if section == "summary":
|
||||||
|
fields = re.split(r"\s{2,}", line, maxsplit=1)
|
||||||
|
if len(fields) == 2:
|
||||||
|
summary[fields[0]] = fields[1]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if section == "size":
|
||||||
|
if not size_header_seen:
|
||||||
|
size_header_seen = True
|
||||||
|
continue
|
||||||
|
fields = re.split(r"\s+", line)
|
||||||
|
if len(fields) < 6:
|
||||||
|
continue
|
||||||
|
size_rows.append(
|
||||||
|
{
|
||||||
|
"partition": int(fields[0]),
|
||||||
|
"cloud_bytes": int(fields[1]),
|
||||||
|
"local_bytes": int(fields[2]),
|
||||||
|
"total_bytes": int(fields[3]),
|
||||||
|
"cloud_segments": int(fields[4]),
|
||||||
|
"local_segments": int(fields[5]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": summary.get("NAME", ""),
|
||||||
|
"partitions": int(summary.get("PARTITIONS", "0")),
|
||||||
|
"replicas": int(summary.get("REPLICAS", "0")),
|
||||||
|
"local_bytes": sum(row["local_bytes"] for row in size_rows),
|
||||||
|
"total_bytes": sum(row["total_bytes"] for row in size_rows),
|
||||||
|
"local_segments": sum(row["local_segments"] for row in size_rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
namespace = args.namespace
|
||||||
|
brokers = args.brokers or kafka_brokers(namespace=namespace)
|
||||||
|
|
||||||
|
if args.topic:
|
||||||
|
topics = args.topic
|
||||||
|
elif args.all_topics:
|
||||||
|
topics = list_topics(namespace, brokers)
|
||||||
|
else:
|
||||||
|
topics = app_topics(namespace=namespace)
|
||||||
|
|
||||||
|
if not topics:
|
||||||
|
raise SystemExit("no topics found")
|
||||||
|
|
||||||
|
topic_rows: list[list[str]] = []
|
||||||
|
total_local_bytes = 0
|
||||||
|
total_bytes = 0
|
||||||
|
total_segments = 0
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
output = redpanda_exec(
|
||||||
|
"rpk",
|
||||||
|
"topic",
|
||||||
|
"describe-storage",
|
||||||
|
topic,
|
||||||
|
"--brokers",
|
||||||
|
brokers,
|
||||||
|
namespace=namespace,
|
||||||
|
).stdout
|
||||||
|
parsed = parse_storage(output)
|
||||||
|
total_local_bytes += int(parsed["local_bytes"])
|
||||||
|
total_bytes += int(parsed["total_bytes"])
|
||||||
|
total_segments += int(parsed["local_segments"])
|
||||||
|
topic_rows.append(
|
||||||
|
[
|
||||||
|
topic,
|
||||||
|
str(parsed["partitions"]),
|
||||||
|
str(parsed["replicas"]),
|
||||||
|
human_bytes(int(parsed["local_bytes"])),
|
||||||
|
human_bytes(int(parsed["total_bytes"])),
|
||||||
|
str(parsed["local_segments"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
redpanda_pod = redpanda_pod_name(namespace=namespace)
|
||||||
|
usage = probe_path_usage(redpanda_pod, DEFAULT_REDPANDA_DATA_PATH, namespace=namespace)
|
||||||
|
|
||||||
|
print(f"Namespace: {namespace}")
|
||||||
|
print(f"Brokers: {brokers}")
|
||||||
|
print(f"Pod: {redpanda_pod}")
|
||||||
|
print(f"Data path: {DEFAULT_REDPANDA_DATA_PATH}")
|
||||||
|
print(
|
||||||
|
"Disk: "
|
||||||
|
f"path={human_bytes(usage['path_bytes'])}, "
|
||||||
|
f"fs_used={human_bytes(usage['filesystem_used_bytes'])}, "
|
||||||
|
f"fs_avail={human_bytes(usage['filesystem_available_bytes'])}, "
|
||||||
|
f"use={usage['filesystem_use_percent']}"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print("Topics")
|
||||||
|
print_table(
|
||||||
|
["TOPIC", "PARTITIONS", "REPLICAS", "LOCAL_BYTES", "TOTAL_BYTES", "LOCAL_SEGMENTS"],
|
||||||
|
topic_rows,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
f"Totals: local={human_bytes(total_local_bytes)}, "
|
||||||
|
f"total={human_bytes(total_bytes)}, "
|
||||||
|
f"segments={total_segments}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
import { createProducer } from '../bus/kafka/producer.mjs';
|
import { createProducer } from '../bus/kafka/producer.mjs';
|
||||||
|
import { startControlApi } from '../core/control-api.mjs';
|
||||||
import { createLogger } from '../core/log.mjs';
|
import { createLogger } from '../core/log.mjs';
|
||||||
import { createPairFilterController } from '../core/pair-filter.mjs';
|
import { createPairFilterController } from '../core/pair-filter.mjs';
|
||||||
import { loadConfig } from '../lib/config.mjs';
|
import { loadConfig } from '../lib/config.mjs';
|
||||||
|
|
@ -40,19 +41,7 @@ const producer = await createProducer({
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
const wsRuntime = await startNearIntentsWs({
|
||||||
pairFilterController.close();
|
|
||||||
await producer.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
pairFilterController.close();
|
|
||||||
await producer.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await startNearIntentsWs({
|
|
||||||
apiKey: config.nearIntentsApiKey,
|
apiKey: config.nearIntentsApiKey,
|
||||||
wsUrl: config.nearIntentsWsUrl,
|
wsUrl: config.nearIntentsWsUrl,
|
||||||
getPairFilter: () => pairFilterController.getPairFilter(),
|
getPairFilter: () => pairFilterController.getPairFilter(),
|
||||||
|
|
@ -65,3 +54,29 @@ await startNearIntentsWs({
|
||||||
venue: 'near-intents',
|
venue: 'near-intents',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const controlApi = config.nearIntentsControlApiEnabled
|
||||||
|
? startControlApi({
|
||||||
|
host: config.nearIntentsControlHost,
|
||||||
|
port: config.nearIntentsControlPort,
|
||||||
|
logger: logger.child({
|
||||||
|
component: 'control-api',
|
||||||
|
venue: 'near-intents',
|
||||||
|
}),
|
||||||
|
service: 'near-intents-ingest',
|
||||||
|
namespace: config.projectNamespace,
|
||||||
|
pairFilterController,
|
||||||
|
stateProvider: wsRuntime,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
controlApi && await controlApi.close().catch(() => {});
|
||||||
|
wsRuntime.close();
|
||||||
|
pairFilterController.close();
|
||||||
|
await producer.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
|
||||||
156
src/core/control-api.mjs
Normal file
156
src/core/control-api.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import http from 'node:http';
|
||||||
|
|
||||||
|
export function startControlApi({
|
||||||
|
host = '0.0.0.0',
|
||||||
|
port = 8081,
|
||||||
|
logger = null,
|
||||||
|
service = 'near-intents-ingest',
|
||||||
|
namespace = 'unrip',
|
||||||
|
pairFilterController,
|
||||||
|
stateProvider = null,
|
||||||
|
} = {}) {
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && req.url === '/healthz') {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/state') {
|
||||||
|
return sendJson(res, 200, buildStateResponse({
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
pairFilterController,
|
||||||
|
stateProvider,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/pair-filter') {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
...pairFilterController.getState(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'PUT' && req.url === '/pair-filter') {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
|
||||||
|
if (body.disabled === true || body.enabled === false || body.pair == null) {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
...pairFilterController.disable(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.pair !== 'string') {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: "send JSON like {\"pair\":\"asset_a->asset_b\"} or {\"pair\":null}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
...pairFilterController.setPairFilter(body.pair),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/pair-filter/reset') {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
...pairFilterController.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, {
|
||||||
|
error: 'not_found',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger?.error('control_api_request_failed', {
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
path: req.url,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sendJson(res, 500, {
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
logger?.info('control_api_started', {
|
||||||
|
details: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
close() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStateResponse({
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
pairFilterController,
|
||||||
|
stateProvider,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
namespace,
|
||||||
|
pair_filter: pairFilterController.getState(),
|
||||||
|
ingest: stateProvider?.getState?.() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, payload) {
|
||||||
|
const body = JSON.stringify(payload, null, 2);
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||||
|
res.end(`${body}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let raw = '';
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
if (raw.length > 64 * 1024) {
|
||||||
|
reject(new Error('request body too large'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!raw.trim()) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('invalid_json'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -74,14 +74,39 @@ export function createPairFilterController({
|
||||||
pairFilterFile = env.NEAR_INTENTS_PAIR_FILTER_FILE,
|
pairFilterFile = env.NEAR_INTENTS_PAIR_FILTER_FILE,
|
||||||
reloadEveryMs = env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS,
|
reloadEveryMs = env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const resolved = resolvePairFilter({ argv, env, defaultPairFilter });
|
function resolveConfiguredState() {
|
||||||
|
const nextResolved = resolvePairFilter({ argv, env, defaultPairFilter });
|
||||||
|
let nextPairFilter = nextResolved.pairFilter;
|
||||||
|
let nextPair = nextResolved.pair;
|
||||||
|
let nextSource = nextResolved.source;
|
||||||
|
|
||||||
|
if (normalizedPairFilterFile) {
|
||||||
|
const fileValue = readPairFilterFile(normalizedPairFilterFile);
|
||||||
|
if (fileValue != null) {
|
||||||
|
const filePairFilter = parsePairFilterValue(fileValue, {
|
||||||
|
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
|
||||||
|
});
|
||||||
|
nextPairFilter = filePairFilter;
|
||||||
|
nextPair = formatPairFilter(filePairFilter);
|
||||||
|
nextSource = 'file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pairFilter: nextPairFilter,
|
||||||
|
pair: nextPair,
|
||||||
|
source: nextSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPairFilterFile = String(pairFilterFile || '').trim() || null;
|
||||||
|
const normalizedReloadEveryMs = parseReloadMs(reloadEveryMs);
|
||||||
|
const resolved = resolveConfiguredState();
|
||||||
let currentPairFilter = resolved.pairFilter;
|
let currentPairFilter = resolved.pairFilter;
|
||||||
let currentPair = resolved.pair;
|
let currentPair = resolved.pair;
|
||||||
let lastLoadedFileValue = null;
|
let lastLoadedFileValue = null;
|
||||||
let source = resolved.source;
|
let source = resolved.source;
|
||||||
|
let overrideSource = null;
|
||||||
const normalizedPairFilterFile = String(pairFilterFile || '').trim() || null;
|
|
||||||
const normalizedReloadEveryMs = parseReloadMs(reloadEveryMs);
|
|
||||||
|
|
||||||
if (normalizedPairFilterFile) {
|
if (normalizedPairFilterFile) {
|
||||||
const initialFileValue = readPairFilterFile(normalizedPairFilterFile);
|
const initialFileValue = readPairFilterFile(normalizedPairFilterFile);
|
||||||
|
|
@ -106,6 +131,8 @@ export function createPairFilterController({
|
||||||
|
|
||||||
const timer = normalizedPairFilterFile
|
const timer = normalizedPairFilterFile
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
|
if (overrideSource === 'api') return;
|
||||||
|
|
||||||
const nextValue = readPairFilterFile(normalizedPairFilterFile);
|
const nextValue = readPairFilterFile(normalizedPairFilterFile);
|
||||||
if (nextValue == null || nextValue === lastLoadedFileValue) return;
|
if (nextValue == null || nextValue === lastLoadedFileValue) return;
|
||||||
|
|
||||||
|
|
@ -136,6 +163,12 @@ export function createPairFilterController({
|
||||||
|
|
||||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||||
|
|
||||||
|
function setState(nextPairFilter, nextSource) {
|
||||||
|
currentPairFilter = nextPairFilter;
|
||||||
|
currentPair = formatPairFilter(nextPairFilter);
|
||||||
|
source = nextSource;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPairFilter() {
|
getPairFilter() {
|
||||||
return currentPairFilter;
|
return currentPairFilter;
|
||||||
|
|
@ -143,6 +176,58 @@ export function createPairFilterController({
|
||||||
getPair() {
|
getPair() {
|
||||||
return currentPair;
|
return currentPair;
|
||||||
},
|
},
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
pairFilter: currentPairFilter,
|
||||||
|
pair: currentPair,
|
||||||
|
source,
|
||||||
|
configured: resolveConfiguredState(),
|
||||||
|
pairFilterFile: normalizedPairFilterFile,
|
||||||
|
reloadEveryMs: normalizedPairFilterFile ? normalizedReloadEveryMs : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setPairFilter(raw, { source: nextSource = 'api' } = {}) {
|
||||||
|
const parsed = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: parsePairFilterValue(raw, {
|
||||||
|
fieldName: 'pair filter update',
|
||||||
|
});
|
||||||
|
overrideSource = nextSource === 'api' ? 'api' : null;
|
||||||
|
setState(parsed, nextSource);
|
||||||
|
logger?.info('pair_filter_updated', {
|
||||||
|
pair: currentPair,
|
||||||
|
details: {
|
||||||
|
source: nextSource,
|
||||||
|
pair_filter_file: normalizedPairFilterFile,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.getState();
|
||||||
|
},
|
||||||
|
disable({ source: nextSource = 'api' } = {}) {
|
||||||
|
overrideSource = nextSource === 'api' ? 'api' : null;
|
||||||
|
setState(null, nextSource);
|
||||||
|
logger?.info('pair_filter_disabled', {
|
||||||
|
pair: null,
|
||||||
|
details: {
|
||||||
|
source: nextSource,
|
||||||
|
pair_filter_file: normalizedPairFilterFile,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.getState();
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
overrideSource = null;
|
||||||
|
const configuredState = resolveConfiguredState();
|
||||||
|
setState(configuredState.pairFilter, configuredState.source);
|
||||||
|
logger?.info('pair_filter_reset', {
|
||||||
|
pair: currentPair,
|
||||||
|
details: {
|
||||||
|
source,
|
||||||
|
pair_filter_file: normalizedPairFilterFile,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.getState();
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ const DEFAULTS = {
|
||||||
nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws',
|
nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws',
|
||||||
nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER,
|
nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER,
|
||||||
nearIntentsPairFilterReloadMs: 5_000,
|
nearIntentsPairFilterReloadMs: 5_000,
|
||||||
|
nearIntentsControlApiEnabled: true,
|
||||||
|
nearIntentsControlHost: '0.0.0.0',
|
||||||
|
nearIntentsControlPort: 8081,
|
||||||
kafkaBrokers: ['127.0.0.1:9092'],
|
kafkaBrokers: ['127.0.0.1:9092'],
|
||||||
kafkaClientId: 'unrip',
|
kafkaClientId: 'unrip',
|
||||||
kafkaTopicRawNearIntentsQuote: 'raw.near_intents.quote',
|
kafkaTopicRawNearIntentsQuote: 'raw.near_intents.quote',
|
||||||
|
|
@ -42,6 +45,12 @@ export function loadConfig({ envPath = '.env' } = {}) {
|
||||||
nearIntentsPairFilterFile: process.env.NEAR_INTENTS_PAIR_FILTER_FILE || '',
|
nearIntentsPairFilterFile: process.env.NEAR_INTENTS_PAIR_FILTER_FILE || '',
|
||||||
nearIntentsPairFilterReloadMs:
|
nearIntentsPairFilterReloadMs:
|
||||||
parseNumber(process.env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS, DEFAULTS.nearIntentsPairFilterReloadMs),
|
parseNumber(process.env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS, DEFAULTS.nearIntentsPairFilterReloadMs),
|
||||||
|
nearIntentsControlApiEnabled:
|
||||||
|
parseBoolean(process.env.NEAR_INTENTS_CONTROL_API_ENABLED, DEFAULTS.nearIntentsControlApiEnabled),
|
||||||
|
nearIntentsControlHost:
|
||||||
|
process.env.NEAR_INTENTS_CONTROL_HOST || DEFAULTS.nearIntentsControlHost,
|
||||||
|
nearIntentsControlPort:
|
||||||
|
parseNumber(process.env.NEAR_INTENTS_CONTROL_PORT, DEFAULTS.nearIntentsControlPort),
|
||||||
kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length
|
kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length
|
||||||
? splitCsv(process.env.KAFKA_BROKERS)
|
? splitCsv(process.env.KAFKA_BROKERS)
|
||||||
: DEFAULTS.kafkaBrokers,
|
: DEFAULTS.kafkaBrokers,
|
||||||
|
|
@ -70,3 +79,12 @@ function parseNumber(value, fallback) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,30 @@ export async function startNearIntentsWs({
|
||||||
let quoteStatusSubscriptionId = null;
|
let quoteStatusSubscriptionId = null;
|
||||||
let publishedCount = 0;
|
let publishedCount = 0;
|
||||||
let publishLocked = false;
|
let publishLocked = false;
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let activeSocket = null;
|
||||||
|
let connected = false;
|
||||||
|
let framesReceived = 0;
|
||||||
|
let quoteFramesReceived = 0;
|
||||||
|
let filteredCount = 0;
|
||||||
|
let publishErrorCount = 0;
|
||||||
|
let invalidJsonCount = 0;
|
||||||
|
let lastMessageAt = null;
|
||||||
|
let lastMatchingQuoteAt = null;
|
||||||
|
let lastPublishedAt = null;
|
||||||
|
let lastPublishedPair = null;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl, {
|
const ws = new WebSocket(wsUrl, {
|
||||||
headers: { Authorization: `Bearer ${apiKey}` },
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
});
|
});
|
||||||
|
activeSocket = ws;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
|
connected = true;
|
||||||
logger?.info('connection_established', {
|
logger?.info('connection_established', {
|
||||||
namespace,
|
namespace,
|
||||||
});
|
});
|
||||||
|
|
@ -40,12 +57,15 @@ export async function startNearIntentsWs({
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('message', async (event) => {
|
ws.addEventListener('message', async (event) => {
|
||||||
|
framesReceived += 1;
|
||||||
|
lastMessageAt = new Date().toISOString();
|
||||||
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(text);
|
payload = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
|
invalidJsonCount += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +81,7 @@ export async function startNearIntentsWs({
|
||||||
|
|
||||||
const eventFrame = extractQuoteEventFrame(payload);
|
const eventFrame = extractQuoteEventFrame(payload);
|
||||||
if (!eventFrame) return;
|
if (!eventFrame) return;
|
||||||
|
quoteFramesReceived += 1;
|
||||||
|
|
||||||
const { subscription, merged } = eventFrame;
|
const { subscription, merged } = eventFrame;
|
||||||
|
|
||||||
|
|
@ -78,15 +99,22 @@ export async function startNearIntentsWs({
|
||||||
if (!assetIn || !assetOut) return;
|
if (!assetIn || !assetOut) return;
|
||||||
|
|
||||||
const activePairFilter = getPairFilter();
|
const activePairFilter = getPairFilter();
|
||||||
if (!matchesPairFilter(assetIn, assetOut, activePairFilter)) return;
|
if (!matchesPairFilter(assetIn, assetOut, activePairFilter)) {
|
||||||
|
filteredCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMatchingQuoteAt = new Date().toISOString();
|
||||||
|
|
||||||
publishLocked = true;
|
publishLocked = true;
|
||||||
try {
|
try {
|
||||||
await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id });
|
await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id });
|
||||||
await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id });
|
await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id });
|
||||||
publishedCount += 1;
|
publishedCount += 1;
|
||||||
|
lastPublishedAt = new Date().toISOString();
|
||||||
|
lastPublishedPair = `${assetIn}->${assetOut}`;
|
||||||
onPublish(envelope, publishedCount);
|
onPublish(envelope, publishedCount);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
publishErrorCount += 1;
|
||||||
logger?.error('publish_failed', {
|
logger?.error('publish_failed', {
|
||||||
namespace,
|
namespace,
|
||||||
topic: normalizedTopic,
|
topic: normalizedTopic,
|
||||||
|
|
@ -103,13 +131,17 @@ export async function startNearIntentsWs({
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', () => {
|
||||||
|
connected = false;
|
||||||
|
activeSocket = null;
|
||||||
logger?.warn('connection_lost', {
|
logger?.warn('connection_lost', {
|
||||||
namespace,
|
namespace,
|
||||||
details: {
|
details: {
|
||||||
reconnect_in_ms: 2_000,
|
reconnect_in_ms: 2_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setTimeout(connect, 2000);
|
if (!closed) {
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('error', (err) => {
|
ws.addEventListener('error', (err) => {
|
||||||
|
|
@ -123,6 +155,33 @@ export async function startNearIntentsWs({
|
||||||
}
|
}
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
frames_received: framesReceived,
|
||||||
|
quote_frames_received: quoteFramesReceived,
|
||||||
|
filtered_count: filteredCount,
|
||||||
|
published_count: publishedCount,
|
||||||
|
publish_error_count: publishErrorCount,
|
||||||
|
invalid_json_count: invalidJsonCount,
|
||||||
|
last_message_at: lastMessageAt,
|
||||||
|
last_matching_quote_at: lastMatchingQuoteAt,
|
||||||
|
last_published_at: lastPublishedAt,
|
||||||
|
last_published_pair: lastPublishedPair,
|
||||||
|
raw_topic: rawTopic,
|
||||||
|
normalized_topic: normalizedTopic,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (activeSocket && activeSocket.readyState <= 1) {
|
||||||
|
activeSocket.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSubscriptionId(result) {
|
function extractSubscriptionId(result) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue