Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 09:36:18

0001 """Host-side Snapper scope providers for the swf platform.
0002 
0003 Everything experiment-specific about the Snapper surfaces lives here
0004 and registers with the agnostic snapper_ai core (snapper_ai.registry):
0005 the epicprod and testbed scopes' curve extraction, labels and families,
0006 the panda / workflow / datataking component cards with their links into
0007 monitor pages, the testbed run-arc activity lanes, reference
0008 resolution, and the host service hooks (preferences, configuration,
0009 scheduler status, health page). Registered from MonitorAppConfig.ready().
0010 """
0011 
0012 from snapper_ai.presentation import (ET_ZONE, component_data, cut_chip,
0013                                      cut_delta, et_naive, span_text)
0014 from snapper_ai.registry import ScopeProvider, register, register_hooks
0015 
0016 # The host template rendering the provider card kinds below.
0017 CARD_TEMPLATE = 'monitor_app/_snapper_cards.html'
0018 
0019 
0020 # ── Testbed run arcs: the single source behind lanes and the cut ─────────
0021 
0022 def _namespace_run_arcs(start, end, dangle_seconds):
0023     """Per-namespace workflow-run arcs — THE single source behind both
0024     the activity lanes and the cut's instant lookup, so the two can
0025     never disagree. An arc is one run's full activity: first observed
0026     instant, the end of its datataking window, and its last recorded
0027     activity (the processing tail), with workflow identity resolved
0028     through the execution record. The state-event record supplies the
0029     full arc where it exists; the universal run record covers every
0030     other workflow. Returns namespace → [arc, ...] ordered by start;
0031     every registered namespace is present."""
0032     from django.db.models import Q
0033     from django.db.models.fields.json import KeyTextTransform
0034 
0035     from .models import Run, RunState, SystemStateEvent
0036     from .workflow_models import Namespace, WorkflowExecution
0037 
0038     events = list(
0039         SystemStateEvent.objects
0040         .filter(timestamp__gte=start, timestamp__lte=end)
0041         .values('run_number', 'timestamp', 'event_type', 'event_data'))
0042 
0043     runs = {}
0044     for event in events:
0045         run = runs.setdefault(event['run_number'], {
0046             'first': event['timestamp'], 'last': event['timestamp'],
0047             'end_run': None, 'execution': ''})
0048         run['first'] = min(run['first'], event['timestamp'])
0049         run['last'] = max(run['last'], event['timestamp'])
0050         if event['event_type'] == 'end_run':
0051             run['end_run'] = event['timestamp']
0052         if not run['execution']:
0053             data = event['event_data']
0054             if isinstance(data, dict) and data.get('execution_id'):
0055                 run['execution'] = str(data['execution_id'])
0056 
0057     run_executions = dict(
0058         RunState.objects
0059         .annotate(execution_key=KeyTextTransform(
0060             'execution_id', 'metadata'))
0061         .exclude(execution_key__isnull=True)
0062         .values_list('run_number', 'execution_key'))
0063     for row in (Run.objects
0064                 .filter(Q(end_time__gte=start) | Q(end_time__isnull=True),
0065                         start_time__lte=end)
0066                 .values('run_number', 'start_time', 'end_time')):
0067         if row['run_number'] in runs:
0068             continue
0069         run = {'first': max(row['start_time'], start),
0070                'end_run': row['end_time'],
0071                'execution': run_executions.get(row['run_number'], '')}
0072         if row['end_time'] is not None:
0073             run['last'] = min(row['end_time'], end)
0074         elif ((end - row['start_time']).total_seconds()
0075                 <= dangle_seconds):
0076             run['last'] = end
0077             run['end_run'] = None
0078         else:
0079             if row['start_time'] < start:
0080                 continue
0081             run['last'] = run['first']
0082             run['end_run'] = None
0083         runs[row['run_number']] = run
0084 
0085     namespaces = dict(
0086         WorkflowExecution.objects
0087         .filter(execution_id__in={run['execution']
0088                                   for run in runs.values()
0089                                   if run['execution']})
0090         .values_list('execution_id', 'namespace'))
0091 
0092     arcs = {name: [] for name in
0093             Namespace.objects.values_list('name', flat=True)}
0094     for run_number, run in sorted(runs.items(),
0095                                   key=lambda item: item[1]['first']):
0096         execution = run['execution']
0097         parts = execution.rsplit('-', 2)
0098         namespace = namespaces.get(execution) or 'unknown'
0099         arcs.setdefault(namespace, []).append({
0100             'run_number': run_number,
0101             'workflow': parts[0] if len(parts) == 3 else 'workflow',
0102             'execution': execution,
0103             'first': run['first'],
0104             'end_run': run['end_run'],
0105             'last': run['last'],
0106             'dangling': (run['end_run'] is None
0107                          and (end - run['last']).total_seconds()
0108                          > dangle_seconds),
0109         })
0110     return arcs
0111 
0112 
0113 def namespace_activity_at(instant, dangle_seconds=12 * 3600):
0114     """Per-namespace datataking truth at one instant, classified from
0115     the same arcs the activity lanes draw. Returns namespace → {phase,
0116     run_number, workflow, execution_id, since} with phase one of
0117     'datataking', 'processing', 'idle'."""
0118     from datetime import timedelta
0119 
0120     arcs = _namespace_run_arcs(instant - timedelta(days=30),
0121                                instant + timedelta(days=30),
0122                                dangle_seconds)
0123     out = {}
0124     for namespace, runs in arcs.items():
0125         entry = {'phase': 'idle', 'run_number': None, 'workflow': '',
0126                  'execution_id': '', 'since': None}
0127         current = None
0128         for arc in runs:
0129             if arc['first'] <= instant:
0130                 current = arc
0131         if current is not None:
0132             entry.update({'run_number': current['run_number'],
0133                           'workflow': current['workflow'],
0134                           'execution_id': current['execution']})
0135             if (current['end_run'] is not None
0136                     and instant <= current['end_run']):
0137                 entry.update({'phase': 'datataking',
0138                               'since': current['first']})
0139             elif instant <= current['last']:
0140                 entry.update(
0141                     {'phase': ('processing'
0142                                if current['end_run'] is not None
0143                                else 'datataking'),
0144                      'since': current['end_run'] or current['first']})
0145             else:
0146                 entry.update({'phase': 'idle', 'since': current['last']})
0147         out[namespace] = entry
0148     return out
0149 
0150 
0151 def _arc_summary(exec_row):
0152     """One-line story for the numbered activity table, from the
0153     execution record: STF volume and the decision-box plan."""
0154     if not exec_row:
0155         return ''
0156     params, executed_by = exec_row
0157     sim = params.get('simulation') or {}
0158     pp = params.get('prompt_processing') or {}
0159     bits = []
0160     try:
0161         bits.append(f"{int(sim.get('stf_count')) * int(sim.get('physics_period_count'))} STF")
0162     except (TypeError, ValueError):
0163         pass
0164     if pp.get('decision_box_enabled'):
0165         sites = ', '.join(str(s) for s in (pp.get('decision_box_sites') or []))
0166         policy = str(pp.get('decision_box_policy') or '')
0167         bits.append(f'decision box ({policy}) → {sites}')
0168     if executed_by:
0169         bits.append(f'by {executed_by}')
0170     return ' · '.join(bits)
0171 
0172 
0173 def _run_activity_lanes(start, end, dangle_seconds):
0174     """Activity lane segments rendered from the shared per-namespace run
0175     arcs: a solid datataking tile opening into a lighter processing
0176     tail, hatched when the run never recorded an end. Idle namespaces
0177     keep an empty lane (a grey track on the plot). Segments carry the
0178     run number as the activity key, with a flag and story summary on
0179     the leading tile for the numbered activity table."""
0180     from .workflow_models import WorkflowExecution
0181     arcs_by_namespace = _namespace_run_arcs(start, end, dangle_seconds)
0182     exec_ids = {arc['execution'] for runs in arcs_by_namespace.values()
0183                 for arc in runs if arc['execution']}
0184     exec_rows = {
0185         row[0]: (row[1] or {}, row[2] or '')
0186         for row in WorkflowExecution.objects
0187         .filter(execution_id__in=exec_ids)
0188         .values_list('execution_id', 'parameter_values', 'executed_by')}
0189     lanes = {}
0190     for namespace, runs in arcs_by_namespace.items():
0191         segments = lanes.setdefault(namespace, [])
0192         for arc in runs:
0193             ident = (f"{arc['workflow']} · run {arc['run_number']}"
0194                      + (f" · {arc['execution']}" if arc['execution']
0195                         else ''))
0196             started = arc['first'].astimezone(ET_ZONE).strftime(
0197                 '%m-%d %H:%M ET')
0198             flag = f"{arc['workflow']} · run {arc['run_number']}"
0199             key = str(arc['run_number'])
0200             summary = _arc_summary(exec_rows.get(arc['execution']))
0201             if arc['dangling']:
0202                 segments.append({
0203                     't0': et_naive(arc['first']),
0204                     't1': et_naive(arc['last']), 'value': 'run',
0205                     'hover': (f'{ident} — started {started}, no '
0206                               'recorded end; last activity '
0207                               + arc['last'].astimezone(
0208                                   ET_ZONE).strftime('%m-%d %H:%M ET')),
0209                     'open_end': True, 'flag': flag, 'key': key,
0210                     'summary': summary})
0211                 continue
0212             datataking_end = arc['end_run'] or arc['last']
0213             total = span_text(
0214                 (arc['last'] - arc['first']).total_seconds())
0215             hover = f'{ident} — started {started}, active {total}'
0216             segments.append({
0217                 't0': et_naive(arc['first']),
0218                 't1': et_naive(datataking_end), 'value': 'run',
0219                 'hover': f'{hover} · datataking window',
0220                 'open_end': False, 'flag': flag, 'key': key,
0221                 'summary': summary})
0222             if arc['last'] > datataking_end:
0223                 segments.append({
0224                     't0': et_naive(datataking_end),
0225                     't1': et_naive(arc['last']), 'value': 'processing',
0226                     'hover': f'{hover} · processing tail',
0227                     'open_end': False, 'key': key})
0228     return lanes
0229 
0230 
0231 # ── Curve extraction and vocabulary ──────────────────────────────────────
0232 
0233 # The categorization lenses of CAMPAIGN_DELIVERY.md: read-time
0234 # projections of the PC leaves into group curves. 'seg' is the
0235 # curve-id segment, 'value' the URL selector value. The requestors
0236 # labeling holds PWG and DSC labels in one multi-membership list — the
0237 # data does not distinguish them, so they are one lens.
0238 DELIVERY_LENSES = (
0239     {'seg': 'cat', 'value': 'category', 'label': 'physics category'},
0240     {'seg': 'req', 'value': 'requestor', 'label': 'PWG/DSC'},
0241 )
0242 
0243 _PC_CACHE = {'at': None, 'requestors': {}, 'keys': {},
0244              'categories': {}, 'group_names': {}}
0245 
0246 
0247 def _group_slug(name):
0248     import re
0249     return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
0250 
0251 
0252 def _pc_cache():
0253     """pc label -> requestor labels, physics category, and identity
0254     key, cached briefly: series assembly calls curve extraction once
0255     per snap, and lens membership is current-state (lenses apply
0256     retroactively). group_names maps curve-id slugs back to display
0257     names across every lens."""
0258     from django.utils import timezone
0259 
0260     from pcs.models import PhysicsConfig
0261 
0262     now = timezone.now()
0263     if (_PC_CACHE['at'] is None
0264             or (now - _PC_CACHE['at']).total_seconds() > 60):
0265         requestors, keys, categories = {}, {}, {}
0266         for label, groups, key, category in (
0267                 PhysicsConfig.objects.values_list(
0268                     'label', 'requestors', 'config_key',
0269                     'physics_tag__category__name')):
0270             requestors[label] = list(groups or [])
0271             keys[label] = key
0272             categories[label] = category or 'Uncategorized'
0273         group_names = {}
0274         for name in set(categories.values()):
0275             group_names[_group_slug(name)] = name
0276         for groups in requestors.values():
0277             for name in groups:
0278                 group_names[_group_slug(name)] = name
0279         for name in ('Unassigned', 'Uncategorized'):
0280             group_names[_group_slug(name)] = name
0281         _PC_CACHE.update({'requestors': requestors, 'keys': keys,
0282                           'categories': categories,
0283                           'group_names': group_names, 'at': now})
0284     return _PC_CACHE
0285 
0286 
0287 def _lens_groups(pc, lens_seg, cache):
0288     """The lens groups one PC's leaf sums into (N-way for requestors;
0289     the empty labeling gets its stated bucket, never silence)."""
0290     if lens_seg == 'cat':
0291         return [cache['categories'].get(pc) or 'Uncategorized']
0292     return cache['requestors'].get(pc) or ['Unassigned']
0293 
0294 
0295 def _delivery_curve_values(state):
0296     """Delivery curves from the DAILY arrivals record only (leaves
0297     carrying arrived_files): lens-group daily bumps (the quilt) and
0298     lens-group cumulative series with a total, per lens, both on the
0299     registered basis throughout. The live placed-basis component feeds
0300     cut cards, never curves, so the plotted series ends at the last
0301     complete day. Events emit in MILLIONS — the family titles carry
0302     (M); files stay raw counts. Unmeasured coverage is stated on the
0303     cut card, never silently mixed into the event sums."""
0304     values = {}
0305     delivery = component_data(state, 'delivery')
0306     cache = _pc_cache() if delivery.get('campaigns') else None
0307     for campaign, block in (delivery.get('campaigns') or {}).items():
0308         totals = block.get('totals') or {}
0309         if 'arrived_files' not in totals:
0310             continue  # the live placed-basis component: cards only
0311         tag = campaign.replace('.', '_')
0312         # The quilt stays PER-CONFIGURATION — one patch color per PC,
0313         # production bursts attributed — the lens only clusters the
0314         # tick boxes and the cut card. Cumulative draws at lens-group
0315         # level: a handful of lines, plus the total.
0316         for pc, leaf in (block.get('leaves') or {}).items():
0317             arrived_events = int(leaf.get('arrived_events') or 0)
0318             arrived_files = int(leaf.get('arrived_files') or 0)
0319             if arrived_events:
0320                 values[f'dlvq_{tag}_{pc}'] = round(
0321                     arrived_events / 1e6, 2)
0322             if arrived_files:
0323                 values[f'dlvqf_{tag}_{pc}'] = arrived_files
0324         for lens in DELIVERY_LENSES:
0325             seg = lens['seg']
0326             cum_e, cum_f = {}, {}
0327             for pc, leaf in (block.get('leaves') or {}).items():
0328                 for group in _lens_groups(pc, seg, cache):
0329                     slug = _group_slug(group)
0330                     cum_e[slug] = (cum_e.get(slug, 0)
0331                                    + int(leaf.get('events') or 0))
0332                     cum_f[slug] = (cum_f.get(slug, 0)
0333                                    + int(leaf.get('cum_files') or 0))
0334             for slug, v in cum_e.items():
0335                 if v:
0336                     values[f'dlvc_{seg}_{tag}_{slug}'] = round(v / 1e6, 2)
0337             for slug, v in cum_f.items():
0338                 if v:
0339                     values[f'dlvcf_{seg}_{tag}_{slug}'] = v
0340             values[f'dlvc_{seg}_{tag}__total'] = round(
0341                 int(totals.get('events') or 0) / 1e6, 2)
0342             values[f'dlvcf_{seg}_{tag}__total'] = int(
0343                 totals.get('cum_files') or 0)
0344     return values
0345 
0346 
0347 def _site_curve_values(panda):
0348     """Per-site job lifecycle curves from the sites maps recorded in
0349     every snap: the in-flight population by status (submission through
0350     queueing to execution), running cores, and the trailing-24h
0351     finished/failed outcomes. Site names carry underscores, so the
0352     status is always the id's last segment."""
0353     values = {}
0354     for site, block in ((panda.get('jobs') or {}).get('sites')
0355                         or {}).items():
0356         for status, count in (block.get('by_status_now') or {}).items():
0357             if status == 'starting':
0358                 continue
0359             values[f'sj_{site}_{status}'] = int(count or 0)
0360         if block.get('running_cores_now') is not None:
0361             values[f'sjc_{site}'] = int(
0362                 block.get('running_cores_now') or 0)
0363         # Cumulative terminal counters (raw absolute values; the
0364         # window-relative families rebase them at render).
0365         cum = block.get('cum') or {}
0366         if 'finished' in cum:
0367             values[f'sjfw_{site}'] = int(cum.get('finished') or 0)
0368         if 'failed' in cum:
0369             values[f'sjxw_{site}'] = int(cum.get('failed') or 0)
0370         for cls, count in (block.get('cum_failed_by_class')
0371                            or {}).items():
0372             values[f'sjxc_{site}_{cls}'] = int(count or 0)
0373     for site, block in ((panda.get('tasks') or {}).get('sites')
0374                         or {}).items():
0375         for status, count in (block.get('by_status_now') or {}).items():
0376             values[f'stt_{site}_{status}'] = int(count or 0)
0377     return values
0378 
0379 
0380 def _epicprod_curve_values(state):
0381     values = {}
0382     panda = component_data(state, 'panda')
0383     jobs = panda.get('jobs') or {}
0384     jobs_now = jobs.get('in_flight_now') or {}
0385     tasks_now = (panda.get('tasks') or {}).get('in_flight_now') or {}
0386     if jobs_now:
0387         values['running_cores'] = int(jobs_now.get('running_cores') or 0)
0388         for status, count in (jobs_now.get('by_status') or {}).items():
0389             if status == 'starting':
0390                 continue
0391             values[f'job_{status}'] = int(count or 0)
0392         type_states = jobs_now.get('by_type_status') or {}
0393         for ptype, count in (jobs_now.get('by_type') or {}).items():
0394             states = type_states.get(ptype) or {}
0395             waiting = sum(int(states.get(status) or 0)
0396                           for status in ('activated', 'starting'))
0397             values[f'type_{ptype}'] = max(0, int(count or 0) - waiting)
0398         for ptype, states in type_states.items():
0399             for status, count in (states or {}).items():
0400                 if status in ('activated', 'starting'):
0401                     continue
0402                 values[f'ts_{ptype}_{status}'] = int(count or 0)
0403     for status in ('finished', 'failed'):
0404         if status in (jobs.get('cum') or {}):
0405             values[f'outcome_{status}'] = int(
0406                 jobs['cum'].get(status) or 0)
0407     if tasks_now:
0408         for status, count in (tasks_now.get('by_status') or {}).items():
0409             if status in ('defined', 'ready'):
0410                 continue
0411             values[f'task_{status}'] = int(count or 0)
0412     values.update(_site_curve_values(panda))
0413     values.update(_delivery_curve_values(state))
0414     return values
0415 
0416 
0417 def _testbed_curve_values(state):
0418     values = {}
0419     workflow = component_data(state, 'workflow')
0420     executions = workflow.get('executions') or {}
0421     stf_tasks = workflow.get('stf_tasks') or {}
0422     if executions:
0423         values['wf_active'] = int(executions.get('active') or 0)
0424     if stf_tasks:
0425         values['stf_total'] = int(stf_tasks.get('in_flight_total') or 0)
0426         for key, count in (stf_tasks.get('by_site_status') or {}).items():
0427             site, _, status = str(key).partition('/')
0428             values[f'sts_{site}_{status}'] = int(count or 0)
0429     return values
0430 
0431 
0432 def _delivery_curve_parts(curve_id):
0433     """(campaign, remainder) from a per-PC arrivals curve id
0434     (dlvq_26_07_pc12); campaign tags serialize dots as underscores."""
0435     remainder = curve_id.split('_', 1)[1]
0436     tag, _, rest = remainder.partition('_')
0437     while rest and rest[0].isdigit():
0438         extra, _, rest = rest.partition('_')
0439         tag = f'{tag}_{extra}'
0440     return tag.replace('_', '.'), rest.strip('_')
0441 
0442 
0443 def _delivery_lens_parts(curve_id):
0444     """(lens_seg, campaign, group_slug) from a lens-group cumulative
0445     curve id (dlvc_cat_26_07_single_particle)."""
0446     remainder = curve_id.split('_', 1)[1]
0447     seg, _, rest = remainder.partition('_')
0448     tag, _, rest = rest.partition('_')
0449     while rest and rest[0].isdigit():
0450         extra, _, rest = rest.partition('_')
0451         tag = f'{tag}_{extra}'
0452     return seg, tag.replace('_', '.'), rest.strip('_')
0453 
0454 
0455 def _epicprod_curve_color(curve_id):
0456     """House state colors for status-bearing curves — one state-color
0457     vocabulary on every surface, red only where failure lives. Curves
0458     without semantic color (cores, types, deliveries) take the
0459     palette deal. Type-by-state curves stay on the palette too:
0460     several types sharing one status must stay distinguishable."""
0461     from .panda.constants import JOB_STATE_COLORS, TASK_STATE_COLORS
0462 
0463     # Operator-set colors on every jobs panel: the running pair reads
0464     # as blues — cores strong, running jobs lighter — and activated is
0465     # grey context rather than health/completion green.
0466     if curve_id == 'running_cores':
0467         return '#1565c0'
0468     if curve_id.startswith('sjfw_'):
0469         return JOB_STATE_COLORS.get('activated')
0470     if curve_id.startswith('sjxw_'):
0471         return JOB_STATE_COLORS.get('failed')
0472     if curve_id.startswith('sjxc_'):
0473         return _FAILURE_CLASS_COLORS.get(
0474             curve_id.rsplit('_', 1)[1], '#424242')
0475     if curve_id.startswith('sjc_'):
0476         return '#1565c0'
0477     if curve_id.startswith('sj_'):
0478         status = curve_id.rsplit('_', 1)[1]
0479         if status == 'running':
0480             return '#64b5f6'
0481         if status == 'sent':
0482             return '#6a1b9a'
0483         if status == 'activated':
0484             # Grey: the queued pool is context, not the story — the
0485             # greens belong to completion and the blues to running.
0486             return '#8a8a8a'
0487         return JOB_STATE_COLORS.get(status)
0488     if curve_id.startswith('job_'):
0489         status = curve_id[4:]
0490         if status == 'running':
0491             return '#64b5f6'
0492         if status == 'sent':
0493             return '#6a1b9a'
0494         if status == 'activated':
0495             return '#8a8a8a'
0496         return JOB_STATE_COLORS.get(status)
0497     if curve_id.startswith('outcome_'):
0498         return JOB_STATE_COLORS.get(curve_id[8:])
0499     if curve_id.startswith('stt_'):
0500         status = curve_id.rsplit('_', 1)[1]
0501         if status == 'running':
0502             # 'Running' is light blue on the site plots — jobs and
0503             # tasks alike; green belongs to completion.
0504             return '#64b5f6'
0505         return TASK_STATE_COLORS.get(status)
0506     if curve_id.startswith('task_'):
0507         return TASK_STATE_COLORS.get(curve_id[5:])
0508     return None
0509 
0510 
0511 def _epicprod_curve_label(curve_id):
0512     # Per-site curves: the family title names the site; the curve
0513     # label is the lifecycle stage alone. 'running' says 'running
0514     # jobs' — 'running cores' sits beside it and the bare word is
0515     # ambiguous.
0516     if curve_id.startswith('sjc_'):
0517         return 'running cores'
0518     if curve_id.startswith('sjfw_'):
0519         return 'finished'
0520     if curve_id.startswith('sjxw_'):
0521         return 'failed'
0522     if curve_id.startswith('sjxc_'):
0523         # Failure-class curves: the class is the last id segment.
0524         return curve_id.rsplit('_', 1)[1]
0525     if curve_id.startswith('sj_'):
0526         status = curve_id.rsplit('_', 1)[1]
0527         return 'running jobs' if status == 'running' else status
0528     if curve_id.startswith('stt_'):
0529         return curve_id.rsplit('_', 1)[1]
0530     if curve_id.startswith(('dlvq_', 'dlvqf_')):
0531         _campaign, pc = _delivery_curve_parts(curve_id)
0532         key = _pc_cache()['keys'].get(pc, '')
0533         return f'{pc} {key}' if key else pc
0534     if curve_id.startswith(('dlvc_', 'dlvcf_')):
0535         # The line states the group; the family header states
0536         # campaign, kind, and unit.
0537         _seg, _campaign, slug = _delivery_lens_parts(curve_id)
0538         if slug in ('', 'total'):
0539             return 'total'
0540         return _pc_cache()['group_names'].get(slug, slug)
0541     if curve_id == 'running_cores':
0542         return 'running cores'
0543     if curve_id.startswith('job_'):
0544         return f'jobs {curve_id[4:]}'
0545     if curve_id.startswith('outcome_'):
0546         return curve_id[8:]
0547     if curve_id.startswith('task_'):
0548         return f'tasks {curve_id[5:]}'
0549     # Curves in the in-flight families are in flight by construction —
0550     # the family title says it once; per-curve qualifiers are noise.
0551     if curve_id.startswith('type_'):
0552         return curve_id[5:]
0553     if curve_id.startswith('ts_'):
0554         remainder = curve_id[3:]
0555         ptype, _, status = remainder.rpartition('_')
0556         return f'{ptype} · {status}' if ptype else remainder
0557     return None
0558 
0559 
0560 def _testbed_curve_label(curve_id):
0561     if curve_id == 'wf_active':
0562         return 'workflow executions (running)'
0563     if curve_id == 'stf_total':
0564         return 'STF tasks total'
0565     if curve_id.startswith('sts_'):
0566         remainder = curve_id[4:]
0567         site, _, status = remainder.rpartition('_')
0568         return f'{site} · {status}' if site else remainder
0569     return None
0570 
0571 
0572 EPICPROD_GROUPS = (
0573     {'name': 'In-flight jobs', 'title': 'Jobs', 'prefixes': ['job_'],
0574      'ids': ['running_cores'], 'default_off_ids': ['job_activated']},
0575     {'name': 'Job outcomes', 'prefixes': ['outcome_'], 'ids': [],
0576      'order': ['outcome_finished', 'outcome_failed'],
0577      'window_relative': True},
0578     {'name': 'Tasks', 'prefixes': ['task_'], 'ids': []},
0579     {'name': 'In-flight job types', 'title': 'Job types',
0580      'prefixes': ['type_'], 'ids': []},
0581     {'name': 'Type × state', 'prefixes': ['ts_'], 'ids': []},
0582 )
0583 
0584 
0585 _CAMPAIGN_START_CACHE = {'at': None, 'starts': {}}
0586 
0587 
0588 def _campaign_delivery_starts():
0589     """Campaign name -> first recorded delivery activity, from the
0590     daily delivery snaps (small, bounded read), cached for an hour.
0591     The campaign focus view clamps its window here: the day count runs
0592     from when the campaign began delivering, never into the void
0593     before it."""
0594     from django.utils import timezone
0595 
0596     from snapper_ai.models import SystemSnap
0597 
0598     now = timezone.now()
0599     if (_CAMPAIGN_START_CACHE['at'] is not None
0600             and (now - _CAMPAIGN_START_CACHE['at']).total_seconds() < 3600):
0601         return _CAMPAIGN_START_CACHE['starts']
0602     starts = {}
0603     rows = (SystemSnap.objects
0604             .filter(scope='epicprod',
0605                     capture_policy__in=('backfill-v1', 'delivery-daily-v1'))
0606             .order_by('snap_time')
0607             .values_list('snap_time', 'state'))
0608     for snap_time, state in rows:
0609         campaigns = (((state or {}).get('components') or {})
0610                      .get('delivery') or {}).get('data') or {}
0611         for name, block in (campaigns.get('campaigns') or {}).items():
0612             if name in starts:
0613                 continue
0614             totals = block.get('totals') or {}
0615             if int(totals.get('cum_files')
0616                    or totals.get('files') or 0) > 0:
0617                 starts[name] = snap_time
0618     _CAMPAIGN_START_CACHE['starts'] = starts
0619     _CAMPAIGN_START_CACHE['at'] = now
0620     return starts
0621 
0622 
0623 def _delivery_focus_view():
0624     """The Campaign focus tab: the report narrowed to one campaign's
0625     delivery — its family only, the delivery card in the cut, the
0626     window floored at the campaign's first delivery."""
0627     from datetime import timedelta
0628 
0629     try:
0630         from swf_epicprod.analytics.rollup import resolve_target_campaigns
0631         campaigns = sorted(resolve_target_campaigns(), reverse=True)
0632     except Exception:                                       # noqa: BLE001
0633         return None
0634     if not campaigns:
0635         return None
0636     starts = _campaign_delivery_starts()
0637     return {
0638         'param': 'campaign',
0639         'label': 'Campaign',
0640         # The natural campaign span carries thousands of unrelated
0641         # scope snaps and curves. Persist the selected campaign
0642         # families as their own small product for immediate display.
0643         'cache_series': True,
0644         'default': campaigns[0],
0645         # Two selector axes: the plotted quantity (files is the
0646         # default while the measured event rates are under review;
0647         # events, in millions, stays one click away) and the grouping
0648         # lens the quilt factorizes by.
0649         'selectors': [
0650             {'param': 'quantity', 'label': 'Counting',
0651              'default': 'files',
0652              'choices': [{'value': 'files', 'label': 'files'},
0653                          {'value': 'events', 'label': 'events'}]},
0654             {'param': 'lens', 'label': 'Grouping',
0655              'default': 'category',
0656              'choices': [{'value': lens['value'],
0657                           'label': lens['label']}
0658                          for lens in DELIVERY_LENSES]},
0659         ],
0660         'options': [
0661             {'value': name, 'label': name,
0662              # The arrivals family is the same per-PC quilt under
0663              # every lens — the lens clusters its tick boxes;
0664              # cumulative swaps to the lens's group lines.
0665              'families_by': {
0666                  f'{quantity}|{lens["value"]}': [
0667                      f'Arrivals {name} {quantity}',
0668                      f'Cumulative {name} {quantity} {lens["value"]}',
0669                  ]
0670                  for quantity in ('files', 'events')
0671                  for lens in DELIVERY_LENSES},
0672              'component': 'delivery',
0673              'collapse_below': 0.01,
0674              'start': (starts[name] - timedelta(hours=12))
0675                       if name in starts else None}
0676             for name in campaigns],
0677     }
0678 
0679 
0680 def _pc_tick_groupings():
0681     """pc label -> {lens value: [group display names]} for every PC —
0682     the client clusters the per-PC quilt's tick boxes by the active
0683     lens with this; a box toggles its PC set, the PC colors stand."""
0684     cache = _pc_cache()
0685     return {
0686         pc: {lens['value']: _lens_groups(pc, lens['seg'], cache)
0687              for lens in DELIVERY_LENSES}
0688         for pc in cache['keys']
0689     }
0690 
0691 
0692 def _delivery_groups():
0693     """Curve families per target campaign: the PER-PC daily arrivals
0694     quilt per quantity (one patch color per configuration — the basis
0695     of the display; tick boxes cluster by the active lens via
0696     pc_groups) and the lens-group cumulative series per quantity ×
0697     lens (off by default — the quilt is the display). The unique
0698     registry name carries selector qualifiers; the display title does
0699     not. A resolution failure yields no delivery families rather than
0700     blocking registration."""
0701     try:
0702         from swf_epicprod.analytics.rollup import resolve_target_campaigns
0703         campaigns = resolve_target_campaigns()
0704     except Exception:                                       # noqa: BLE001
0705         return ()
0706     pc_groups = _pc_tick_groupings()
0707     groups = []
0708     for name in campaigns:
0709         tag = name.replace('.', '_')
0710         groups.append({
0711             'name': f'Arrivals {name} files',
0712             'title': f'Arrivals {name}',
0713             'prefixes': [f'dlvqf_{tag}_'], 'ids': [],
0714             'stacked': True, 'pc_groups': pc_groups,
0715             'units': 'files'})
0716         groups.append({
0717             'name': f'Arrivals {name} events',
0718             'title': f'Arrivals {name}',
0719             'prefixes': [f'dlvq_{tag}_'], 'ids': [],
0720             'stacked': True, 'pc_groups': pc_groups,
0721             'default_off': True, 'units': 'events (M)'})
0722         for lens in DELIVERY_LENSES:
0723             seg, lens_value = lens['seg'], lens['value']
0724             groups.append({
0725                 'name': f'Cumulative {name} files {lens_value}',
0726                 'title': f'Cumulative {name}',
0727                 'prefixes': [f'dlvcf_{seg}_{tag}_'], 'ids': [],
0728                 'default_off': True, 'units': 'files'})
0729             groups.append({
0730                 'name': f'Cumulative {name} events {lens_value}',
0731                 'title': f'Cumulative {name}',
0732                 'prefixes': [f'dlvc_{seg}_{tag}_'], 'ids': [],
0733                 'default_off': True, 'units': 'events (M)'})
0734     return tuple(groups)
0735 
0736 _SITE_CACHE = {'at': None, 'sites': ()}
0737 
0738 
0739 def _panda_sites():
0740     """Queue names from current PanDA activity plus Canary's queue list.
0741 
0742     Canary queues whose names contain ``test`` are deliberately excluded.
0743     Current in-flight jobs still determine the display order. The union is
0744     cached briefly and drives the per-queue families and Site-page queue
0745     options.
0746     """
0747     from django.utils import timezone
0748 
0749     from canary.store.models import Queue as CanaryQueue
0750     from snapper_ai.models import SystemSnap
0751 
0752     now = timezone.now()
0753     if (_SITE_CACHE['at'] is not None
0754             and (now - _SITE_CACHE['at']).total_seconds() < 300):
0755         return _SITE_CACHE['sites']
0756     state = (SystemSnap.objects.filter(scope='epicprod')
0757              .order_by('-snap_time').values_list('state', flat=True)
0758              .first())
0759     panda = ((((state or {}).get('components') or {})
0760               .get('panda') or {}).get('data') or {})
0761     job_sites = (panda.get('jobs') or {}).get('sites') or {}
0762     task_sites = (panda.get('tasks') or {}).get('sites') or {}
0763     canary_sites = set(
0764         CanaryQueue.objects
0765         .exclude(name__icontains='test')
0766         .values_list('name', flat=True))
0767     sites = tuple(sorted(
0768         set(job_sites) | set(task_sites) | canary_sites,
0769         key=lambda site: (-int((job_sites.get(site) or {})
0770                                .get('in_flight_jobs_now') or 0), site)))
0771     _SITE_CACHE.update({'sites': sites, 'at': now})
0772     return sites
0773 
0774 
0775 # Display order of the site jobs family: submission through queueing
0776 # to execution (cores beside running jobs) to the trailing outcomes.
0777 _JOB_LIFECYCLE_EARLY = ('defined', 'waiting', 'assigned', 'activated',
0778                         'sent')
0779 _JOB_LIFECYCLE_LATE = ('holding', 'transferring', 'merging')
0780 
0781 
0782 def _site_groups():
0783     """Per-queue curve families on the Site page: in-flight jobs with cores,
0784     window-relative terminal outcomes, and tasks. Off by default on the
0785     scope view — the Site focus page is their home."""
0786     groups = []
0787     for site in _panda_sites():
0788         # Keep instantaneous job populations and terminal flow on
0789         # separate scales. Outcome staircases rise from zero at the
0790         # window's left edge; the displayed window is their integration
0791         # range.
0792         order = ([f'sj_{site}_{s}' for s in _JOB_LIFECYCLE_EARLY]
0793                  + [f'sj_{site}_running', f'sjc_{site}']
0794                  + [f'sj_{site}_{s}' for s in _JOB_LIFECYCLE_LATE])
0795         groups.append({
0796             'name': f'Site jobs {site}',
0797             'title': f'Jobs · {site}',
0798             'prefixes': [f'sj_{site}_'],
0799             'ids': [f'sjc_{site}'],
0800             'order': order,
0801             'default_off_ids': [f'sj_{site}_activated'],
0802             'tall': True,
0803             'default_off': True})
0804         groups.append({
0805             'name': f'Site outcomes {site}',
0806             'title': f'Job outcomes · {site}',
0807             'detail_key': site,
0808             'prefixes': [],
0809             'ids': [f'sjfw_{site}', f'sjxw_{site}'],
0810             'order': [f'sjfw_{site}', f'sjxw_{site}'],
0811             'window_relative': True,
0812             'default_off': True})
0813         groups.append({
0814             'name': f'Site failures {site}',
0815             'title': f'Failures by class · {site}',
0816             'prefixes': [f'sjxc_{site}_'], 'ids': [],
0817             'window_relative': True,
0818             'focus_closed': True,
0819             'default_off': True})
0820         groups.append({
0821             'name': f'Site tasks {site}',
0822             'title': f'Tasks · {site}',
0823             'prefixes': [f'stt_{site}_'], 'ids': [],
0824             'focus_closed': True,
0825             'default_off': True})
0826     return tuple(groups)
0827 
0828 
0829 def _epicprod_groups():
0830     """The epicprod curve families, resolved per render (the seam's
0831     callable form) so new campaigns and sites appear without an app
0832     restart."""
0833     return EPICPROD_GROUPS + _delivery_groups() + _site_groups()
0834 
0835 
0836 def _site_focus_view():
0837     """The Site focus tab: one queue's job lifecycle — submission
0838     through queueing to execution to the trailing finished/failed
0839     outcomes — with its tasks panel, and the cut narrowed to the panda
0840     component's queue detail."""
0841     sites = _panda_sites()
0842     if not sites:
0843         return None
0844     return {
0845         'param': 'site',
0846         'label': 'Site',
0847         'selector_label': 'Queue',
0848         'note': ('In-flight counts are the recorded queue state through '
0849                  'time; finished and failed accumulate from the left '
0850                  'edge of the shown window — the window is the '
0851                  'integration range, and zooming re-bases it. Click '
0852                  'the plot for the full picture at that instant.'),
0853         'default': sites[0],
0854         'options': [
0855             {'value': site, 'label': site,
0856              'families': [f'Site jobs {site}',
0857                           f'Site outcomes {site}',
0858                           f'Site failures {site}',
0859                           f'Site tasks {site}'],
0860              'component': 'panda'}
0861             for site in sites],
0862     }
0863 
0864 
0865 TESTBED_GROUPS = (
0866     {'name': 'Workflows', 'prefixes': ['wf_'], 'ids': []},
0867     {'name': 'STF tasks', 'prefixes': ['sts_'], 'ids': ['stf_total']},
0868 )
0869 
0870 
0871 # ── Component cards ──────────────────────────────────────────────────────
0872 
0873 _FAILURE_CLASS_COLORS = {
0874     'brokerage': '#8d6e63',
0875     'ddm': '#0277bd',
0876     'executor': '#c2185b',
0877     'dispatcher': '#00838f',
0878     'pilot': '#ef6c00',
0879     'supervisor': '#6a1b9a',
0880     'taskbuffer': '#455a64',
0881     'other': '#757575',
0882 }
0883 
0884 
0885 def _pie_segment(cx, cy, r_in, r_out, a0, a1):
0886     """SVG path of an annular sector; angles in radians clockwise from
0887     12 o'clock. A full circle is clamped a hair short so the arc pair
0888     stays well-formed."""
0889     import math
0890 
0891     a1 = min(a1, a0 + 2 * math.pi - 0.001)
0892     large = 1 if (a1 - a0) > math.pi else 0
0893     x0o, y0o = cx + r_out * math.sin(a0), cy - r_out * math.cos(a0)
0894     x1o, y1o = cx + r_out * math.sin(a1), cy - r_out * math.cos(a1)
0895     x1i, y1i = cx + r_in * math.sin(a1), cy - r_in * math.cos(a1)
0896     x0i, y0i = cx + r_in * math.sin(a0), cy - r_in * math.cos(a0)
0897     return (f'M {x0o:.2f} {y0o:.2f} '
0898             f'A {r_out} {r_out} 0 {large} 1 {x1o:.2f} {y1o:.2f} '
0899             f'L {x1i:.2f} {y1i:.2f} '
0900             f'A {r_in} {r_in} 0 {large} 0 {x0i:.2f} {y0i:.2f} Z')
0901 
0902 
0903 def _site_outcomes_pie(site, window_finished, window_failed,
0904                        class_windows, class_names, jobs_url, errors_url):
0905     """Reusable context for Snapper's site-outcomes pie."""
0906     import math
0907 
0908     pie = []
0909     total = window_finished + window_failed
0910     if not total:
0911         return pie
0912     class_colors = {
0913         name: _FAILURE_CLASS_COLORS.get(name, '#424242')
0914         for name in class_names
0915     }
0916     tau = 2 * math.pi
0917     split = tau * window_finished / total
0918     if window_finished:
0919         curve = f'sjfw_{site}'
0920         pie.append({
0921             'path': _pie_segment(60, 60, 22, 40, 0, split),
0922             'curve': curve,
0923             'color': _epicprod_curve_color(curve),
0924             'url': jobs_url(site, 'finished'),
0925             'title': (f'finished · {window_finished:,} '
0926                       f'({window_finished / total:.0%})')})
0927     if window_failed:
0928         curve = f'sjxw_{site}'
0929         pie.append({
0930             'path': _pie_segment(60, 60, 22, 40, split, tau),
0931             'curve': curve,
0932             'color': _epicprod_curve_color(curve),
0933             'url': jobs_url(site, 'failed'),
0934             'title': (f'failed · {window_failed:,} '
0935                       f'({window_failed / total:.0%})')})
0936         angle = split
0937         for cls, in_window, _count in class_windows:
0938             span = (tau - split) * in_window / window_failed
0939             pie.append({
0940                 'path': _pie_segment(60, 60, 42, 58,
0941                                      angle, angle + span),
0942                 'curve': f'sjxc_{site}_{cls}',
0943                 'color': class_colors.get(cls),
0944                 'url': errors_url(site, cls),
0945                 'title': f'{cls} · {in_window:,}'})
0946             angle += span
0947     return pie
0948 
0949 
0950 def _avg_exec_times(site, since, until):
0951     """Average execution wall time (endtime − starttime) of the site's
0952     finished and failed jobs with end times in (since, until] — the
0953     same jobs the slice's window outcomes count. Never-started jobs
0954     carry no execution time and are excluded."""
0955     from django.db import connections
0956 
0957     from .panda.constants import PANDA_SCHEMA
0958 
0959     where = ('"computingsite" = %s AND "jobstatus" IN '
0960              "('finished', 'failed') AND \"endtime\" > %s "
0961              'AND "endtime" <= %s AND "starttime" IS NOT NULL')
0962     sql = f"""
0963         SELECT "jobstatus", AVG("endtime" - "starttime")
0964         FROM (
0965             SELECT "pandaid", "jobstatus", "starttime", "endtime"
0966             FROM "{PANDA_SCHEMA}"."jobsactive4" WHERE {where}
0967             UNION
0968             SELECT "pandaid", "jobstatus", "starttime", "endtime"
0969             FROM "{PANDA_SCHEMA}"."jobsarchived4" WHERE {where}
0970         ) completed
0971         GROUP BY "jobstatus"
0972     """
0973     params = [site, since, until]
0974     out = {}
0975     with connections['panda'].cursor() as cursor:
0976         cursor.execute(sql, params + params)
0977         for status, average in cursor.fetchall():
0978             if average is not None:
0979                 out[status] = average.total_seconds()
0980     return out
0981 
0982 
0983 def _counter_site_blocks(scope, instant):
0984     """The per-site counter blocks of the nearest counter-bearing panda
0985     state at or before ``instant``: (sites map, snap time). The
0986     cumulative terminal counters ride two interleaved snap chains — the
0987     live v5 publications and the hourly backfill reconstruction — and a
0988     snap resolved from the pre-counter era carries none, so outcome
0989     differencing must find the counter-bearing chain itself."""
0990     from snapper_ai.models import SystemSnap
0991 
0992     if instant is None:
0993         return {}, None
0994     row = (SystemSnap.objects
0995            .filter(scope=scope, snap_time__lte=instant,
0996                    state__components__panda__data__jobs__has_key='cum')
0997            .order_by('-snap_time')
0998            .values('snap_time', 'state').first())
0999     if not row:
1000         return {}, None
1001     jobs = (((((row['state'] or {}).get('components') or {})
1002               .get('panda') or {}).get('data') or {}).get('jobs') or {})
1003     return (jobs.get('sites') or {}), row['snap_time']
1004 
1005 
1006 def panda_site_outcomes_pie(site, since, until, size=270):
1007     """Placeable context for the Site page's final-job-state pie."""
1008     import math
1009     from urllib.parse import quote
1010 
1011     from django.urls import reverse
1012 
1013     cut_sites, _ = _counter_site_blocks('epicprod', until)
1014     basis_sites, _ = _counter_site_blocks('epicprod', since)
1015     cut = cut_sites.get(site) or {}
1016     basis = basis_sites.get(site) or {}
1017     cut_cum = cut.get('cum') or {}
1018     basis_cum = basis.get('cum') or {}
1019     cut_classes = cut.get('cum_failed_by_class') or {}
1020     basis_classes = basis.get('cum_failed_by_class') or {}
1021 
1022     def window_count(key):
1023         return max(0, int(cut_cum.get(key) or 0)
1024                    - int(basis_cum.get(key) or 0))
1025 
1026     class_names = set(cut_classes) | set(basis_classes)
1027     class_windows = []
1028     for cls in class_names:
1029         count = max(0, int(cut_classes.get(cls) or 0)
1030                     - int(basis_classes.get(cls) or 0))
1031         if count:
1032             class_windows.append(
1033                 (cls, count, int(cut_classes.get(cls) or 0)))
1034     class_windows.sort(key=lambda item: (-item[1], item[0]))
1035 
1036     jobs_base = reverse('monitor_app:panda_jobs_list')
1037     errors_base = reverse('monitor_app:panda_errors_list')
1038     days = max(1, math.ceil((until - since).total_seconds() / 86400))
1039     window_q = (
1040         f'&days={days}&ended_after=' + quote(since.isoformat())
1041         + '&ended_before=' + quote(until.isoformat()))
1042 
1043     def jobs_url(site_name, status=None):
1044         return (f'{jobs_base}?site={quote(site_name)}'
1045                 + (f'&status={quote(status)}' if status else '')
1046                 + window_q)
1047 
1048     def errors_url(site_name, cls=None):
1049         return (f'{errors_base}?site={quote(site_name)}'
1050                 + '&status=failed'
1051                 + ('&classified=1' if cls and cls != 'other' else '')
1052                 + (f'&error_source={quote(cls)}'
1053                    if cls and cls != 'other' else '')
1054                 + window_q)
1055 
1056     finished = window_count('finished')
1057     failed = window_count('failed')
1058     return {
1059         'site': site,
1060         'pie': _site_outcomes_pie(
1061             site, finished, failed, class_windows, class_names,
1062             jobs_url, errors_url),
1063         'pie_size': int(size),
1064     }
1065 
1066 
1067 def _panda_card(data, previous_data, ctx):
1068     jobs_now = (data.get('jobs') or {}).get('in_flight_now') or {}
1069     prev_jobs = ((previous_data.get('jobs') or {})
1070                  .get('in_flight_now') or {})
1071     tasks_now = (data.get('tasks') or {}).get('in_flight_now') or {}
1072     prev_tasks = ((previous_data.get('tasks') or {})
1073                   .get('in_flight_now') or {})
1074 
1075     def stat(label, value, previous):
1076         return {'label': label,
1077                 'value': value if value is not None else '—',
1078                 'delta': cut_delta(value, previous)}
1079 
1080     headline = [
1081         stat('running jobs', jobs_now.get('running_jobs'),
1082              prev_jobs.get('running_jobs')),
1083         stat('running cores', jobs_now.get('running_cores'),
1084              prev_jobs.get('running_cores')),
1085         stat('in-flight jobs', jobs_now.get('total'),
1086              prev_jobs.get('total')),
1087         stat('queued (activated)',
1088              (jobs_now.get('by_status') or {}).get('activated'),
1089              (prev_jobs.get('by_status') or {}).get('activated')),
1090         stat('in-flight tasks', tasks_now.get('total'),
1091              prev_tasks.get('total')),
1092     ]
1093     types = sorted((jobs_now.get('by_type') or {}).items(),
1094                    key=lambda item: -item[1])
1095     type_states = []
1096     for ptype, states in sorted(
1097             (jobs_now.get('by_type_status') or {}).items()):
1098         for status, count in sorted((states or {}).items()):
1099             previous = ((prev_jobs.get('by_type_status') or {})
1100                         .get(ptype) or {}).get(status)
1101             type_states.append({
1102                 'label': f'{ptype} · {status}', 'value': count,
1103                 'delta': cut_delta(count, previous)})
1104     # The Site focus narrows the card to the selected sites' detail:
1105     # the germane facts of the slice, color-coded as in the plot —
1106     # window outcomes first (differenced against the ?since= basis,
1107     # the view's left edge), then the in-flight standing in lifecycle
1108     # order, then tasks. One swatch per fact, no repetition.
1109     params = (ctx or {}).get('params') or {}
1110     selected = [value for value in
1111                 (params.get('site') or '').split(',') if value]
1112     compact = str(params.get('compact') or '') == '1'
1113     since_sites = ((((ctx or {}).get('since_data') or {})
1114                     .get('jobs') or {}).get('sites') or {})
1115     since_stamp = (ctx or {}).get('since')
1116     basis_text = ''
1117     if since_stamp is not None:
1118         from zoneinfo import ZoneInfo
1119         basis_text = (since_stamp
1120                       .astimezone(ZoneInfo('America/New_York'))
1121                       .strftime('%m-%d %H:%M ET'))
1122     lifecycle = (list(_JOB_LIFECYCLE_EARLY) + ['running']
1123                  + list(_JOB_LIFECYCLE_LATE))
1124     # Counter sourcing: the resolved snap and the ?since= basis snap
1125     # may come from the pre-counter era (no 'cum'); the outcomes rows
1126     # then difference the nearest counter-bearing snaps instead — the
1127     # finished/failed story must never vanish from the slice.
1128     scope = (ctx or {}).get('scope') or 'epicprod'
1129     requested_at = (ctx or {}).get('requested_at')
1130     counter_cut, counter_cut_time = ({}, None)
1131     counter_since = {}
1132     if selected:
1133         counter_cut, counter_cut_time = _counter_site_blocks(
1134             scope, requested_at)
1135         counter_since, _ = _counter_site_blocks(scope, since_stamp)
1136     # Every fact in the slice is a drill-down: rows and pie slices link
1137     # to the jobs list (site + status) or the error summary (site +
1138     # class), scoped to the window via the days filter.
1139     import math
1140     from urllib.parse import quote
1141 
1142     from django.urls import reverse
1143     from django.utils import timezone as _timezone
1144 
1145     jobs_base = reverse('monitor_app:panda_jobs_list')
1146     errors_base = reverse('monitor_app:panda_errors_list')
1147     window_q = ''
1148     if since_stamp is not None and requested_at is not None:
1149         window_days = max(1, math.ceil(
1150             (requested_at - since_stamp).total_seconds() / 86400))
1151         window_q = (
1152             f'&days={window_days}&ended_after='
1153             + quote(since_stamp.isoformat())
1154             + '&ended_before=' + quote(requested_at.isoformat()))
1155     elif since_stamp is not None:
1156         window_q = '&days=' + str(max(1, math.ceil(
1157             (_timezone.now() - since_stamp).total_seconds() / 86400)))
1158 
1159     def _jobs_url(site, status=None):
1160         return (f'{jobs_base}?site={quote(site)}'
1161                 + (f'&status={quote(status)}' if status else '') + window_q)
1162 
1163     def _errors_url(site, cls=None):
1164         return (f'{errors_base}?site={quote(site)}'
1165                 + '&status=failed'
1166                 + ('&classified=1' if cls and cls != 'other' else '')
1167                 + (f'&error_source={quote(cls)}'
1168                    if cls and cls != 'other' else '') + window_q)
1169     sites = []
1170     for site in selected:
1171         block = ((data.get('jobs') or {}).get('sites')
1172                  or {}).get(site) or {}
1173         prev_block = ((previous_data.get('jobs') or {}).get('sites')
1174                       or {}).get(site) or {}
1175         task_block = ((data.get('tasks') or {}).get('sites')
1176                       or {}).get(site) or {}
1177         base = since_sites.get(site) or {}
1178         counter_base = counter_since.get(site) or {}
1179         base_cum = base.get('cum') or counter_base.get('cum') or {}
1180         base_classes = (base.get('cum_failed_by_class')
1181                         or counter_base.get('cum_failed_by_class') or {})
1182         counter_block = counter_cut.get(site) or {}
1183         own_cum = bool(block.get('cum'))
1184         cum = block.get('cum') or counter_block.get('cum') or {}
1185         classes = (block.get('cum_failed_by_class')
1186                    or counter_block.get('cum_failed_by_class') or {})
1187 
1188         def _window(key, _cum=cum, _base=base_cum):
1189             return max(0, int(_cum.get(key) or 0)
1190                        - int(_base.get(key) or 0))
1191 
1192         # One table row per curve, in panel order — swatch-correlated
1193         # to the plot, with the in-window integral where the curve is
1194         # cumulative and the failure classes nested under failed.
1195         statuses = block.get('by_status_now') or {}
1196         prev_statuses = prev_block.get('by_status_now') or {}
1197         prev_cum = prev_block.get('cum') or {}
1198         prev_classes = prev_block.get('cum_failed_by_class') or {}
1199         ordered = ([s for s in lifecycle if s in statuses]
1200                    + sorted(s for s in statuses if s not in lifecycle))
1201         rows = [
1202             {'label': ('running jobs' if status == 'running'
1203                        else status),
1204              'curve': f'sj_{site}_{status}',
1205              'url': '',
1206              'at_cut': str(int(statuses.get(status) or 0)),
1207              'delta': cut_delta(statuses.get(status),
1208                                 prev_statuses.get(status)) or '',
1209              'window': '—', 'indent': False}
1210             for status in ordered]
1211         if block.get('running_cores_now') is not None:
1212             position = next(
1213                 (i + 1 for i, entry in enumerate(rows)
1214                  if entry['label'] == 'running jobs'), len(rows))
1215             rows.insert(position, {
1216                 'label': 'running cores', 'curve': f'sjc_{site}',
1217                 'url': '',
1218                 'at_cut': str(int(block.get('running_cores_now')
1219                                   or 0)),
1220                 'delta': cut_delta(block.get('running_cores_now'),
1221                                    prev_block.get('running_cores_now'))
1222                 or '',
1223                 'window': '—', 'indent': False})
1224         # The outcomes story renders whenever a counter chain exists —
1225         # zero finished and zero failed at a quiet instant are facts,
1226         # never omissions.
1227         have_counters = bool(cum or base_cum or counter_cut
1228                              or counter_since)
1229         window_finished = _window('finished')
1230         window_failed = _window('failed')
1231         avg_exec = {}
1232         avg_note = ''
1233         if since_stamp is not None and requested_at is not None:
1234             try:
1235                 avg_exec = _avg_exec_times(site, since_stamp,
1236                                            requested_at)
1237             except Exception as e:                           # noqa: BLE001
1238                 import logging
1239                 logging.getLogger(__name__).error(
1240                     'average exec time lookup failed for %s: %s',
1241                     site, e)
1242                 avg_note = 'average execution time lookup failed'
1243         class_windows = []
1244         for cls, count in sorted(classes.items(),
1245                                  key=lambda item: -int(item[1] or 0)):
1246             in_window = max(0, int(count or 0)
1247                             - int(base_classes.get(cls) or 0))
1248             if in_window:
1249                 class_windows.append((cls, in_window, count))
1250         if have_counters:
1251             rows.append({
1252                 'label': 'finished', 'curve': f'sjfw_{site}',
1253                 'url': _jobs_url(site, 'finished'),
1254                 'at_cut': '—',
1255                 'delta': cut_delta(cum.get('finished'),
1256                                    prev_cum.get('finished')) or '',
1257                 'window': str(window_finished),
1258                 'avg': (span_text(avg_exec['finished'])
1259                         if 'finished' in avg_exec else ''),
1260                 'indent': False})
1261             rows.append({
1262                 'label': 'failed', 'curve': f'sjxw_{site}',
1263                 'url': _jobs_url(site, 'failed'),
1264                 'at_cut': '—',
1265                 'delta': cut_delta(cum.get('failed'),
1266                                    prev_cum.get('failed')) or '',
1267                 'window': str(window_failed),
1268                 'avg': (span_text(avg_exec['failed'])
1269                         if 'failed' in avg_exec else ''),
1270                 'indent': False})
1271             for cls, in_window, count in class_windows:
1272                 rows.append({
1273                     'label': cls, 'curve': f'sjxc_{site}_{cls}',
1274                     'url': _errors_url(site, cls),
1275                     'at_cut': '—',
1276                     'delta': cut_delta(count,
1277                                        prev_classes.get(cls)) or '',
1278                     'window': str(in_window), 'indent': True})
1279         # The outcomes pie: inner ring finished|failed over the window,
1280         # outer ring the failure classes over the failed arc — every
1281         # slice the same drill-down as its table row, colored by
1282         # data-curve exactly as the plot.
1283         pie = _site_outcomes_pie(
1284             site, window_finished, window_failed, class_windows,
1285             classes, _jobs_url, _errors_url)
1286         counter_note = ''
1287         if have_counters and not own_cum and counter_cut_time:
1288             counter_note = ('outcomes from the counter record at '
1289                             + counter_cut_time.astimezone(ET_ZONE)
1290                             .strftime('%m-%d %H:%M ET'))
1291         sites.append({
1292             'site': site,
1293             'url': _jobs_url(site),
1294             'found': bool(block or task_block or have_counters),
1295             'quiet': not ordered,
1296             'counter_note': counter_note,
1297             'avg_note': avg_note,
1298             'basis': basis_text if have_counters else '',
1299             'rows': rows,
1300             'pie': pie,
1301             # The pie fills the table's height: sized to the row count.
1302             'pie_size': min(400, max(220, 34 * (len(rows) + 1))),
1303         })
1304     return {'kind': 'panda', 'headline': headline, 'types': types,
1305             'type_states': type_states, 'sites': sites,
1306             'site_only': bool(sites) and compact}
1307 
1308 
1309 def _delivery_card(data, previous_data, ctx):
1310     """The delivery cut card. On a daily-record snap (the quilt), the
1311     breakdown of that day: what arrived, per configuration, with
1312     cumulative standing. On a live placed-basis snap, the placement
1313     totals with deltas and the top configurations. Full lists live on
1314     the campaign plan page."""
1315     from django.urls import reverse
1316 
1317     cache = _pc_cache()
1318     requestors = cache['requestors']
1319     keys = cache['keys']
1320     # The campaign view's selection narrows the card: only the ticked
1321     # campaigns' sections render. Without the parameter (a scope-view
1322     # cut) every campaign in the snap renders.
1323     params = (ctx or {}).get('params') or {}
1324     selected = {value for value in
1325                 (params.get('campaign') or '').split(',') if value}
1326     campaigns = []
1327     for name, block in sorted((data.get('campaigns') or {}).items()):
1328         if selected and name not in selected:
1329             continue
1330         totals = block.get('totals') or {}
1331         previous_totals = (((previous_data.get('campaigns') or {})
1332                             .get(name) or {}).get('totals') or {})
1333         if 'arrived_files' in totals:
1334             leaves = block.get('leaves') or {}
1335             # The day's arrivals grouped by the ACTIVE lens — the same
1336             # projection the quilt draws, so each section's swatch is
1337             # the patch color above it. The drilldown inside a group
1338             # stays in physics-configuration terms.
1339             lens_value = str(params.get('lens') or 'category').strip()
1340             lens = next((entry for entry in DELIVERY_LENSES
1341                          if entry['value'] == lens_value),
1342                         DELIVERY_LENSES[0])
1343             seg = lens['seg']
1344             tag = name.replace('.', '_')
1345             by_group = {}
1346             delivering = 0
1347             for pc, leaf in leaves.items():
1348                 arrived = int(leaf.get('arrived_files') or 0)
1349                 if not arrived:
1350                     continue
1351                 delivering += 1
1352                 row = {
1353                     'label': pc,
1354                     # The quilt curve this row is a patch of, in
1355                     # either plotted quantity: the swatch painter
1356                     # takes the first candidate the plot carries.
1357                     'curve': (f'dlvq_{tag}_{pc} dlvqf_{tag}_{pc}'),
1358                     'identity': keys.get(pc, ''),
1359                     'url': reverse('pcs:pcs_config_detail', args=[pc]),
1360                     'groups': ', '.join(requestors.get(pc)
1361                                         or ['Unassigned']),
1362                     'arrived_events': int(
1363                         leaf.get('arrived_events') or 0),
1364                     'cum_events': int(leaf.get('events') or 0),
1365                     'arrived': arrived,
1366                     'cum': int(leaf.get('cum_files') or 0),
1367                     'expected': leaf.get('expected'),
1368                     'tier': leaf.get('tier') or '',
1369                 }
1370                 for group in _lens_groups(pc, seg, cache):
1371                     slot = by_group.setdefault(group, {
1372                         'name': group,
1373                         'rows': [], 'arrived_events': 0,
1374                         'arrived_files': 0})
1375                     slot['rows'].append(row)
1376                     slot['arrived_events'] += row['arrived_events']
1377                     slot['arrived_files'] += arrived
1378             day_groups = sorted(
1379                 by_group.values(),
1380                 key=lambda g: (-g['arrived_events'],
1381                                -g['arrived_files']))
1382             for group in day_groups:
1383                 group['rows'].sort(
1384                     key=lambda r: (-r['arrived_events'], -r['arrived']))
1385             requested_at = (ctx or {}).get('requested_at')
1386             unmeasured = int(totals.get('unmeasured_files') or 0)
1387             campaigns.append({
1388                 'name': name,
1389                 # The compact card names its own day — no surrounding
1390                 # chrome does it anymore.
1391                 'day': (requested_at.astimezone(ET_ZONE)
1392                         .strftime('%b %-d')
1393                         if requested_at is not None else ''),
1394                 'day_groups': day_groups,
1395                 'headline': [
1396                     {'label': 'events arrived this day',
1397                      'value': totals.get('arrived_events'),
1398                      'delta': None},
1399                     {'label': 'cumulative events',
1400                      'value': totals.get('events'),
1401                      'delta': cut_delta(totals.get('events'),
1402                                         previous_totals.get('events'))},
1403                     {'label': 'files arrived this day',
1404                      'value': totals.get('arrived_files'), 'delta': None},
1405                     {'label': 'cumulative TB',
1406                      'value': round(
1407                          (totals.get('cum_bytes') or 0) / 1e12, 1),
1408                      'delta': None},
1409                     {'label': 'configurations delivering',
1410                      'value': delivering, 'delta': None},
1411                 ],
1412                 'unmeasured_files': unmeasured,
1413                 'plan_url': (reverse('pcs:pcs_campaign_plan')
1414                              + f'?campaign={name}'),
1415             })
1416             continue
1417         by_group = {}
1418         for pc, leaf in (block.get('leaves') or {}).items():
1419             files = int(leaf.get('files') or 0)
1420             if not files:
1421                 continue
1422             for group in requestors.get(pc) or ['Unassigned']:
1423                 by_group[group] = by_group.get(group, 0) + files
1424         # The PC drill: the campaign's configurations by files placed,
1425         # in physics-configuration terms — pc label linked to its page,
1426         # per-group attribution, target beside delivery. Bounded to the
1427         # top rows; the plan page is the full list.
1428         pcs = []
1429         for pc, leaf in sorted(
1430                 (block.get('leaves') or {}).items(),
1431                 key=lambda kv: -int(kv[1].get('files') or 0))[:10]:
1432             if not int(leaf.get('files') or 0):
1433                 continue
1434             pcs.append({
1435                 'label': pc,
1436                 'url': reverse('pcs:pcs_config_detail', args=[pc]),
1437                 'groups': ', '.join(requestors.get(pc)
1438                                     or ['Unassigned']),
1439                 'files': int(leaf.get('files') or 0),
1440                 'expected': leaf.get('expected'),
1441                 'tier': leaf.get('tier') or '',
1442             })
1443         campaigns.append({
1444             'name': name,
1445             'pcs': pcs,
1446             'headline': [
1447                 {'label': 'configurations',
1448                  'value': totals.get('configs'),
1449                  'delta': cut_delta(totals.get('configs'),
1450                                     previous_totals.get('configs'))},
1451                 {'label': 'with targets',
1452                  'value': totals.get('with_target'),
1453                  'delta': cut_delta(totals.get('with_target'),
1454                                     previous_totals.get('with_target'))},
1455                 {'label': 'files placed',
1456                  'value': totals.get('files'),
1457                  'delta': cut_delta(totals.get('files'),
1458                                     previous_totals.get('files'))},
1459                 {'label': 'TB placed',
1460                  'value': round((totals.get('bytes') or 0) / 1e12, 1),
1461                  'delta': None},
1462             ],
1463             'groups': sorted(by_group.items(), key=lambda kv: -kv[1]),
1464             'plan_url': (reverse('pcs:pcs_campaign_plan')
1465                          + f'?campaign={name}'),
1466         })
1467     return {'kind': 'delivery', 'campaigns': campaigns}
1468 
1469 
1470 def _workflow_card(data, previous_data, ctx):
1471     from .snapper_workflow import STF_PROCESSING_TYPE
1472 
1473     executions = data.get('executions') or {}
1474     prev_exec = previous_data.get('executions') or {}
1475     stf = data.get('stf_tasks') or {}
1476     prev_stf = previous_data.get('stf_tasks') or {}
1477 
1478     def stat(label, value, previous):
1479         return {'label': label,
1480                 'value': value if value is not None else '—',
1481                 'delta': cut_delta(value, previous)}
1482 
1483     headline = [
1484         stat('executions running', executions.get('active'),
1485              prev_exec.get('active')),
1486         stat('executions started (24h)', executions.get('started_24h'),
1487              prev_exec.get('started_24h')),
1488         stat('STF tasks in flight', stf.get('in_flight_total'),
1489              prev_stf.get('in_flight_total')),
1490     ]
1491     by_workflow = sorted(
1492         (executions.get('by_workflow') or {}).items(),
1493         key=lambda item: -item[1])
1494     site_states = []
1495     for key, count in sorted((stf.get('by_site_status') or {}).items()):
1496         site, _, status = str(key).partition('/')
1497         previous = (prev_stf.get('by_site_status') or {}).get(key)
1498         site_states.append({'site': site, 'status': status, 'value': count,
1499                             'delta': cut_delta(count, previous)})
1500     return {'kind': 'workflow',
1501             'headline': headline, 'by_workflow': by_workflow,
1502             'site_states': site_states,
1503             'stf_processing_type': STF_PROCESSING_TYPE}
1504 
1505 
1506 def _stf_tasks_for_run(run_number):
1507     """The run's STF prompt-processing tasks with file progress, from
1508     the PanDA mirror (jedi_tasks joined to input-dataset file counts)."""
1509     from django.db import connections
1510     from django.urls import reverse
1511 
1512     from .panda.constants import PANDA_SCHEMA
1513     sql = f"""
1514         SELECT t."jeditaskid", COALESCE(t."site", ''),
1515                COALESCE(t."status", ''),
1516                COALESCE(d."nfiles", 0), COALESCE(d."nfilesfinished", 0),
1517                COALESCE(d."nfilesfailed", 0)
1518         FROM "{PANDA_SCHEMA}"."jedi_tasks" t
1519         LEFT JOIN "{PANDA_SCHEMA}"."jedi_datasets" d
1520             ON d."jeditaskid" = t."jeditaskid" AND d."type" = 'input'
1521         WHERE t."processingtype" = 'stfprocessing'
1522           AND t."taskname" LIKE %s
1523         ORDER BY t."jeditaskid"
1524     """
1525     rows = []
1526     with connections['panda'].cursor() as cursor:
1527         cursor.execute(sql, [f'%swf.{int(run_number)}.%'])
1528         for taskid, site, status, nfiles, nfinished, nfailed in cursor.fetchall():
1529             rows.append({
1530                 'jeditaskid': taskid,
1531                 'site': site or 'unknown',
1532                 'status': status or 'unknown',
1533                 'files_total': int(nfiles or 0),
1534                 'files_finished': int(nfinished or 0),
1535                 'files_failed': int(nfailed or 0),
1536                 'url': reverse('monitor_app:panda_task_detail',
1537                                args=[taskid]),
1538             })
1539     return rows
1540 
1541 
1542 def _run_story(info):
1543     """The activity's story for the cut card, at the level an operator
1544     would report it: what the execution set out to do (STF volume, the
1545     decision box and its target sites) and what its STF tasks did."""
1546     import logging
1547 
1548     from .workflow_models import WorkflowExecution
1549     story = {}
1550     execution_id = info.get('execution_id') or ''
1551     if execution_id:
1552         ex = WorkflowExecution.objects.filter(
1553             execution_id=execution_id).first()
1554         params = (ex.parameter_values or {}) if ex else {}
1555         sim = params.get('simulation') or {}
1556         pp = params.get('prompt_processing') or {}
1557         try:
1558             stf_total = (int(sim.get('stf_count'))
1559                          * int(sim.get('physics_period_count')))
1560         except (TypeError, ValueError):
1561             stf_total = None
1562         story.update({
1563             'executed_by': getattr(ex, 'executed_by', '') or '',
1564             'stf_total': stf_total,
1565             'decision_box': bool(pp.get('decision_box_enabled')),
1566             'policy': str(pp.get('decision_box_policy') or ''),
1567             'sites': [str(s) for s in (pp.get('decision_box_sites') or [])],
1568         })
1569     if info.get('run_number'):
1570         try:
1571             story['tasks'] = _stf_tasks_for_run(info['run_number'])
1572         except Exception as e:
1573             logging.getLogger(__name__).error(
1574                 'STF task story query failed for run %s: %s',
1575                 info.get('run_number'), e)
1576             story['tasks'] = []
1577             story['tasks_error'] = str(e)
1578     return story
1579 
1580 
1581 def _activity_card(key):
1582     """Detail card for one numbered activity: the run's story at the
1583     level an operator would report it. The key is the run number."""
1584     from datetime import timedelta
1585 
1586     from django.utils import timezone
1587     now = timezone.now()
1588     arcs = _namespace_run_arcs(now - timedelta(days=90), now, 12 * 3600)
1589     for namespace, runs in arcs.items():
1590         for arc in runs:
1591             if str(arc['run_number']) == str(key):
1592                 info = {'run_number': arc['run_number'],
1593                         'workflow': arc['workflow'],
1594                         'execution_id': arc['execution']}
1595                 episode_id = ''
1596                 if arc['execution']:
1597                     # A live episode links like a finished one: watching
1598                     # a workflow unfold is the view's purpose. Integrity
1599                     # comes from the builder never orphaning a record,
1600                     # not from hiding in-progress ones.
1601                     from snapper_ai.models import Episode
1602                     if Episode.objects.filter(
1603                             scope='testbed',
1604                             episode_id=arc['execution']).exists():
1605                         episode_id = arc['execution']
1606                 return {'kind': 'run_story',
1607                         'episode_id': episode_id,
1608                         'namespace': namespace,
1609                         'workflow': arc['workflow'],
1610                         'run_number': arc['run_number'],
1611                         'execution_id': arc['execution'],
1612                         'started': arc['first'].isoformat(),
1613                         'ended': (arc['last'].isoformat()
1614                                   if not arc['dangling'] else ''),
1615                         'story': _run_story(info)}
1616     return None
1617 
1618 
1619 def _datataking_card(data, previous_data, ctx):
1620     # One truth: at a reference instant (the cut, or the snap's own
1621     # time) the rows come from the run record, like the lanes; the
1622     # snap's recorded entry stays in the card's audit document.
1623     # Lean by design: the run's story lives in the Time history's
1624     # numbered activity table (the activity card); the cut states each
1625     # namespace's phase without repeating it.
1626     requested_at = ctx.get('requested_at')
1627     if requested_at is not None:
1628         rows = [
1629             {'namespace': namespace,
1630              'chip': cut_chip(info['phase']),
1631              'run_number': info['run_number'],
1632              'phase': info['workflow'],
1633              'since': (info['since'].isoformat() if info['since'] else '')}
1634             for namespace, info in sorted(
1635                 namespace_activity_at(requested_at).items())
1636         ]
1637     else:
1638         rows = [
1639             {'namespace': namespace,
1640              'chip': cut_chip(
1641                  f"{ns.get('state')}"
1642                  + (f"/{ns.get('substate')}" if ns.get('substate') else '')),
1643              'run_number': ns.get('run_number'),
1644              'phase': ns.get('phase'),
1645              'since': ns.get('last_transition_at')}
1646             for namespace, ns in sorted(
1647                 (data.get('namespaces') or {}).items())
1648             for ns in [ns if isinstance(ns, dict) else {}]
1649         ]
1650     return {'kind': 'datataking', 'namespaces': rows}
1651 
1652 
1653 # ── Host service hooks ───────────────────────────────────────────────────
1654 
1655 SNAPPER_PREFS_KEY = 'snapper'
1656 
1657 
1658 def _prefs_get(username, scope):
1659     from .models import UserPreference
1660 
1661     row = UserPreference.objects.filter(username=username).first()
1662     section = (row.prefs or {}).get(SNAPPER_PREFS_KEY) if row else None
1663     per_scope = (section or {}).get(scope) if isinstance(section, dict) \
1664         else None
1665     return per_scope if isinstance(per_scope, dict) else {}
1666 
1667 
1668 def _prefs_set(username, scope, values):
1669     from .models import UserPreference
1670 
1671     row, _ = UserPreference.objects.get_or_create(username=username)
1672     prefs = row.prefs if isinstance(row.prefs, dict) else {}
1673     section = prefs.get(SNAPPER_PREFS_KEY)
1674     if not isinstance(section, dict):
1675         section = {}
1676     per_scope = section.get(scope)
1677     if not isinstance(per_scope, dict):
1678         per_scope = {}
1679     per_scope.update(values)
1680     section[scope] = per_scope
1681     prefs[SNAPPER_PREFS_KEY] = section
1682     row.prefs = prefs
1683     row.save()
1684 
1685 
1686 def _config_get(key, default=None):
1687     from .models import SysConfig
1688 
1689     return SysConfig.get_setting(key, default)
1690 
1691 
1692 def _scheduler_status(scope):
1693     from .models import SystemStatus
1694 
1695     return SystemStatus.objects.filter(
1696         name=f'snapper-{scope}-scheduler').first()
1697 
1698 
1699 def _series_cache(key, builder):
1700     """Snapper series as a cached product (docs/CACHED_PRODUCTS.md):
1701     served stored, rebuilt behind responses on staleness. Refresh state
1702     is returned with the value so the page can fetch the newly built
1703     product promptly; a concurrent first fill never duplicates work."""
1704     from .cached_product import get_product
1705 
1706     product = get_product(key, builder, ttl_seconds=90)
1707     return {
1708         'value': product['value'],
1709         'refreshing': product['refreshing'],
1710         'built_at': product['built_at'],
1711         'age_seconds': product['age_seconds'],
1712     }
1713 
1714 
1715 def _health_url():
1716     from django.urls import reverse
1717 
1718     return reverse('monitor_app:system_status')
1719 
1720 
1721 def register_snapper_providers():
1722     """Register the swf scopes and host hooks with the snapper core.
1723 
1724     Called from MonitorAppConfig.ready(); idempotent by construction
1725     (registration replaces by scope/hook name).
1726     """
1727     from .snapper_resolvers import annotate_references
1728 
1729     register(ScopeProvider(
1730         scope='epicprod',
1731         label='epicprod',
1732         curve_values=_epicprod_curve_values,
1733         curve_label=_epicprod_curve_label,
1734         curve_color=_epicprod_curve_color,
1735         curve_groups=_epicprod_groups,
1736         scope_curve_groups=EPICPROD_GROUPS,
1737         focus_view=(_delivery_focus_view, _site_focus_view),
1738         component_cards={'panda': _panda_card,
1739                          'delivery': _delivery_card},
1740         card_template=CARD_TEMPLATE,
1741         annotate_references=annotate_references,
1742     ))
1743     # Testbed curves retired 2026-07-26 (operator decision): on the
1744     # broad window the spike-train curves said nothing the numbered
1745     # activity bars don't say better. The extraction code stays
1746     # (_testbed_curve_values and friends) for the day testbed load
1747     # becomes continuous; re-registering the three curve fields
1748     # restores the panels.
1749     register(ScopeProvider(
1750         scope='testbed',
1751         label='Testbed',
1752         episodic_lanes=_run_activity_lanes,
1753         activity_at=namespace_activity_at,
1754         activity_card=_activity_card,
1755         component_cards={'workflow': _workflow_card,
1756                          'datataking': _datataking_card},
1757         card_template=CARD_TEMPLATE,
1758         annotate_references=annotate_references,
1759     ))
1760     register_hooks(
1761         prefs_get=_prefs_get,
1762         prefs_set=_prefs_set,
1763         config_get=_config_get,
1764         scheduler_status=_scheduler_status,
1765         health_url=_health_url,
1766         series_cache=_series_cache,
1767     )