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
0017 CARD_TEMPLATE = 'monitor_app/_snapper_cards.html'
0018
0019
0020
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
0232
0233
0234
0235
0236
0237
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
0311 tag = campaign.replace('.', '_')
0312
0313
0314
0315
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
0364
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
0464
0465
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
0485
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
0503
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
0513
0514
0515
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
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
0536
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
0550
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:
0633 return None
0634 if not campaigns:
0635 return None
0636 starts = _campaign_delivery_starts()
0637 return {
0638 'param': 'campaign',
0639 'label': 'Campaign',
0640
0641
0642
0643 'cache_series': True,
0644 'default': campaigns[0],
0645
0646
0647
0648
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
0663
0664
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:
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
0776
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
0789
0790
0791
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
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
1105
1106
1107
1108
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
1125
1126
1127
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
1137
1138
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
1193
1194
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
1225
1226
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:
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
1280
1281
1282
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
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
1321
1322
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
1336
1337
1338
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
1355
1356
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
1390
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
1425
1426
1427
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
1598
1599
1600
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
1621
1622
1623
1624
1625
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
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
1744
1745
1746
1747
1748
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 )