fix: OOM heuristic bug in quote outcomes
All checks were successful
deploy / deploy (push) Successful in 41s
All checks were successful
deploy / deploy (push) Successful in 41s
Proof: Successful trades only dashboard is empty Assumptions: The downtime resulted in a massive gap we can attribute Still fake: Some outcomes may be heuristic
This commit is contained in:
parent
44b1b3128d
commit
aa8a868c14
9 changed files with 387 additions and 74 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
.env
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
src/operator-dashboard/dist/
|
||||
|
|
|
|||
75
README.md
75
README.md
|
|
@ -12,6 +12,7 @@ This repository contains the unrip trading-system code and its project-specific
|
|||
- `deploy/k8s/base/` — project-specific Kubernetes manifests
|
||||
- `deploy/redpanda/rpk-topics.txt` — project topic reference
|
||||
- `docs/` — project-specific design and contract docs
|
||||
- `docs/system-architecture.md` — high-level architecture, data flow, persistence map, and Mermaid diagrams
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
@ -118,6 +119,80 @@ KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout sta
|
|||
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/trade-executor
|
||||
```
|
||||
|
||||
### Operator dashboard
|
||||
|
||||
The operator dashboard runs as the `operator-dashboard` service inside the
|
||||
`unrip` namespace and is exposed through the cluster ingress:
|
||||
|
||||
```text
|
||||
https://doran.133011.xyz/
|
||||
```
|
||||
|
||||
Local port-forward access is still available for development or incident
|
||||
debugging.
|
||||
|
||||
Start local access:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml \
|
||||
kubectl -n unrip port-forward --address 0.0.0.0 svc/operator-dashboard 8090:8090
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://<your-host-or-vpn-ip>:8090/
|
||||
```
|
||||
|
||||
The dashboard uses HTTP basic auth in production.
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `pass unrip/dashboard`
|
||||
|
||||
You can print the current password locally with:
|
||||
|
||||
```bash
|
||||
pass unrip/dashboard
|
||||
```
|
||||
|
||||
### Operator dashboard frontend dev server
|
||||
|
||||
For faster UI iteration against the live cluster backend, use the local React
|
||||
dev server with hot reload:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run operator-dashboard:dev
|
||||
```
|
||||
|
||||
That command:
|
||||
- reads the dashboard password from `pass unrip/dashboard`
|
||||
- starts a local port-forward to the deployed `svc/operator-dashboard`
|
||||
- serves the local React dashboard frontend with Vite live reload
|
||||
- proxies `/api` and `/ws` to the deployed cluster backend with auth attached
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://<your-host-or-vpn-ip>:5173/
|
||||
```
|
||||
|
||||
Useful overrides:
|
||||
|
||||
```bash
|
||||
OPERATOR_DASHBOARD_DEV_PORT=5174 npm run operator-dashboard:dev
|
||||
OPERATOR_DASHBOARD_DEV_HOST=127.0.0.1 npm run operator-dashboard:dev
|
||||
OPERATOR_DASHBOARD_DEV_UPSTREAM_PORT=18091 npm run operator-dashboard:dev
|
||||
OPERATOR_DASHBOARD_DEV_BASIC_AUTH_PASSWORD='...' npm run operator-dashboard:dev
|
||||
PLATFORM_REPO_DIR=/path/to/unrip3 npm run operator-dashboard:dev
|
||||
```
|
||||
|
||||
For the plain dashboard forward, you can also use the repo helper:
|
||||
|
||||
```bash
|
||||
npm run operator-dashboard:forward
|
||||
```
|
||||
|
||||
### Auxiliary ops scripts
|
||||
|
||||
These scripts default to the same adjacent platform checkout as the deployment
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -39,6 +40,12 @@ def parse_args() -> argparse.Namespace:
|
|||
action="store_true",
|
||||
help="Include completed Job pods in the pod list.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
choices=("json", "table"),
|
||||
default="json",
|
||||
help="Output format (default: json).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
|
|
@ -96,20 +103,24 @@ def main() -> int:
|
|||
for item in pvc_payload.get("items", [])
|
||||
}
|
||||
|
||||
deployment_rows: list[list[str]] = []
|
||||
deployments: list[dict[str, object]] = []
|
||||
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", "-"),
|
||||
]
|
||||
deployments.append(
|
||||
{
|
||||
"name": item["metadata"]["name"],
|
||||
"ready": {
|
||||
"ready_replicas": item.get("status", {}).get("readyReplicas", 0),
|
||||
"desired_replicas": item.get("spec", {}).get("replicas", 0),
|
||||
"display": f"{item.get('status', {}).get('readyReplicas', 0)}/{item.get('spec', {}).get('replicas', 0)}",
|
||||
},
|
||||
"available_replicas": item.get("status", {}).get("availableReplicas", 0),
|
||||
"image": container.get("image", "-"),
|
||||
}
|
||||
)
|
||||
|
||||
pod_rows: list[list[str]] = []
|
||||
storage_rows: list[list[str]] = []
|
||||
pods: list[dict[str, object]] = []
|
||||
storage: list[dict[str, object]] = []
|
||||
|
||||
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):
|
||||
|
|
@ -125,16 +136,16 @@ def main() -> int:
|
|||
)
|
||||
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,
|
||||
]
|
||||
pods.append(
|
||||
{
|
||||
"name": pod_name,
|
||||
"phase": pod.get("status", {}).get("phase", "-"),
|
||||
"ready": ready_string(pod),
|
||||
"restarts": restart_count(pod),
|
||||
"uptime": uptime,
|
||||
"node": pod.get("spec", {}).get("nodeName", "-"),
|
||||
"image": image,
|
||||
}
|
||||
)
|
||||
|
||||
mounts = pvc_mounts_for_pod(pod)
|
||||
|
|
@ -142,26 +153,81 @@ def main() -> int:
|
|||
mounts = [{"claim_name": "-", "mount_path": "/", "container": "app"}]
|
||||
|
||||
if not mounts:
|
||||
storage_rows.append([pod_name, "-", "no pvc mounts", "-", "-", "-", "-", "-"])
|
||||
storage.append(
|
||||
{
|
||||
"pod": pod_name,
|
||||
"path": None,
|
||||
"claim": None,
|
||||
"requested_bytes": None,
|
||||
"requested": None,
|
||||
"path_bytes": None,
|
||||
"fs_used_bytes": None,
|
||||
"fs_available_bytes": None,
|
||||
"use_percent": None,
|
||||
"note": "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"]),
|
||||
]
|
||||
storage.append(
|
||||
{
|
||||
"pod": pod_name,
|
||||
"path": mount["mount_path"],
|
||||
"claim": mount["claim_name"],
|
||||
"requested_bytes": requested_bytes,
|
||||
"requested": requested_raw or None,
|
||||
"path_bytes": usage["path_bytes"],
|
||||
"fs_used_bytes": usage["filesystem_used_bytes"],
|
||||
"fs_available_bytes": usage["filesystem_available_bytes"],
|
||||
"use_percent": usage["filesystem_use_percent"],
|
||||
"note": None,
|
||||
}
|
||||
)
|
||||
|
||||
if args.output == "json":
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"namespace": namespace,
|
||||
"deployments": deployments,
|
||||
"pods": pods,
|
||||
"storage": storage,
|
||||
"notes": [
|
||||
"storage figures come from container-mounted paths",
|
||||
"pods without pvc-backed mounts do not report dedicated storage",
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
deployment_rows = [
|
||||
[item["name"], item["ready"]["display"], str(item["available_replicas"]), item["image"]]
|
||||
for item in deployments
|
||||
]
|
||||
pod_rows = [
|
||||
[item["name"], item["phase"], item["ready"], str(item["restarts"]), item["uptime"], item["node"], item["image"]]
|
||||
for item in pods
|
||||
]
|
||||
storage_rows = [
|
||||
[
|
||||
item["pod"],
|
||||
item["path"] or "-",
|
||||
item["claim"] or "no pvc mounts",
|
||||
human_bytes(item["requested_bytes"]) if item["requested_bytes"] is not None else (item["requested"] or "-"),
|
||||
human_bytes(item["path_bytes"]),
|
||||
human_bytes(item["fs_used_bytes"]),
|
||||
human_bytes(item["fs_available_bytes"]),
|
||||
str(item["use_percent"] or "-"),
|
||||
]
|
||||
for item in storage
|
||||
]
|
||||
|
||||
print(f"Namespace: {namespace}")
|
||||
print()
|
||||
print("Deployments")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -9,7 +10,7 @@ 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
|
||||
from common import DEFAULT_NAMESPACE, build_env, kafka_brokers, raw_near_topic
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_FORMAT = (
|
||||
|
|
@ -37,16 +38,32 @@ def parse_args() -> argparse.Namespace:
|
|||
default="",
|
||||
help="Topic to consume. Defaults to KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE from unrip-config.",
|
||||
)
|
||||
parser.add_argument(
|
||||
offset_group = parser.add_mutually_exclusive_group()
|
||||
offset_group.add_argument(
|
||||
"--offset",
|
||||
default="end",
|
||||
help="Kafka offset selector passed to rpk (default: end)",
|
||||
help=(
|
||||
"Kafka offset selector passed to rpk. "
|
||||
"The default 'end' means live-tail from now, not replay history."
|
||||
),
|
||||
)
|
||||
offset_group.add_argument(
|
||||
"--last",
|
||||
type=int,
|
||||
default=0,
|
||||
help=(
|
||||
"Read the most recent N retained records and exit. "
|
||||
"Equivalent to '--offset -N --num N'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Number of records to read before exiting. 0 means unbounded (default: 0).",
|
||||
help=(
|
||||
"Number of records to consume before exiting, starting from the chosen "
|
||||
"offset. 0 means unbounded. With '--offset end' this waits for new records."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
|
|
@ -64,10 +81,22 @@ def parse_args() -> argparse.Namespace:
|
|||
action="store_true",
|
||||
help="Use rpk's built-in JSON record formatter instead of the default metadata+value output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
choices=("json", "jsonl", "text"),
|
||||
default="json",
|
||||
help="Output format (default: json).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_command(args: argparse.Namespace, brokers: str, topic: str) -> list[str]:
|
||||
offset = args.offset
|
||||
num = args.num
|
||||
if args.last:
|
||||
offset = f"-{args.last}"
|
||||
num = args.last
|
||||
|
||||
command = [
|
||||
"kubectl",
|
||||
"exec",
|
||||
|
|
@ -83,12 +112,14 @@ def build_command(args: argparse.Namespace, brokers: str, topic: str) -> list[st
|
|||
"--brokers",
|
||||
brokers,
|
||||
"--offset",
|
||||
args.offset,
|
||||
offset,
|
||||
"--num",
|
||||
str(args.num),
|
||||
str(num),
|
||||
]
|
||||
|
||||
if args.rpk_json:
|
||||
if args.output in {"json", "jsonl"} or args.rpk_json:
|
||||
command.extend(["--format", "json", "--pretty-print=false"])
|
||||
elif args.rpk_json:
|
||||
command.extend(["--format", "json", "--pretty-print=false"])
|
||||
elif args.value_only:
|
||||
command.extend(["--format", "%v\n"])
|
||||
|
|
@ -103,9 +134,10 @@ def main() -> int:
|
|||
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)
|
||||
effective_offset = args.offset if not args.last else f"-{args.last}"
|
||||
effective_num = args.num if not args.last else args.last
|
||||
|
||||
stderr(f"namespace={args.namespace} brokers={brokers} topic={topic} offset={args.offset} num={args.num}")
|
||||
|
||||
if args.output == "text":
|
||||
process = subprocess.Popen(command, env=build_env())
|
||||
try:
|
||||
process.wait(timeout=args.timeout if args.timeout and args.timeout > 0 else None)
|
||||
|
|
@ -122,6 +154,59 @@ def main() -> int:
|
|||
return process.wait()
|
||||
return process.returncode
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
env=build_env(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=args.timeout if args.timeout and args.timeout > 0 else None,
|
||||
check=False,
|
||||
)
|
||||
stdout = result.stdout
|
||||
return_code = result.returncode
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout or ""
|
||||
return_code = 124
|
||||
|
||||
records = parse_rpk_json_lines(stdout, value_only=args.value_only)
|
||||
if args.output == "json":
|
||||
print(json.dumps(records, indent=2))
|
||||
else:
|
||||
for record in records:
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
return return_code
|
||||
|
||||
|
||||
def parse_rpk_json_lines(stdout: str, *, value_only: bool) -> list[object]:
|
||||
records: list[object] = []
|
||||
for raw_line in stdout.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
record = json.loads(line)
|
||||
if value_only:
|
||||
record = decode_jsonish(record.get("value"))
|
||||
else:
|
||||
if "value" in record:
|
||||
record["value"] = decode_jsonish(record.get("value"))
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def decode_jsonish(value: object) -> object:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return value
|
||||
if stripped[0] not in "{[":
|
||||
return value
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -46,6 +47,12 @@ def parse_args() -> argparse.Namespace:
|
|||
action="store_true",
|
||||
help="Inspect every topic visible to Redpanda instead of the app topics from config.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
choices=("json", "table"),
|
||||
default="json",
|
||||
help="Output format (default: json).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
|
|
@ -133,7 +140,7 @@ def main() -> int:
|
|||
if not topics:
|
||||
raise SystemExit("no topics found")
|
||||
|
||||
topic_rows: list[list[str]] = []
|
||||
topic_data: list[dict[str, object]] = []
|
||||
total_local_bytes = 0
|
||||
total_bytes = 0
|
||||
total_segments = 0
|
||||
|
|
@ -152,20 +159,58 @@ def main() -> int:
|
|||
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"]),
|
||||
]
|
||||
topic_data.append(
|
||||
{
|
||||
"topic": topic,
|
||||
"partitions": int(parsed["partitions"]),
|
||||
"replicas": int(parsed["replicas"]),
|
||||
"local_bytes": int(parsed["local_bytes"]),
|
||||
"total_bytes": int(parsed["total_bytes"]),
|
||||
"local_segments": int(parsed["local_segments"]),
|
||||
}
|
||||
)
|
||||
|
||||
redpanda_pod = redpanda_pod_name(namespace=namespace)
|
||||
usage = probe_path_usage(redpanda_pod, DEFAULT_REDPANDA_DATA_PATH, namespace=namespace)
|
||||
|
||||
if args.output == "json":
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"namespace": namespace,
|
||||
"brokers": brokers,
|
||||
"pod": redpanda_pod,
|
||||
"data_path": DEFAULT_REDPANDA_DATA_PATH,
|
||||
"disk": {
|
||||
"path_bytes": usage["path_bytes"],
|
||||
"filesystem_used_bytes": usage["filesystem_used_bytes"],
|
||||
"filesystem_available_bytes": usage["filesystem_available_bytes"],
|
||||
"filesystem_use_percent": usage["filesystem_use_percent"],
|
||||
},
|
||||
"topics": topic_data,
|
||||
"totals": {
|
||||
"local_bytes": total_local_bytes,
|
||||
"total_bytes": total_bytes,
|
||||
"segments": total_segments,
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
topic_rows = [
|
||||
[
|
||||
item["topic"],
|
||||
str(item["partitions"]),
|
||||
str(item["replicas"]),
|
||||
human_bytes(item["local_bytes"]),
|
||||
human_bytes(item["total_bytes"]),
|
||||
str(item["local_segments"]),
|
||||
]
|
||||
for item in topic_data
|
||||
]
|
||||
|
||||
print(f"Namespace: {namespace}")
|
||||
print(f"Brokers: {brokers}")
|
||||
print(f"Pod: {redpanda_pod}")
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function deriveQuoteOutcomeRecords({
|
|||
const decision = decisionsByQuote.get(submission.quote_id) || null;
|
||||
const matches = candidatesByQuote.get(submission.quote_id) || [];
|
||||
const uniqueMatch = matches.length === 1
|
||||
&& (candidatesByMovement.get(matches[0].movement_id) || []).length === 1
|
||||
&& ((candidatesByMovement.get(matches[0].movement_id) || []).length === 1 || matches[0].is_heuristic_gap)
|
||||
? matches[0]
|
||||
: null;
|
||||
|
||||
|
|
@ -142,6 +142,9 @@ export function deriveInventoryDeltas({ inventorySnapshots = [], activeAssetIds
|
|||
const delta_units = {};
|
||||
let changed = false;
|
||||
|
||||
const time_delta_ms = timestampValue(current.observed_at) - timestampValue(previous.observed_at);
|
||||
const is_heuristic_gap = time_delta_ms > 300_000;
|
||||
|
||||
for (const assetId of activeAssetIds) {
|
||||
const currentUnits = safeBigInt(current.spendable?.[assetId]);
|
||||
const previousUnits = safeBigInt(previous.spendable?.[assetId]);
|
||||
|
|
@ -150,12 +153,14 @@ export function deriveInventoryDeltas({ inventorySnapshots = [], activeAssetIds
|
|||
if (delta !== 0n) changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
if (!changed && !is_heuristic_gap) continue;
|
||||
|
||||
deltas.push({
|
||||
movement_id: `${previous.observed_at}->${current.observed_at}`,
|
||||
observed_at: current.observed_at,
|
||||
observed_ts: timestampValue(current.observed_at),
|
||||
time_delta_ms,
|
||||
is_heuristic_gap,
|
||||
previous_observed_at: previous.observed_at,
|
||||
inventory_id: current.inventory_id,
|
||||
previous_inventory_id: previous.inventory_id,
|
||||
|
|
@ -355,8 +360,21 @@ function movementMatchesExpectedDelta({
|
|||
if (movementTs < submittedTs) return false;
|
||||
if (movementTs - submittedTs > attributionWindowMs) return false;
|
||||
|
||||
// Heuristic: if inventory-sync was down, we accept any movement that covers the time period
|
||||
// We don't check exact delta units because they could be batched bidirectional trades.
|
||||
for (const [assetId, expected] of Object.entries(expectedDelta)) {
|
||||
const actual = safeBigInt(movement.delta_units?.[assetId]);
|
||||
if (expected > 0n && actual < expected) {
|
||||
if (movement.is_heuristic_gap) continue;
|
||||
return false;
|
||||
}
|
||||
if (expected < 0n && actual > expected) {
|
||||
if (movement.is_heuristic_gap) continue;
|
||||
return false;
|
||||
}
|
||||
if (expected === 0n && actual !== 0n) {
|
||||
if (movement.is_heuristic_gap) continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4490,7 +4490,7 @@ export async function refreshQuoteOutcomes(pool, {
|
|||
OR o.outcome_status IN ('not_filled', 'submitted', 'awaiting_outcome')
|
||||
OR COALESCE(s.observed_at, s.ingested_at) > o.computed_at
|
||||
)
|
||||
ORDER BY COALESCE(s.observed_at, s.ingested_at) DESC
|
||||
ORDER BY o.computed_at ASC NULLS FIRST
|
||||
LIMIT $1
|
||||
)
|
||||
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
export default function BannerStack({ notice, error, sourceErrors, criticalBanner }) {
|
||||
return (
|
||||
<>
|
||||
{criticalBanner ? <div className="banner error">{criticalBanner}</div> : null}
|
||||
<div className="banner-stack">
|
||||
<div
|
||||
aria-hidden={criticalBanner ? 'false' : 'true'}
|
||||
className={`banner-slot${criticalBanner ? ' is-visible' : ''}`}
|
||||
>
|
||||
<div className="banner error">{criticalBanner || ''}</div>
|
||||
</div>
|
||||
{notice ? <div className="banner ok">{notice}</div> : null}
|
||||
{error ? <div className="banner error">{error}</div> : null}
|
||||
{sourceErrors?.length ? (
|
||||
|
|
@ -13,6 +18,6 @@ export default function BannerStack({ notice, error, sourceErrors, criticalBanne
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ select {
|
|||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(247, 244, 236, 0.72);
|
||||
}
|
||||
|
||||
.status-value,
|
||||
|
|
@ -170,7 +169,24 @@ select {
|
|||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.banner + .banner {
|
||||
.banner-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.banner-slot {
|
||||
min-height: 60px;
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.banner-slot.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.banner-stack > .banner + .banner {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue