diagnostic capture feature
This commit is contained in:
parent
ff1b9982d1
commit
c653e65d5b
9 changed files with 324 additions and 12 deletions
59
app/api/captures.py
Normal file
59
app/api/captures.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures"
|
||||||
|
CAPTURE_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
CAPTURE_LIMIT = 50
|
||||||
|
CAPTURE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(capture_id: str) -> None:
|
||||||
|
for suffix in (".png", ".json"):
|
||||||
|
(CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_captures(now: float | None = None) -> None:
|
||||||
|
current = time.time() if now is None else now
|
||||||
|
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
|
||||||
|
for image in images:
|
||||||
|
if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS:
|
||||||
|
_remove(image.stem)
|
||||||
|
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
|
||||||
|
for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]:
|
||||||
|
_remove(image.stem)
|
||||||
|
|
||||||
|
|
||||||
|
def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]:
|
||||||
|
if not image.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
raise ValueError("Capture is not a PNG")
|
||||||
|
if len(image) > CAPTURE_MAX_BYTES:
|
||||||
|
raise ValueError("Capture exceeds 10 MB")
|
||||||
|
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cleanup_captures()
|
||||||
|
capture_id = f"c-{secrets.token_urlsafe(9)}"
|
||||||
|
details = {
|
||||||
|
**metadata,
|
||||||
|
"id": capture_id,
|
||||||
|
"received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||||
|
"commit": os.environ.get("SOURCE_COMMIT", "dev"),
|
||||||
|
"bytes": len(image),
|
||||||
|
}
|
||||||
|
image_path = CAPTURE_DIR / f"{capture_id}.png"
|
||||||
|
metadata_path = CAPTURE_DIR / f"{capture_id}.json"
|
||||||
|
image_path.write_bytes(image)
|
||||||
|
metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return capture_id, details
|
||||||
|
|
||||||
|
|
||||||
|
def capture_path(capture_id: str, suffix: str) -> Path | None:
|
||||||
|
if not CAPTURE_ID.fullmatch(capture_id):
|
||||||
|
return None
|
||||||
|
path = CAPTURE_DIR / f"{capture_id}{suffix}"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
@ -5,9 +5,11 @@ the browser needs /api/login before it has a session, so these routes stay
|
||||||
outside the protected API router.
|
outside the protected API router.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
|
|
@ -18,6 +20,7 @@ from app.api.deps import (
|
||||||
password_matches,
|
password_matches,
|
||||||
token_matches,
|
token_matches,
|
||||||
)
|
)
|
||||||
|
from app.api.captures import capture_path
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
@ -40,6 +43,22 @@ def version():
|
||||||
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
|
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/captures/{capture_id}")
|
||||||
|
def get_debug_capture(capture_id: str):
|
||||||
|
path = capture_path(capture_id, ".png")
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(404, "Capture not found")
|
||||||
|
return FileResponse(path, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/captures/{capture_id}/meta")
|
||||||
|
def get_debug_capture_metadata(capture_id: str):
|
||||||
|
path = capture_path(capture_id, ".json")
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(404, "Capture not found")
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def login(credentials: LoginRequest, request: Request, response: Response):
|
def login(credentials: LoginRequest, request: Request, response: Response):
|
||||||
presented_token = request.headers.get("x-chart-token", "")
|
presented_token = request.headers.get("x-chart-token", "")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -9,6 +11,7 @@ from app.bars.models import Timeframe
|
||||||
from app.analysis.levels import Side
|
from app.analysis.levels import Side
|
||||||
from app.analysis.manual_lines import ManualLine
|
from app.analysis.manual_lines import ManualLine
|
||||||
from app.api.deps import require_token
|
from app.api.deps import require_token
|
||||||
|
from app.api.captures import CAPTURE_MAX_BYTES, save_capture
|
||||||
|
|
||||||
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
||||||
# /version live in app.api.meta and stay open on purpose.
|
# /version live in app.api.meta and stay open on purpose.
|
||||||
|
|
@ -17,6 +20,42 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/captures", status_code=201)
|
||||||
|
async def create_debug_capture(request: Request):
|
||||||
|
try:
|
||||||
|
content_length = int(request.headers.get("content-length", "0") or 0)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid Content-Length") from None
|
||||||
|
if content_length > CAPTURE_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "Capture exceeds 10 MB")
|
||||||
|
if request.headers.get("content-type", "").split(";", 1)[0] != "image/png":
|
||||||
|
raise HTTPException(415, "Diagnostic capture must be image/png")
|
||||||
|
try:
|
||||||
|
encoded = request.headers.get("x-capture-metadata", "")
|
||||||
|
metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
raise HTTPException(400, "Invalid capture metadata") from None
|
||||||
|
image = await request.body()
|
||||||
|
try:
|
||||||
|
capture_id, details = save_capture(image, metadata)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
logger.warning(
|
||||||
|
"CAPTUREDBG id=%s tf=%s viewport=%sx%s",
|
||||||
|
capture_id,
|
||||||
|
details.get("timeframe"),
|
||||||
|
details.get("viewport_width"),
|
||||||
|
details.get("viewport_height"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": capture_id,
|
||||||
|
"url": f"/api/debug/captures/{capture_id}",
|
||||||
|
"metadata_url": f"/api/debug/captures/{capture_id}/meta",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LineCreate(BaseModel):
|
class LineCreate(BaseModel):
|
||||||
tf: Timeframe
|
tf: Timeframe
|
||||||
side: Side
|
side: Side
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,8 @@ createApp({
|
||||||
const timeframe = ref(prefs.value.base_tf || '1m');
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
||||||
const levels = ref([]);
|
const levels = ref([]);
|
||||||
const clusters = ref([]);
|
const clusters = ref([]);
|
||||||
const alerts = ref([]);
|
const events = ref([]);
|
||||||
let alertSequence = 0;
|
let eventSequence = 0;
|
||||||
// Which tool the next chart gesture creates. null = pan/select as normal.
|
// Which tool the next chart gesture creates. null = pan/select as normal.
|
||||||
const armedTool = ref(null);
|
const armedTool = ref(null);
|
||||||
const drawName = ref('');
|
const drawName = ref('');
|
||||||
|
|
@ -125,6 +125,8 @@ createApp({
|
||||||
const comments = ref([]);
|
const comments = ref([]);
|
||||||
const drawingFilter = ref('');
|
const drawingFilter = ref('');
|
||||||
const drawingKind = ref('all');
|
const drawingKind = ref('all');
|
||||||
|
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
||||||
|
const captureBusy = ref(false);
|
||||||
const snap = ref(true);
|
const snap = ref(true);
|
||||||
const selectedLine = ref(null);
|
const selectedLine = ref(null);
|
||||||
const selectedLines = ref([]);
|
const selectedLines = ref([]);
|
||||||
|
|
@ -191,6 +193,13 @@ createApp({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function addEvent(message, kind = 'info', url = null) {
|
||||||
|
events.value = [
|
||||||
|
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url },
|
||||||
|
...events.value,
|
||||||
|
].slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
const response = await apiFetch('/api/status');
|
const response = await apiFetch('/api/status');
|
||||||
if (response.ok) status.value = await response.json();
|
if (response.ok) status.value = await response.json();
|
||||||
|
|
@ -238,10 +247,7 @@ createApp({
|
||||||
} else if (message.type === 'alert') {
|
} else if (message.type === 'alert') {
|
||||||
// Keyed on a counter, not the timestamp: two alerts inside the same
|
// Keyed on a counter, not the timestamp: two alerts inside the same
|
||||||
// second would collide and Vue would reuse the wrong row.
|
// second would collide and Vue would reuse the wrong row.
|
||||||
alerts.value = [
|
addEvent(message.message, 'alert');
|
||||||
{ key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
|
|
||||||
...alerts.value,
|
|
||||||
].slice(0, 20);
|
|
||||||
playAlert();
|
playAlert();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -281,6 +287,82 @@ createApp({
|
||||||
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeCaptureMetadata(metadata) {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(metadata));
|
||||||
|
return btoa([...bytes].map(byte => String.fromCharCode(byte)).join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureDiagnostic() {
|
||||||
|
if (captureBusy.value) return;
|
||||||
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
|
addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureBusy.value = true;
|
||||||
|
let stream = null;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { displaySurface: 'browser' },
|
||||||
|
audio: false,
|
||||||
|
preferCurrentTab: true,
|
||||||
|
selfBrowserSurface: 'include',
|
||||||
|
});
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.muted = true;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
video.onloadedmetadata = resolve;
|
||||||
|
video.onerror = reject;
|
||||||
|
video.srcObject = stream;
|
||||||
|
});
|
||||||
|
await video.play();
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve());
|
||||||
|
else requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||||
|
const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
if (!image) throw new Error('Browser did not produce a PNG');
|
||||||
|
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
||||||
|
const bars = chartApi?.bars || [];
|
||||||
|
const selected = levels.value.find(level => level.id === selectedLine.value) || null;
|
||||||
|
const metadata = {
|
||||||
|
url: location.href,
|
||||||
|
timeframe: timeframe.value,
|
||||||
|
viewport_width: window.innerWidth,
|
||||||
|
viewport_height: window.innerHeight,
|
||||||
|
device_pixel_ratio: window.devicePixelRatio,
|
||||||
|
image_width: canvas.width,
|
||||||
|
image_height: canvas.height,
|
||||||
|
first_bar_t: bars[0]?.t ?? null,
|
||||||
|
last_bar_t: bars[bars.length - 1]?.t ?? null,
|
||||||
|
bars_held: bars.length,
|
||||||
|
selected_drawing: selected,
|
||||||
|
latest_snap: chartApi?.lastSnapDiagnostic ?? null,
|
||||||
|
commit: version.commit ?? null,
|
||||||
|
};
|
||||||
|
const response = await apiFetch('/api/debug/captures', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'X-Capture-Metadata': encodeCaptureMetadata(metadata),
|
||||||
|
},
|
||||||
|
body: image,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`);
|
||||||
|
const saved = await response.json();
|
||||||
|
addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url);
|
||||||
|
} catch (error) {
|
||||||
|
const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError';
|
||||||
|
addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error');
|
||||||
|
} finally {
|
||||||
|
stream?.getTracks().forEach(track => track.stop());
|
||||||
|
captureBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function armTool(tool) {
|
function armTool(tool) {
|
||||||
armedTool.value = armedTool.value === tool ? null : tool;
|
armedTool.value = armedTool.value === tool ? null : tool;
|
||||||
selectedLine.value = null;
|
selectedLine.value = null;
|
||||||
|
|
@ -677,6 +759,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
|
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
|
||||||
},
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class ConfluenceChart {
|
||||||
this.contextLabelFrame = null;
|
this.contextLabelFrame = null;
|
||||||
this.onCommentToggle = null;
|
this.onCommentToggle = null;
|
||||||
this.onCommentMove = null;
|
this.onCommentMove = null;
|
||||||
|
this.lastSnapDiagnostic = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static TICK = 0.25;
|
static TICK = 0.25;
|
||||||
|
|
@ -899,6 +900,7 @@ class ConfluenceChart {
|
||||||
? 'coordinateToTime returned null'
|
? 'coordinateToTime returned null'
|
||||||
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
||||||
};
|
};
|
||||||
|
this.lastSnapDiagnostic = body;
|
||||||
fetch('/api/debug/snap', {
|
fetch('/api/debug/snap', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json',
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@
|
||||||
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
|
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
|
||||||
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
||||||
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
|
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
|
||||||
|
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
|
||||||
|
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
|
||||||
|
</button>
|
||||||
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
|
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -224,9 +227,13 @@
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section">
|
<details class="sidebar-section">
|
||||||
<summary>Alert log ({{ alerts.length }})</summary>
|
<summary>Events ({{ events.length }})</summary>
|
||||||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
<div v-if="!events.length" class="empty">No events yet.</div>
|
||||||
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
|
||||||
|
<time>{{ event.at }}</time>
|
||||||
|
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
||||||
|
<template v-else>{{ event.message }}</template>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
|
||||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||||
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
||||||
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }
|
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
|
||||||
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
||||||
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
||||||
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
|
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
|
||||||
|
|
@ -53,7 +53,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
||||||
.arm-hint { color:var(--accent); }
|
.arm-hint { color:var(--accent); }
|
||||||
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||||
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }
|
||||||
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
||||||
/* --- chart comments ---------------------------------------------------- */
|
/* --- chart comments ---------------------------------------------------- */
|
||||||
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,71 @@ test('an existing browser token is migrated once and removed from local storage'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('diagnostic capture uploads a PNG and adds its capability ID to Events',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
let uploaded = null;
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
|
||||||
|
await page.route('**/api/debug/captures', async route => {
|
||||||
|
uploaded = {
|
||||||
|
contentType: route.request().headers()['content-type'],
|
||||||
|
metadata: JSON.parse(Buffer.from(
|
||||||
|
route.request().headers()['x-capture-metadata'], 'base64').toString()),
|
||||||
|
};
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'c-TESTCAPTURE1',
|
||||||
|
url: '/api/debug/captures/c-TESTCAPTURE1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await openChart(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(navigator, 'mediaDevices', {
|
||||||
|
configurable: true,
|
||||||
|
value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
|
||||||
|
configurable: true,
|
||||||
|
set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
|
||||||
|
configurable: true, get: () => 800,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
|
||||||
|
configurable: true, get: () => 600,
|
||||||
|
});
|
||||||
|
HTMLMediaElement.prototype.play = async function play() {};
|
||||||
|
HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
|
||||||
|
HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} });
|
||||||
|
HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
|
||||||
|
done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
|
||||||
|
type: 'image/png',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('.diag-capture').click();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
[...document.querySelectorAll('.alert-entry')]
|
||||||
|
.some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
|
||||||
|
|
||||||
|
assert.equal(uploaded.contentType, 'image/png');
|
||||||
|
assert.equal(uploaded.metadata.timeframe, '1m');
|
||||||
|
assert.equal(uploaded.metadata.image_width, 800);
|
||||||
|
assert.equal(uploaded.metadata.image_height, 600);
|
||||||
|
const event = page.locator('.alert-entry.capture a');
|
||||||
|
assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
|
||||||
|
assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
|
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const state = await chartState(page);
|
const state = await chartState(page);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -5,6 +8,7 @@ from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.meta import router as meta_router
|
from app.api.meta import router as meta_router
|
||||||
|
from app.api import captures
|
||||||
from app.api.deps import create_session, session_matches, session_secret
|
from app.api.deps import create_session, session_matches, session_secret
|
||||||
from app.api.routes import router as api_router
|
from app.api.routes import router as api_router
|
||||||
from app.api.schwab_auth import router as schwab_auth_router
|
from app.api.schwab_auth import router as schwab_auth_router
|
||||||
|
|
@ -170,6 +174,41 @@ def test_writes_are_protected(client):
|
||||||
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
|
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_capture_is_authenticated_and_retrievable_by_capability_id(
|
||||||
|
client, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
|
||||||
|
probe = client("s3cret")
|
||||||
|
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
|
||||||
|
metadata = base64.b64encode(
|
||||||
|
json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
assert probe.post(
|
||||||
|
"/api/debug/captures",
|
||||||
|
content=image,
|
||||||
|
headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata},
|
||||||
|
).status_code == 401
|
||||||
|
response = probe.post(
|
||||||
|
"/api/debug/captures",
|
||||||
|
content=image,
|
||||||
|
headers={
|
||||||
|
"X-Chart-Token": "s3cret",
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"X-Capture-Metadata": metadata,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
saved = response.json()
|
||||||
|
assert saved["id"].startswith("c-")
|
||||||
|
assert probe.get(saved["url"]).content == image
|
||||||
|
details = probe.get(saved["metadata_url"]).json()
|
||||||
|
assert details["id"] == saved["id"]
|
||||||
|
assert details["timeframe"] == "30m"
|
||||||
|
assert details["viewport_width"] == 1440
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
|
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
|
||||||
def test_meta_endpoints_stay_open(client, path):
|
def test_meta_endpoints_stay_open(client, path):
|
||||||
"""bin/wait-deploy polls /api/version without carrying the token."""
|
"""bin/wait-deploy polls /api/version without carrying the token."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue