interloper-api 0.96.0__tar.gz → 0.97.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_api-0.96.0 → interloper_api-0.97.0}/PKG-INFO +1 -1
- {interloper_api-0.96.0 → interloper_api-0.97.0}/pyproject.toml +1 -1
- {interloper_api-0.96.0 → interloper_api-0.97.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/app.py +2 -0
- interloper_api-0.97.0/src/interloper_api/routes/overview.py +952 -0
- interloper_api-0.97.0/src/interloper_api/utils/__init__.py +5 -0
- interloper_api-0.97.0/src/interloper_api/utils/time.py +41 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/README.md +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/__init__.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/auth.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/rbac.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/dependencies/state.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/notifications/__init__.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/notifications/invitations.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/health.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/routes/websocket.py +0 -0
|
@@ -31,6 +31,7 @@ from interloper_api.routes import (
|
|
|
31
31
|
health,
|
|
32
32
|
oauth,
|
|
33
33
|
organisations,
|
|
34
|
+
overview,
|
|
34
35
|
runs,
|
|
35
36
|
tokens,
|
|
36
37
|
websocket,
|
|
@@ -49,6 +50,7 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
|
|
|
49
50
|
components,
|
|
50
51
|
runs,
|
|
51
52
|
backfills,
|
|
53
|
+
overview,
|
|
52
54
|
oauth,
|
|
53
55
|
tokens,
|
|
54
56
|
websocket,
|
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
"""Overview API: the landing page's health, attention, schedule and inventory in one read.
|
|
2
|
+
|
|
3
|
+
The store answers what the data says; this module answers what it means
|
|
4
|
+
for the reader. Each section of the response is built from store rows by its
|
|
5
|
+
own model's ``from_*`` constructor, so every tile and list on the page shares
|
|
6
|
+
one vocabulary (see ``docs/superpowers/specs/2026-09-30-overview-page-design.md``).
|
|
7
|
+
|
|
8
|
+
Run and event timestamps pass through ``assume_utc`` before any arithmetic:
|
|
9
|
+
Postgres hands ``TIMESTAMPTZ`` columns back aware, but SQLite has no timezone
|
|
10
|
+
type and hands them back naive, and comparing those with an aware ``now``
|
|
11
|
+
would raise.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
from collections import Counter, defaultdict
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Annotated, Any, Literal
|
|
20
|
+
from uuid import UUID
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
23
|
+
from interloper.partitioning.time import TimeGranularity, TimePartitionConfig, TimePartitionWindow
|
|
24
|
+
from interloper.utils.time import assume_utc
|
|
25
|
+
from interloper_db import Backfill, Component, ComponentStatus
|
|
26
|
+
from interloper_db.models import Run
|
|
27
|
+
from interloper_db.store.events import CoverageRow, ErrorGroup, PartitionBounds
|
|
28
|
+
from pydantic import BaseModel
|
|
29
|
+
|
|
30
|
+
from interloper_api.dependencies import OrgIdDep, StoreDep, ViewerDep
|
|
31
|
+
from interloper_api.routes.runs import RunResponse
|
|
32
|
+
from interloper_api.utils import format_duration, job_zone
|
|
33
|
+
|
|
34
|
+
router = APIRouter(prefix="/overview", tags=["overview"])
|
|
35
|
+
|
|
36
|
+
OVERDUE_AFTER = dt.timedelta(minutes=15)
|
|
37
|
+
# Verdicts only: a step-level failure (dest_write_failed and the like) repeats its operation's own verdict.
|
|
38
|
+
FAILURE_EVENT_TYPES = ("operation_failed", "run_failed")
|
|
39
|
+
INVENTORY_KINDS = ("source", "asset", "destination", "connection", "job", "hook")
|
|
40
|
+
HOOK_EVENTS_LOOKBACK = dt.timedelta(days=30)
|
|
41
|
+
RECENT_LIMIT = 5
|
|
42
|
+
MAX_COVERAGE_SPAN_DAYS = 400
|
|
43
|
+
TERMINAL_STATUSES = frozenset({"success", "failed", "canceled"})
|
|
44
|
+
DRIFT_TITLES = {
|
|
45
|
+
ComponentStatus.MISSING: "{name} is missing from the catalog",
|
|
46
|
+
ComponentStatus.DISABLED: "{name} is disabled in this deployment",
|
|
47
|
+
ComponentStatus.UNREADABLE: "{name} has an unreadable config",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -- Response models -----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HourBucket(BaseModel):
|
|
55
|
+
"""Attempts that finished inside one hour, by verdict."""
|
|
56
|
+
|
|
57
|
+
hour: dt.datetime
|
|
58
|
+
succeeded: int
|
|
59
|
+
failed: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunsSummary(BaseModel):
|
|
63
|
+
"""Attempts finished over the last 24 clock hours, with their hourly profile.
|
|
64
|
+
|
|
65
|
+
The window starts at the top of the hour 23 hours before *now*'s and ends
|
|
66
|
+
at *now*, so the totals always equal the sum of the bars.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
total: int
|
|
70
|
+
succeeded: int
|
|
71
|
+
failed: int
|
|
72
|
+
hourly: list[HourBucket]
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_runs(cls, runs: list[Run], now: dt.datetime) -> RunsSummary:
|
|
76
|
+
"""Bucket the attempts that finished in the window by the clock hour they completed in.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
runs: Attempts completed around the window, whether or not they
|
|
80
|
+
started; those that did not complete inside it, or ended other
|
|
81
|
+
than in success or failure, are left out.
|
|
82
|
+
now: The window's end, aware UTC.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The verdict totals and 24 hourly buckets, oldest first.
|
|
86
|
+
"""
|
|
87
|
+
start = now.replace(minute=0, second=0, microsecond=0) - dt.timedelta(hours=23)
|
|
88
|
+
counts: dict[dt.datetime, Counter[str]] = defaultdict(Counter)
|
|
89
|
+
for run in runs:
|
|
90
|
+
if run.completed_at is None or run.status not in ("success", "failed"):
|
|
91
|
+
continue
|
|
92
|
+
completed = assume_utc(run.completed_at).astimezone(dt.timezone.utc)
|
|
93
|
+
if start <= completed <= now:
|
|
94
|
+
counts[completed.replace(minute=0, second=0, microsecond=0)][run.status] += 1
|
|
95
|
+
hourly = [
|
|
96
|
+
HourBucket(hour=hour, succeeded=counts[hour]["success"], failed=counts[hour]["failed"])
|
|
97
|
+
for hour in (start + dt.timedelta(hours=i) for i in range(24))
|
|
98
|
+
]
|
|
99
|
+
succeeded = sum(bucket.succeeded for bucket in hourly)
|
|
100
|
+
failed = sum(bucket.failed for bucket in hourly)
|
|
101
|
+
return cls(total=succeeded + failed, succeeded=succeeded, failed=failed, hourly=hourly)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ActivitySummary(BaseModel):
|
|
105
|
+
"""What is executing right now."""
|
|
106
|
+
|
|
107
|
+
running: int
|
|
108
|
+
queued: int
|
|
109
|
+
longest_running_seconds: float | None = None
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_runs(cls, running: list[Run], queued: int, now: dt.datetime) -> ActivitySummary:
|
|
113
|
+
"""Summarise the attempts in flight.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
running: The attempts currently running.
|
|
117
|
+
queued: How many attempts wait in the queue.
|
|
118
|
+
now: The reference instant the running time is measured to.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The counts, and the longest running time in seconds (``None``
|
|
122
|
+
when nothing that started is running).
|
|
123
|
+
"""
|
|
124
|
+
longest = max(
|
|
125
|
+
((now - assume_utc(run.started_at)).total_seconds() for run in running if run.started_at), default=None
|
|
126
|
+
)
|
|
127
|
+
return cls(running=len(running), queued=queued, longest_running_seconds=longest)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BackfillsSummary(BaseModel):
|
|
131
|
+
"""Backfills still queued or running, their partitions rolled up."""
|
|
132
|
+
|
|
133
|
+
active: int
|
|
134
|
+
partitions_done: int
|
|
135
|
+
partitions_total: int
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_backfills(cls, backfills: list[Backfill], counts: dict[UUID, dict[str, int]]) -> BackfillsSummary:
|
|
139
|
+
"""Roll the active backfills' partitions up into one progress figure.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
backfills: The active backfills.
|
|
143
|
+
counts: Each backfill's runs per status, by backfill id; a
|
|
144
|
+
backfill absent from it has no runs counted as done.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The active count, the partitions in a terminal status and the
|
|
148
|
+
partitions in total.
|
|
149
|
+
"""
|
|
150
|
+
done = sum(
|
|
151
|
+
n
|
|
152
|
+
for backfill in backfills
|
|
153
|
+
for status, n in counts.get(backfill.id, {}).items()
|
|
154
|
+
if status in TERMINAL_STATUSES
|
|
155
|
+
)
|
|
156
|
+
return cls(
|
|
157
|
+
active=len(backfills),
|
|
158
|
+
partitions_done=done,
|
|
159
|
+
partitions_total=sum(backfill.partitions for backfill in backfills),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class JobsSummary(BaseModel):
|
|
164
|
+
"""Enabled jobs, and how many of them last ended in failure."""
|
|
165
|
+
|
|
166
|
+
enabled: int
|
|
167
|
+
failing: int
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_jobs(cls, enabled_jobs: list[Component], failing_ids: set[UUID]) -> JobsSummary:
|
|
171
|
+
"""Count the enabled jobs and those whose latest attempt failed.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
enabled_jobs: The organisation's enabled job rows.
|
|
175
|
+
failing_ids: Ids of the jobs whose latest attempt failed.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The two counts.
|
|
179
|
+
"""
|
|
180
|
+
return cls(enabled=len(enabled_jobs), failing=sum(1 for job in enabled_jobs if job.id in failing_ids))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AttentionItem(BaseModel):
|
|
184
|
+
"""One thing that needs a person, with what to open to act on it."""
|
|
185
|
+
|
|
186
|
+
kind: Literal["error_group", "connection", "run_stack", "drift", "overdue"]
|
|
187
|
+
severity: Literal["error", "warning"]
|
|
188
|
+
title: str
|
|
189
|
+
target: str | None = None
|
|
190
|
+
since: dt.datetime | None = None
|
|
191
|
+
component_id: UUID | None = None
|
|
192
|
+
component_kind: str | None = None
|
|
193
|
+
run_id: UUID | None = None
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_error_groups(cls, groups: list[ErrorGroup], names: dict[UUID, str]) -> list[AttentionItem]:
|
|
197
|
+
"""Merge error groups by job and the error's first line.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
groups: The store's error groups (one per job, run, component, type and text).
|
|
201
|
+
names: Job names by id; a group whose run targets anything else
|
|
202
|
+
(an ad-hoc asset run, a deleted target) names no job.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
One error per job and first line, the most runs first, then by title.
|
|
206
|
+
"""
|
|
207
|
+
merged: dict[tuple[UUID | None, str], dict[str, Any]] = {}
|
|
208
|
+
for group in groups:
|
|
209
|
+
line = group.error.splitlines()[0].strip() if group.error else ""
|
|
210
|
+
entry = merged.setdefault(
|
|
211
|
+
(group.job_id, line), {"runs": set(), "last_seen": group.last_seen, "sample": group.run_id}
|
|
212
|
+
)
|
|
213
|
+
entry["runs"].add(group.run_id)
|
|
214
|
+
if group.last_seen > entry["last_seen"]:
|
|
215
|
+
entry["last_seen"] = group.last_seen
|
|
216
|
+
entry["sample"] = group.run_id
|
|
217
|
+
pairs = []
|
|
218
|
+
for (job_id, line), entry in merged.items():
|
|
219
|
+
count = len(entry["runs"])
|
|
220
|
+
name = names.get(job_id) if job_id is not None else None
|
|
221
|
+
item = cls(
|
|
222
|
+
kind="error_group",
|
|
223
|
+
severity="error",
|
|
224
|
+
title=f"{count} run{'s' if count != 1 else ''} failed: {line}",
|
|
225
|
+
target=name,
|
|
226
|
+
since=assume_utc(entry["last_seen"]),
|
|
227
|
+
component_id=job_id,
|
|
228
|
+
component_kind="job" if name is not None else None,
|
|
229
|
+
run_id=entry["sample"],
|
|
230
|
+
)
|
|
231
|
+
pairs.append((count, item))
|
|
232
|
+
return [item for _, item in sorted(pairs, key=lambda pair: (-pair[0], pair[1].title))]
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_connections(cls, connections: list[Component]) -> list[AttentionItem]:
|
|
236
|
+
"""Flag the connections whose last renewal failed.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
connections: The organisation's connection rows.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
One error per connection carrying ``state.last_renewal_error``.
|
|
243
|
+
"""
|
|
244
|
+
items = []
|
|
245
|
+
for connection in connections:
|
|
246
|
+
error = (connection.state or {}).get("last_renewal_error")
|
|
247
|
+
if not error:
|
|
248
|
+
continue
|
|
249
|
+
items.append(
|
|
250
|
+
cls(
|
|
251
|
+
kind="connection",
|
|
252
|
+
severity="error",
|
|
253
|
+
title=f"{connection.name or connection.key} connection needs re-authorisation",
|
|
254
|
+
target=str(error).splitlines()[0],
|
|
255
|
+
since=connection.state_datetime("last_renewed_at"),
|
|
256
|
+
component_id=connection.id,
|
|
257
|
+
component_kind="connection",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return items
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def from_run_stacks(
|
|
264
|
+
cls, failed: list[Run], names: dict[UUID, str], since: dt.datetime, now: dt.datetime
|
|
265
|
+
) -> list[AttentionItem]:
|
|
266
|
+
"""Flag the stacks whose latest attempt is a retry that still failed.
|
|
267
|
+
|
|
268
|
+
Every stack counts, not only each job's newest one, so a partition
|
|
269
|
+
still failing after retries stays listed once a later run starts.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
failed: The latest attempt of each job stack whose latest attempt
|
|
273
|
+
failed, whether or not that attempt ever started.
|
|
274
|
+
names: Job names by id.
|
|
275
|
+
since: Only stacks that finished at or after this instant count.
|
|
276
|
+
now: Only stacks that finished by this instant count.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
One error per stack.
|
|
280
|
+
"""
|
|
281
|
+
items = []
|
|
282
|
+
for run in failed:
|
|
283
|
+
if run.status != "failed" or run.attempt <= 1 or run.completed_at is None:
|
|
284
|
+
continue
|
|
285
|
+
completed = assume_utc(run.completed_at)
|
|
286
|
+
if not since <= completed <= now:
|
|
287
|
+
continue
|
|
288
|
+
name = names.get(run.component_id) if run.component_id else None
|
|
289
|
+
items.append(
|
|
290
|
+
cls(
|
|
291
|
+
kind="run_stack",
|
|
292
|
+
severity="error",
|
|
293
|
+
title=f"Still failing after {run.attempt} attempts",
|
|
294
|
+
target=f"{name} · {run.partition_key}" if run.partition_key else name,
|
|
295
|
+
since=completed,
|
|
296
|
+
component_id=run.component_id,
|
|
297
|
+
component_kind="job",
|
|
298
|
+
run_id=run.id,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return items
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_drift(cls, statuses: list[tuple[Component, ComponentStatus]]) -> list[AttentionItem]:
|
|
305
|
+
"""Flag the components whose catalog status is not ``ok``.
|
|
306
|
+
|
|
307
|
+
An owned asset whose source is itself drifted is left out: it drifts
|
|
308
|
+
because its source does, and the source's one warning covers it.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
statuses: Every component row with its read status.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
One warning per drifted component whose parent, if any, is not drifted.
|
|
315
|
+
"""
|
|
316
|
+
drifted = {component.id for component, status in statuses if status is not ComponentStatus.OK}
|
|
317
|
+
return [
|
|
318
|
+
cls(
|
|
319
|
+
kind="drift",
|
|
320
|
+
severity="warning",
|
|
321
|
+
title=DRIFT_TITLES[status].format(name=component.name or component.key),
|
|
322
|
+
target=component.key,
|
|
323
|
+
component_id=component.id,
|
|
324
|
+
component_kind=component.kind,
|
|
325
|
+
)
|
|
326
|
+
for component, status in statuses
|
|
327
|
+
if status is not ComponentStatus.OK and component.parent_id not in drifted
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def from_overdue(cls, jobs: list[Component], now: dt.datetime) -> list[AttentionItem]:
|
|
332
|
+
"""Flag the enabled jobs whose next slot passed more than :data:`OVERDUE_AFTER` ago.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
jobs: The organisation's job rows.
|
|
336
|
+
now: The reference instant.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
One warning per overdue job.
|
|
340
|
+
"""
|
|
341
|
+
items = []
|
|
342
|
+
for job in jobs:
|
|
343
|
+
due = job.state_datetime("next_run_at")
|
|
344
|
+
if not job.enabled or due is None or now - due <= OVERDUE_AFTER:
|
|
345
|
+
continue
|
|
346
|
+
items.append(
|
|
347
|
+
cls(
|
|
348
|
+
kind="overdue",
|
|
349
|
+
severity="warning",
|
|
350
|
+
title=f"Scheduled run is {format_duration(now - due)} overdue",
|
|
351
|
+
target=job.name or job.key,
|
|
352
|
+
since=due,
|
|
353
|
+
component_id=job.id,
|
|
354
|
+
component_kind="job",
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
return items
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class UpcomingRun(BaseModel):
|
|
361
|
+
"""A job's next firing and the partition window it will cover."""
|
|
362
|
+
|
|
363
|
+
job_id: UUID
|
|
364
|
+
job_name: str
|
|
365
|
+
next_run_at: dt.datetime
|
|
366
|
+
start_key: str | None = None
|
|
367
|
+
end_key: str | None = None
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def from_jobs(cls, jobs: list[Component], granularities: dict[UUID, TimeGranularity | None]) -> list[UpcomingRun]:
|
|
371
|
+
"""List the enabled jobs by their next firing, each with the window that firing covers.
|
|
372
|
+
|
|
373
|
+
The window is resolved the way the scheduler resolves it: the job's
|
|
374
|
+
``lookback`` and ``offset`` over its targets' granularity, on the job
|
|
375
|
+
timezone's clock at the firing instant.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
jobs: The organisation's job rows.
|
|
379
|
+
granularities: Each job's target granularity by id; a job absent
|
|
380
|
+
from it, or mapped to ``None``, is unpartitioned.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Upcoming firings, soonest first; jobs without a stored next slot are absent.
|
|
384
|
+
"""
|
|
385
|
+
upcoming = []
|
|
386
|
+
for job in jobs:
|
|
387
|
+
next_run = job.state_datetime("next_run_at")
|
|
388
|
+
if not job.enabled or next_run is None:
|
|
389
|
+
continue
|
|
390
|
+
window = cls._window(job, granularities.get(job.id), next_run)
|
|
391
|
+
upcoming.append(
|
|
392
|
+
cls(
|
|
393
|
+
job_id=job.id,
|
|
394
|
+
job_name=job.name or job.key,
|
|
395
|
+
next_run_at=next_run,
|
|
396
|
+
start_key=window.granularity.format(window.start) if window else None,
|
|
397
|
+
end_key=window.granularity.format(window.end) if window else None,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
return sorted(upcoming, key=lambda run: run.next_run_at)
|
|
401
|
+
|
|
402
|
+
@classmethod
|
|
403
|
+
def _window(
|
|
404
|
+
cls, job: Component, granularity: TimeGranularity | None, fires_at: dt.datetime
|
|
405
|
+
) -> TimePartitionWindow | None:
|
|
406
|
+
"""Resolve the partition window a job's firing at *fires_at* will cover.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
job: The job row, whose config carries ``lookback``, ``offset`` and ``timezone``.
|
|
410
|
+
granularity: The granularity its targets share, ``None`` when unpartitioned.
|
|
411
|
+
fires_at: The firing instant, aware.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
The window, or ``None`` for an unpartitioned job, one whose lookback
|
|
415
|
+
is explicitly null, or one whose lookback or offset is out of range.
|
|
416
|
+
"""
|
|
417
|
+
config = job.config or {}
|
|
418
|
+
lookback = config.get("lookback", 1)
|
|
419
|
+
if not lookback or granularity is None:
|
|
420
|
+
return None
|
|
421
|
+
try:
|
|
422
|
+
return TimePartitionWindow.lookback(
|
|
423
|
+
fires_at.astimezone(job_zone(config.get("timezone"))),
|
|
424
|
+
lookback=lookback,
|
|
425
|
+
offset=config.get("offset", 1),
|
|
426
|
+
granularity=granularity,
|
|
427
|
+
)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class KindInventory(BaseModel):
|
|
433
|
+
"""How many components of one kind sit in each state."""
|
|
434
|
+
|
|
435
|
+
kind: str
|
|
436
|
+
total: int
|
|
437
|
+
healthy: int
|
|
438
|
+
failing: int
|
|
439
|
+
attention: int
|
|
440
|
+
disabled: int
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def from_statuses(
|
|
444
|
+
cls,
|
|
445
|
+
statuses: list[tuple[Component, ComponentStatus]],
|
|
446
|
+
failing_ids: set[UUID],
|
|
447
|
+
attention_ids: set[UUID],
|
|
448
|
+
) -> list[KindInventory]:
|
|
449
|
+
"""Count each kind's components by state, first match winning: disabled, failing, attention, healthy.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
statuses: Every component row with its read status.
|
|
453
|
+
failing_ids: Components whose latest execution, attempt or hook event failed.
|
|
454
|
+
attention_ids: Components needing attention for a reason other than drift.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
One row per kind in :data:`INVENTORY_KINDS`, in that order.
|
|
458
|
+
"""
|
|
459
|
+
counts: dict[str, Counter[str]] = {kind: Counter() for kind in INVENTORY_KINDS}
|
|
460
|
+
for component, status in statuses:
|
|
461
|
+
if component.kind not in counts:
|
|
462
|
+
continue
|
|
463
|
+
if not component.enabled:
|
|
464
|
+
state = "disabled"
|
|
465
|
+
elif component.id in failing_ids:
|
|
466
|
+
state = "failing"
|
|
467
|
+
elif status is not ComponentStatus.OK or component.id in attention_ids:
|
|
468
|
+
state = "attention"
|
|
469
|
+
else:
|
|
470
|
+
state = "healthy"
|
|
471
|
+
counts[component.kind][state] += 1
|
|
472
|
+
return [
|
|
473
|
+
cls(
|
|
474
|
+
kind=kind,
|
|
475
|
+
total=sum(tally.values()),
|
|
476
|
+
healthy=tally["healthy"],
|
|
477
|
+
failing=tally["failing"],
|
|
478
|
+
attention=tally["attention"],
|
|
479
|
+
disabled=tally["disabled"],
|
|
480
|
+
)
|
|
481
|
+
for kind, tally in counts.items()
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class OverviewResponse(BaseModel):
|
|
486
|
+
"""Everything the overview page draws, except the coverage calendar."""
|
|
487
|
+
|
|
488
|
+
generated_at: dt.datetime
|
|
489
|
+
runs: RunsSummary
|
|
490
|
+
activity: ActivitySummary
|
|
491
|
+
backfills: BackfillsSummary
|
|
492
|
+
jobs: JobsSummary
|
|
493
|
+
attention: list[AttentionItem]
|
|
494
|
+
upcoming: list[UpcomingRun]
|
|
495
|
+
recent: list[RunResponse]
|
|
496
|
+
components: list[KindInventory]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@dataclass(frozen=True)
|
|
500
|
+
class AssetCoverage:
|
|
501
|
+
"""One partitioned asset's evidence, and what the calendar expects of it.
|
|
502
|
+
|
|
503
|
+
Attributes:
|
|
504
|
+
asset: The asset row.
|
|
505
|
+
group: The row the asset counts toward on the calendar: its parent
|
|
506
|
+
source, or the asset itself when standalone.
|
|
507
|
+
partitioning: The asset's partitioning, from its catalog definition.
|
|
508
|
+
bounds: The days its attempted partitions span, all-time, or ``None``
|
|
509
|
+
when it was never attempted.
|
|
510
|
+
scheduled: Whether an enabled job targets the asset or its parent source.
|
|
511
|
+
rows: Its coverage rows for the window.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
asset: Component
|
|
515
|
+
group: Component
|
|
516
|
+
partitioning: TimePartitionConfig
|
|
517
|
+
bounds: PartitionBounds | None
|
|
518
|
+
scheduled: bool
|
|
519
|
+
rows: list[CoverageRow]
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def from_components(
|
|
523
|
+
cls,
|
|
524
|
+
components: list[Component],
|
|
525
|
+
partitionings: dict[UUID, TimePartitionConfig],
|
|
526
|
+
bounds: dict[UUID, PartitionBounds],
|
|
527
|
+
rows: list[CoverageRow],
|
|
528
|
+
) -> list[AssetCoverage]:
|
|
529
|
+
"""Pair each partitioned asset row with its group, partitioning, bounds and evidence.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
components: The organisation's source, asset and job rows, with
|
|
533
|
+
their outgoing relations loaded.
|
|
534
|
+
partitionings: Each partitioned asset's partitioning by id; an
|
|
535
|
+
asset absent from it (unpartitioned, drifted) is left out.
|
|
536
|
+
bounds: Each attempted asset's all-time bounds by id.
|
|
537
|
+
rows: The store's coverage rows for the window, from runs of any target.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
One entry per partitioned asset, in the order of *components*.
|
|
541
|
+
"""
|
|
542
|
+
by_id = {component.id: component for component in components}
|
|
543
|
+
targeted = {
|
|
544
|
+
relation.dst_id
|
|
545
|
+
for job in components
|
|
546
|
+
if job.kind == "job" and job.enabled
|
|
547
|
+
for relation in job.out_relations
|
|
548
|
+
if relation.name == "targets"
|
|
549
|
+
}
|
|
550
|
+
rows_by_asset: dict[UUID, list[CoverageRow]] = defaultdict(list)
|
|
551
|
+
for row in rows:
|
|
552
|
+
rows_by_asset[row.asset_id].append(row)
|
|
553
|
+
coverages = []
|
|
554
|
+
for asset in components:
|
|
555
|
+
if asset.kind != "asset" or asset.id not in partitionings:
|
|
556
|
+
continue
|
|
557
|
+
group = by_id.get(asset.parent_id) if asset.parent_id else asset
|
|
558
|
+
if group is None:
|
|
559
|
+
continue
|
|
560
|
+
coverages.append(
|
|
561
|
+
cls(
|
|
562
|
+
asset=asset,
|
|
563
|
+
group=group,
|
|
564
|
+
partitioning=partitionings[asset.id],
|
|
565
|
+
bounds=bounds.get(asset.id),
|
|
566
|
+
scheduled=asset.id in targeted or asset.parent_id in targeted,
|
|
567
|
+
rows=rows_by_asset[asset.id],
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
return coverages
|
|
571
|
+
|
|
572
|
+
def expected_span(self, now: dt.datetime) -> tuple[dt.date, dt.date] | None:
|
|
573
|
+
"""The days the asset owes partitions for, before clipping to a window.
|
|
574
|
+
|
|
575
|
+
The span starts at the declared ``start`` when there is one, else on
|
|
576
|
+
the first day of the earliest attempted partition. It ends on the last
|
|
577
|
+
day of the latest attempted partition; a scheduled asset that is
|
|
578
|
+
enabled, under an enabled group, also owes every period closed before
|
|
579
|
+
*now*: up to yesterday for a daily asset, the end of last month or
|
|
580
|
+
year for a monthly or yearly one, and the hours elapsed today for an
|
|
581
|
+
hourly one.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
now: The reference instant, aware UTC.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
The first and last day owed, inclusive, or ``None`` when nothing
|
|
588
|
+
is owed: no evidence and no declared start, or a declared start
|
|
589
|
+
with no evidence and no schedule.
|
|
590
|
+
"""
|
|
591
|
+
granularity = self.partitioning.granularity
|
|
592
|
+
start = self.partitioning.start
|
|
593
|
+
if start is not None:
|
|
594
|
+
first = start.date() if isinstance(start, dt.datetime) else start
|
|
595
|
+
else:
|
|
596
|
+
first = self.bounds.first if self.bounds else None
|
|
597
|
+
last = self.bounds.last if self.bounds else None
|
|
598
|
+
if self.scheduled and self.asset.enabled and self.group.enabled:
|
|
599
|
+
if granularity is TimeGranularity.HOUR:
|
|
600
|
+
closed = (now - dt.timedelta(hours=1)).date()
|
|
601
|
+
else:
|
|
602
|
+
closed = granularity.truncate(now.date()) - dt.timedelta(days=1)
|
|
603
|
+
last = closed if last is None else max(last, closed)
|
|
604
|
+
if first is None or last is None:
|
|
605
|
+
return None
|
|
606
|
+
return first, last
|
|
607
|
+
|
|
608
|
+
def slots(self, day: dt.date, now: dt.datetime) -> int:
|
|
609
|
+
"""How many partitions the asset owes on one day of its span.
|
|
610
|
+
|
|
611
|
+
An hourly asset owes the hours of the day from its declared start, when
|
|
612
|
+
the start falls on that day, to *now*'s hour, when the day is today
|
|
613
|
+
(the hours elapsed since midnight UTC), else to midnight: 24 on a
|
|
614
|
+
full day, at least one. Any other asset owes one slot a day.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
day: A day of the asset's expected span.
|
|
618
|
+
now: The reference instant, aware UTC.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
The slots owed, before raising to the partitions attempted that day.
|
|
622
|
+
"""
|
|
623
|
+
if self.partitioning.granularity is not TimeGranularity.HOUR:
|
|
624
|
+
return 1
|
|
625
|
+
start = self.partitioning.start
|
|
626
|
+
first_hour = start.hour if isinstance(start, dt.datetime) and start.date() == day else 0
|
|
627
|
+
end_hour = now.hour if day == now.date() else 24
|
|
628
|
+
return max(1, end_hour - first_hour)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class CoverageDay(BaseModel):
|
|
632
|
+
"""One group's asset-partitions on one day: expected, covered, failed."""
|
|
633
|
+
|
|
634
|
+
date: dt.date
|
|
635
|
+
source_id: UUID
|
|
636
|
+
expected: int
|
|
637
|
+
covered: int
|
|
638
|
+
failed: int
|
|
639
|
+
failed_run_id: UUID | None = None
|
|
640
|
+
|
|
641
|
+
@classmethod
|
|
642
|
+
def from_assets(
|
|
643
|
+
cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
|
|
644
|
+
) -> list[CoverageDay]:
|
|
645
|
+
"""Roll every asset's days onto its group, summing per group and day.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
assets: The partitioned assets with their evidence.
|
|
649
|
+
since: First day of the window.
|
|
650
|
+
until: Last day of the window, inclusive.
|
|
651
|
+
now: The reference instant, aware UTC.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
One entry per group and day with anything expected, by group id then day.
|
|
655
|
+
"""
|
|
656
|
+
merged: dict[tuple[UUID, dt.date], CoverageDay] = {}
|
|
657
|
+
for asset in assets:
|
|
658
|
+
for day in cls.from_asset(asset, since, until, now):
|
|
659
|
+
slot = (day.source_id, day.date)
|
|
660
|
+
merged[slot] = merged[slot]._plus(day) if slot in merged else day
|
|
661
|
+
return [merged[slot] for slot in sorted(merged)]
|
|
662
|
+
|
|
663
|
+
@classmethod
|
|
664
|
+
def from_asset(cls, asset: AssetCoverage, since: dt.date, until: dt.date, now: dt.datetime) -> list[CoverageDay]:
|
|
665
|
+
"""Roll one asset's partitions onto the days of its expected span, clipped to the window and today.
|
|
666
|
+
|
|
667
|
+
Each day owes the asset's :meth:`AssetCoverage.slots`, raised to the
|
|
668
|
+
partitions attempted on it when more. A
|
|
669
|
+
monthly or yearly key counts toward every day it spans. A partition
|
|
670
|
+
is covered once any execution succeeded, failed when one failed and
|
|
671
|
+
none succeeded; one attempted but still in flight, or canceled, is
|
|
672
|
+
neither, so it reads as missing, as does a day with nothing attempted.
|
|
673
|
+
The failed run kept for a day is the greatest id among its failed
|
|
674
|
+
runs, so the pick does not depend on row order.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
asset: The asset with its evidence.
|
|
678
|
+
since: First day of the window.
|
|
679
|
+
until: Last day of the window, inclusive.
|
|
680
|
+
now: The reference instant, aware UTC.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
One entry per day owed, oldest first, each carrying the asset's group id.
|
|
684
|
+
"""
|
|
685
|
+
today = now.date()
|
|
686
|
+
span = asset.expected_span(now)
|
|
687
|
+
if span is None:
|
|
688
|
+
return []
|
|
689
|
+
first, last = max(span[0], since), min(span[1], until, today)
|
|
690
|
+
if first > last:
|
|
691
|
+
return []
|
|
692
|
+
|
|
693
|
+
attempted: dict[dt.date, set[str]] = defaultdict(set)
|
|
694
|
+
covered: dict[dt.date, set[str]] = defaultdict(set)
|
|
695
|
+
failed: dict[dt.date, set[str]] = defaultdict(set)
|
|
696
|
+
failed_run: dict[dt.date, UUID] = {}
|
|
697
|
+
for row in asset.rows:
|
|
698
|
+
for day in cls._days_of(row.partition_key, first, last):
|
|
699
|
+
attempted[day].add(row.partition_key)
|
|
700
|
+
if row.succeeded:
|
|
701
|
+
covered[day].add(row.partition_key)
|
|
702
|
+
elif row.failed:
|
|
703
|
+
failed[day].add(row.partition_key)
|
|
704
|
+
if row.failed_run_id is not None:
|
|
705
|
+
failed_run[day] = max(failed_run.get(day, row.failed_run_id), row.failed_run_id)
|
|
706
|
+
|
|
707
|
+
return [
|
|
708
|
+
cls(
|
|
709
|
+
date=day,
|
|
710
|
+
source_id=asset.group.id,
|
|
711
|
+
expected=max(asset.slots(day, now), len(attempted[day])),
|
|
712
|
+
covered=len(covered[day]),
|
|
713
|
+
failed=len(failed[day]),
|
|
714
|
+
failed_run_id=failed_run.get(day),
|
|
715
|
+
)
|
|
716
|
+
for day in (first + dt.timedelta(days=i) for i in range((last - first).days + 1))
|
|
717
|
+
]
|
|
718
|
+
|
|
719
|
+
@classmethod
|
|
720
|
+
def _days_of(cls, key: str, since: dt.date, until: dt.date) -> list[dt.date]:
|
|
721
|
+
"""List the days a partition key spans, clipped to a range of days.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
key: A partition key of any granularity.
|
|
725
|
+
since: First day of the range.
|
|
726
|
+
until: Last day of the range, inclusive.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
The spanned days inside the range, oldest first; empty when the
|
|
730
|
+
partition lies outside it.
|
|
731
|
+
"""
|
|
732
|
+
span = PartitionBounds.from_keys([key])
|
|
733
|
+
first, last = max(span.first, since), min(span.last, until)
|
|
734
|
+
return [first + dt.timedelta(days=i) for i in range((last - first).days + 1)]
|
|
735
|
+
|
|
736
|
+
def _plus(self, other: CoverageDay) -> CoverageDay:
|
|
737
|
+
"""Sum this day with another of the same group and date.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
other: The other entry.
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
The summed entry, keeping the greater failed run id.
|
|
744
|
+
"""
|
|
745
|
+
failed_runs = [run_id for run_id in (self.failed_run_id, other.failed_run_id) if run_id is not None]
|
|
746
|
+
return self.model_copy(
|
|
747
|
+
update={
|
|
748
|
+
"expected": self.expected + other.expected,
|
|
749
|
+
"covered": self.covered + other.covered,
|
|
750
|
+
"failed": self.failed + other.failed,
|
|
751
|
+
"failed_run_id": max(failed_runs, default=None),
|
|
752
|
+
}
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class CoverageSource(BaseModel):
|
|
757
|
+
"""A calendar group: a source with its assets, or a standalone asset."""
|
|
758
|
+
|
|
759
|
+
id: UUID
|
|
760
|
+
name: str
|
|
761
|
+
kind: Literal["source", "asset"]
|
|
762
|
+
|
|
763
|
+
@classmethod
|
|
764
|
+
def from_component(cls, component: Component) -> CoverageSource:
|
|
765
|
+
"""Name a group after its row.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
component: The source row, or the standalone asset row.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
The group, named by the row's name or else its key.
|
|
772
|
+
"""
|
|
773
|
+
return cls(
|
|
774
|
+
id=component.id,
|
|
775
|
+
name=component.name or component.key,
|
|
776
|
+
kind="source" if component.kind == "source" else "asset",
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class CoverageResponse(BaseModel):
|
|
781
|
+
"""The coverage calendar's data over a window of days."""
|
|
782
|
+
|
|
783
|
+
since: dt.date
|
|
784
|
+
until: dt.date
|
|
785
|
+
sources: list[CoverageSource]
|
|
786
|
+
days: list[CoverageDay]
|
|
787
|
+
|
|
788
|
+
@classmethod
|
|
789
|
+
def from_assets(
|
|
790
|
+
cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
|
|
791
|
+
) -> CoverageResponse:
|
|
792
|
+
"""Build the calendar's data, listing only the groups with a day in the window.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
assets: The partitioned assets with their evidence.
|
|
796
|
+
since: First day of the window.
|
|
797
|
+
until: Last day of the window, inclusive.
|
|
798
|
+
now: The reference instant, aware UTC.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
The window, its groups by name, and their days by group (in that
|
|
802
|
+
order) then date.
|
|
803
|
+
"""
|
|
804
|
+
days = CoverageDay.from_assets(assets, since, until, now)
|
|
805
|
+
groups = {asset.group.id: asset.group for asset in assets}
|
|
806
|
+
sources = sorted(
|
|
807
|
+
(CoverageSource.from_component(groups[group_id]) for group_id in {day.source_id for day in days}),
|
|
808
|
+
key=lambda source: (source.name, str(source.id)),
|
|
809
|
+
)
|
|
810
|
+
rank = {source.id: i for i, source in enumerate(sources)}
|
|
811
|
+
days.sort(key=lambda day: (rank[day.source_id], day.date))
|
|
812
|
+
return cls(since=since, until=until, sources=sources, days=days)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# -- Endpoints -----------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@router.get("")
|
|
819
|
+
def get_overview(
|
|
820
|
+
user: ViewerDep,
|
|
821
|
+
org_id: OrgIdDep,
|
|
822
|
+
store: StoreDep,
|
|
823
|
+
now: Annotated[
|
|
824
|
+
dt.datetime | None,
|
|
825
|
+
Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
|
|
826
|
+
] = None,
|
|
827
|
+
) -> OverviewResponse:
|
|
828
|
+
"""Compose the overview page's data for the current organisation.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
user: The authenticated user, required to hold at least the ``viewer`` role.
|
|
832
|
+
org_id: The active organisation's UUID.
|
|
833
|
+
store: The Store instance.
|
|
834
|
+
now: Reference instant. A test seam: tests pin it so every window and
|
|
835
|
+
"overdue" reading is deterministic; clients omit it.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
The overview.
|
|
839
|
+
"""
|
|
840
|
+
now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
|
|
841
|
+
day_ago = now - dt.timedelta(hours=24)
|
|
842
|
+
|
|
843
|
+
# gather rows
|
|
844
|
+
components = store.components.list_all(org_id)
|
|
845
|
+
keys = {component.id: component.key for component in components}
|
|
846
|
+
statuses = [
|
|
847
|
+
(component, store.components.read(component, parent_key=keys.get(component.parent_id)).status)
|
|
848
|
+
for component in components
|
|
849
|
+
]
|
|
850
|
+
jobs = [component for component in components if component.kind == "job"]
|
|
851
|
+
enabled_jobs = [job for job in jobs if job.enabled]
|
|
852
|
+
names = {job.id: job.name or job.key for job in jobs}
|
|
853
|
+
running = store.runs.list_all(org_id, status="running", limit=10_000)
|
|
854
|
+
backfills = store.runs.list_backfills(org_id, active_only=True)
|
|
855
|
+
latest_by_job = {run.component_id: run for run in store.runs.latest_by_target(org_id, component_kind="job")}
|
|
856
|
+
failed_stacks = store.runs.list_all(
|
|
857
|
+
org_id, status="failed", component_kind="job", completed_after=day_ago, completed_before=now, limit=10_000
|
|
858
|
+
)
|
|
859
|
+
granularities = store.components.job_partition_granularities([job.id for job in enabled_jobs])
|
|
860
|
+
groups, _ = store.events.error_groups(org_id, event_types=FAILURE_EVENT_TYPES, since=day_ago, until=now)
|
|
861
|
+
hook_events = store.events.latest_by_component(
|
|
862
|
+
org_id, event_types=("hook_fired", "hook_failed"), since=now - HOOK_EVENTS_LOOKBACK
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# derive failing ids
|
|
866
|
+
failing_jobs = {job.id for job in enabled_jobs if (run := latest_by_job.get(job.id)) and run.status == "failed"}
|
|
867
|
+
failing_assets = {
|
|
868
|
+
execution.component_id
|
|
869
|
+
for execution in store.events.latest_executions(org_id)
|
|
870
|
+
if execution.status == "failed" and execution.component_id
|
|
871
|
+
}
|
|
872
|
+
failing_sources = {
|
|
873
|
+
component.parent_id
|
|
874
|
+
for component in components
|
|
875
|
+
if component.kind == "asset" and component.id in failing_assets and component.parent_id
|
|
876
|
+
}
|
|
877
|
+
failing_hooks = {event.component_id for event in hook_events if event.event_type == "hook_failed"}
|
|
878
|
+
failing_ids = {i for i in (*failing_jobs, *failing_assets, *failing_sources, *failing_hooks) if i is not None}
|
|
879
|
+
|
|
880
|
+
# build sections
|
|
881
|
+
attention = [
|
|
882
|
+
*AttentionItem.from_error_groups(groups, names),
|
|
883
|
+
*AttentionItem.from_connections([component for component in components if component.kind == "connection"]),
|
|
884
|
+
*AttentionItem.from_run_stacks(failed_stacks, names, day_ago, now),
|
|
885
|
+
*AttentionItem.from_drift(statuses),
|
|
886
|
+
*AttentionItem.from_overdue(jobs, now),
|
|
887
|
+
]
|
|
888
|
+
attention.sort(key=lambda item: (item.severity != "error", -(item.since.timestamp() if item.since else 0)))
|
|
889
|
+
attention_ids = {item.component_id for item in attention if item.kind == "connection" and item.component_id}
|
|
890
|
+
|
|
891
|
+
return OverviewResponse(
|
|
892
|
+
generated_at=now,
|
|
893
|
+
runs=RunsSummary.from_runs(
|
|
894
|
+
store.runs.list_all(
|
|
895
|
+
org_id, completed_after=day_ago, completed_before=now, all_attempts=True, limit=100_000
|
|
896
|
+
),
|
|
897
|
+
now,
|
|
898
|
+
),
|
|
899
|
+
activity=ActivitySummary.from_runs(running, store.runs.count(org_id, status="queued"), now),
|
|
900
|
+
backfills=BackfillsSummary.from_backfills(
|
|
901
|
+
backfills, store.runs.count_backfill_runs([backfill.id for backfill in backfills])
|
|
902
|
+
),
|
|
903
|
+
jobs=JobsSummary.from_jobs(enabled_jobs, failing_jobs),
|
|
904
|
+
attention=attention,
|
|
905
|
+
upcoming=UpcomingRun.from_jobs(jobs, granularities),
|
|
906
|
+
recent=[
|
|
907
|
+
RunResponse.from_run(run)
|
|
908
|
+
for run in store.runs.list_all(org_id, completed_before=now, sort="-completed_at", limit=RECENT_LIMIT)
|
|
909
|
+
],
|
|
910
|
+
components=KindInventory.from_statuses(statuses, failing_ids, attention_ids),
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
@router.get("/coverage")
|
|
915
|
+
def get_coverage(
|
|
916
|
+
user: ViewerDep,
|
|
917
|
+
org_id: OrgIdDep,
|
|
918
|
+
store: StoreDep,
|
|
919
|
+
since: dt.date,
|
|
920
|
+
until: dt.date,
|
|
921
|
+
now: Annotated[
|
|
922
|
+
dt.datetime | None,
|
|
923
|
+
Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
|
|
924
|
+
] = None,
|
|
925
|
+
) -> CoverageResponse:
|
|
926
|
+
"""Read per source and day coverage over a window of at most :data:`MAX_COVERAGE_SPAN_DAYS` days.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
user: The authenticated user, required to hold at least the ``viewer`` role.
|
|
930
|
+
org_id: The active organisation's UUID.
|
|
931
|
+
store: The Store instance.
|
|
932
|
+
since: First day of the window.
|
|
933
|
+
until: Last day of the window, inclusive.
|
|
934
|
+
now: Reference instant, which decides which periods have closed and
|
|
935
|
+
how much of today is owed. A test seam: tests pin it; clients omit it.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
The coverage calendar's data.
|
|
939
|
+
|
|
940
|
+
Raises:
|
|
941
|
+
HTTPException: 422 when the window is empty or spans more than :data:`MAX_COVERAGE_SPAN_DAYS` days.
|
|
942
|
+
"""
|
|
943
|
+
if until < since or (until - since).days + 1 > MAX_COVERAGE_SPAN_DAYS:
|
|
944
|
+
raise HTTPException(status_code=422, detail=f"The window must span 1 to {MAX_COVERAGE_SPAN_DAYS} days")
|
|
945
|
+
now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
|
|
946
|
+
assets = AssetCoverage.from_components(
|
|
947
|
+
store.components.list_all(org_id, kinds=["source", "asset", "job"]),
|
|
948
|
+
store.components.asset_partitionings(org_id),
|
|
949
|
+
store.events.partition_bounds(org_id),
|
|
950
|
+
store.events.coverage_rows(org_id, since, until),
|
|
951
|
+
)
|
|
952
|
+
return CoverageResponse.from_assets(assets, since, until, now)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Time zones and durations as the API presents them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def job_zone(name: str | None) -> dt.tzinfo:
|
|
10
|
+
"""Resolve a job's timezone name, falling back to UTC like the scheduler does.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
name: The IANA name from the job's config; ``None`` or an empty name
|
|
14
|
+
means UTC.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The zone, or UTC when the name is unknown or malformed.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
return ZoneInfo(name or "UTC")
|
|
21
|
+
except (ZoneInfoNotFoundError, ValueError, TypeError):
|
|
22
|
+
return dt.timezone.utc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_duration(delta: dt.timedelta) -> str:
|
|
26
|
+
"""Render a duration compactly, keeping its two largest units: ``2d 3h``, ``5h 12m`` or ``12m``.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
delta: The duration; seconds below a whole minute are dropped.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The compact label.
|
|
33
|
+
"""
|
|
34
|
+
minutes = int(delta.total_seconds() // 60)
|
|
35
|
+
days, minutes = divmod(minutes, 24 * 60)
|
|
36
|
+
hours, minutes = divmod(minutes, 60)
|
|
37
|
+
if days:
|
|
38
|
+
return f"{days}d {hours}h"
|
|
39
|
+
if hours:
|
|
40
|
+
return f"{hours}h {minutes}m"
|
|
41
|
+
return f"{minutes}m"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/notifications/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.96.0 → interloper_api-0.97.0}/src/interloper_api/notifications/invitations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|