Add burst job scheduling, enhance timeline lane management, and improve tooltip functionality
This commit is contained in:
@@ -140,6 +140,27 @@ def get_fake_jobs(now: datetime) -> list[FakeReconJob]:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A burst of jobs all scheduled at the same time (16:00) — the engine will
|
||||||
|
# serialise these so the dashboard caps the stack at 3 lanes and chains the
|
||||||
|
# rest horizontally on the bottom lane.
|
||||||
|
burst_due = _at(today, 16, 0) if now < _at(today, 16, 0) else now + timedelta(minutes=30)
|
||||||
|
burst_names = [
|
||||||
|
("Branch Cash Recon", "branch-cash"),
|
||||||
|
("Card Settlement Recon", "card-settlement"),
|
||||||
|
("ATM Float Recon", "atm-float"),
|
||||||
|
("Loan Disbursement Recon","loan-disb"),
|
||||||
|
("Mortgage Payment Recon", "mortgage-pmt"),
|
||||||
|
("Term Deposit Recon", "term-deposit"),
|
||||||
|
("Wire Transfer Recon", "wire-transfer"),
|
||||||
|
]
|
||||||
|
for i, (name, ref) in enumerate(burst_names):
|
||||||
|
jobs.append(FakeReconJob(
|
||||||
|
id=210 + i, name=name, as_at_date=today,
|
||||||
|
recon_config_reference=ref,
|
||||||
|
due_datetime=burst_due,
|
||||||
|
status="created",
|
||||||
|
))
|
||||||
|
|
||||||
# Add a 7-day history (excluding the two days above) so totals look real.
|
# Add a 7-day history (excluding the two days above) so totals look real.
|
||||||
history_template = [
|
history_template = [
|
||||||
("FX Settlement Recon", "fx-settlement", 6, 45, "completed"),
|
("FX Settlement Recon", "fx-settlement", 6, 45, "completed"),
|
||||||
|
|||||||
+10
-1
@@ -1,16 +1,25 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/.well-known/appspecific/com.chrome.devtools.json", include_in_schema=False)
|
||||||
|
async def chrome_devtools_probe() -> Response:
|
||||||
|
"""Chrome DevTools probes this path whenever DevTools opens. Return an
|
||||||
|
empty 204 so it doesn't show as a 404 in the access log."""
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/docs", include_in_schema=False)
|
@router.get("/api/docs", include_in_schema=False)
|
||||||
async def swagger_ui_dark(request: Request) -> HTMLResponse:
|
async def swagger_ui_dark(request: Request) -> HTMLResponse:
|
||||||
base = get_swagger_ui_html(
|
base = get_swagger_ui_html(
|
||||||
openapi_url=request.app.openapi_url,
|
openapi_url=request.app.openapi_url,
|
||||||
title="Recon Ranger — API Docs",
|
title="Recon Ranger — API Docs",
|
||||||
|
swagger_js_url="/static/api/swagger-ui-bundle.js",
|
||||||
swagger_css_url="/static/api/swagger-ui.css",
|
swagger_css_url="/static/api/swagger-ui.css",
|
||||||
|
swagger_favicon_url="/static/api/favicon.png",
|
||||||
)
|
)
|
||||||
html = base.body.decode()
|
html = base.body.decode()
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -87,22 +87,43 @@ def _build_day_timeline(
|
|||||||
# Lane (row) assignment so overlapping/adjacent bars stack vertically.
|
# Lane (row) assignment so overlapping/adjacent bars stack vertically.
|
||||||
# An entry occupies [left, left+width); for collision purposes scheduled
|
# An entry occupies [left, left+width); for collision purposes scheduled
|
||||||
# markers are widened slightly so back-to-back scheduled jobs separate.
|
# markers are widened slightly so back-to-back scheduled jobs separate.
|
||||||
|
# Lanes are capped at MAX_LANES — additional collisions are pushed to the
|
||||||
|
# right of the last entry on the bottom lane (visualising "and N more
|
||||||
|
# serialised after this one") rather than growing the track vertically.
|
||||||
GAP = 0.2 # % units
|
GAP = 0.2 # % units
|
||||||
MIN_COLLISION_WIDTH = 1.2
|
MIN_COLLISION_WIDTH = 1.2
|
||||||
|
MAX_LANES = 3
|
||||||
lane_ends: list[float] = []
|
lane_ends: list[float] = []
|
||||||
|
lane_visible_ends: list[float] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
coll_width = max(e["width"], MIN_COLLISION_WIDTH)
|
coll_width = max(e["width"], MIN_COLLISION_WIDTH)
|
||||||
coll_end = e["left"] + coll_width
|
|
||||||
placed = False
|
placed = False
|
||||||
for i, end in enumerate(lane_ends):
|
for i, end in enumerate(lane_ends):
|
||||||
if e["left"] >= end + GAP:
|
if e["left"] >= end + GAP:
|
||||||
lane_ends[i] = coll_end
|
lane_ends[i] = e["left"] + coll_width
|
||||||
e["lane"] = i
|
e["lane"] = i
|
||||||
placed = True
|
placed = True
|
||||||
break
|
break
|
||||||
if not placed:
|
if not placed:
|
||||||
|
if len(lane_ends) < MAX_LANES:
|
||||||
e["lane"] = len(lane_ends)
|
e["lane"] = len(lane_ends)
|
||||||
lane_ends.append(coll_end)
|
lane_ends.append(e["left"] + coll_width)
|
||||||
|
lane_visible_ends.append(e["left"] + e["width"])
|
||||||
|
else:
|
||||||
|
# All lanes occupied — shunt this entry horizontally onto the
|
||||||
|
# last lane, immediately after whatever is already there. We
|
||||||
|
# advance by the previous entry's *visible* width (tracked
|
||||||
|
# separately) rather than the inflated collision width, so
|
||||||
|
# scheduled markers sit flush against each other.
|
||||||
|
last = MAX_LANES - 1
|
||||||
|
new_left = lane_visible_ends[last] + GAP
|
||||||
|
e["left"] = new_left
|
||||||
|
e["lane"] = last
|
||||||
|
lane_ends[last] = new_left + coll_width
|
||||||
|
lane_visible_ends[last] = new_left + e["width"]
|
||||||
|
continue
|
||||||
|
# Track visible end too (used only for chaining on the last lane).
|
||||||
|
lane_visible_ends[e["lane"]] = e["left"] + e["width"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"date": day,
|
"date": day,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
File diff suppressed because one or more lines are too long
@@ -364,7 +364,7 @@ footer {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
height: calc(var(--lane-count) * var(--lane-height) + 6px);
|
height: calc(var(--lane-count) * var(--lane-height) + 6px);
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.gridline {
|
.gridline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -399,7 +399,6 @@ footer {
|
|||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -412,6 +411,8 @@ footer {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.job-bar-completed { background: rgba(74, 222, 128, 0.85); }
|
.job-bar-completed { background: rgba(74, 222, 128, 0.85); }
|
||||||
.job-bar-running { background: rgba(250, 204, 21, 0.85); }
|
.job-bar-running { background: rgba(250, 204, 21, 0.85); }
|
||||||
@@ -491,6 +492,12 @@ footer {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-table-scroll {
|
||||||
|
/* Let the table scroll horizontally inside a narrow card instead of
|
||||||
|
spilling out. The card itself stays put. */
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 -4px;
|
||||||
|
}
|
||||||
.metric-table {
|
.metric-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -500,6 +507,7 @@ footer {
|
|||||||
padding: 6px 6px;
|
padding: 6px 6px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.metric-table thead th {
|
.metric-table thead th {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -613,7 +621,7 @@ a.job-bar:hover { filter: brightness(1.1); box-shadow: 0 0 0 1px rgba(255,255,25
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.history-dot {
|
.history-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -664,3 +672,35 @@ a.job-bar:hover { filter: brightness(1.1); box-shadow: 0 0 0 1px rgba(255,255,25
|
|||||||
}
|
}
|
||||||
.nested-item { display: block; font-size: 13px; }
|
.nested-item { display: block; font-size: 13px; }
|
||||||
.nested-key { color: #94a3b8; margin-right: 6px; }
|
.nested-key { color: #94a3b8; margin-right: 6px; }
|
||||||
|
|
||||||
|
/* ── Instant CSS tooltip (replaces native title= delay) ── */
|
||||||
|
/* Note: do NOT add `position: relative` here — it would override the
|
||||||
|
`position: absolute` on .job-bar / .history-dot. The pseudo-element below
|
||||||
|
positions relative to whichever ancestor is already positioned. */
|
||||||
|
[data-tip]::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #0f1117;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #2d2d3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
[data-tip]:hover::after,
|
||||||
|
[data-tip]:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<a class="job-bar job-bar-{{ e.kind }}"
|
<a class="job-bar job-bar-{{ e.kind }}"
|
||||||
href="/jobs/{{ e.id }}"
|
href="/jobs/{{ e.id }}"
|
||||||
style="left: {{ e.left }}%; width: {{ e.width }}%; --lane: {{ e.lane }}"
|
style="left: {{ e.left }}%; width: {{ e.width }}%; --lane: {{ e.lane }}"
|
||||||
title="{{ e.name }} — {{ e.kind }}{% if e.start %} | start {{ e.start }}{% endif %}{% if e.finish %} | finish {{ e.finish }}{% endif %}{% if e.due and not e.start %} | due {{ e.due }}{% endif %}">
|
data-tip="{{ e.name }} — {{ e.kind }}{% if e.start %} | start {{ e.start }}{% endif %}{% if e.finish %} | finish {{ e.finish }}{% endif %}{% if e.due and not e.start %} | due {{ e.due }}{% endif %}">
|
||||||
<span class="job-bar-label">{{ e.name }}</span>
|
<span class="job-bar-label">{{ e.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
|
|
||||||
<section class="metric-card">
|
<section class="metric-card">
|
||||||
<h3>Job Outcomes</h3>
|
<h3>Job Outcomes</h3>
|
||||||
|
<div class="metric-table-scroll">
|
||||||
<table class="metric-table">
|
<table class="metric-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th></th><th>✓ Done</th><th>✗ Failed</th><th>Running</th><th>Pending</th><th>Success</th></tr>
|
<tr><th></th><th>✓ Done</th><th>✗ Failed</th><th>Running</th><th>Pending</th><th>Success</th></tr>
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="metric-card">
|
<section class="metric-card">
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<a class="history-dot history-dot-{{ e.kind }}{% if e.id == job.id %} is-current{% endif %}"
|
<a class="history-dot history-dot-{{ e.kind }}{% if e.id == job.id %} is-current{% endif %}"
|
||||||
href="/jobs/{{ e.id }}"
|
href="/jobs/{{ e.id }}"
|
||||||
style="left: {{ e.left }}%"
|
style="left: {{ e.left }}%"
|
||||||
title="{{ e.name }} · {{ e.as_at }}{% if e.when %} · {{ e.when }}{% endif %} · {{ e.status }}">
|
data-tip="{{ e.name }} · {{ e.as_at }}{% if e.when %} · {{ e.when }}{% endif %} · {{ e.status }}">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user