File indexing completed on 2026-11-09 10:47:34
0001 """The curated epicprod-live page (ACTION_STREAM.md, Curated view).
0002
0003 The dashboard's epicprod-live title opens the live stream's last day as a
0004 reader wants it: one line per event in six groups, newest first in each,
0005 with who and why. Machinery and the step records behind an event another
0006 line reports are left out and counted, never dropped silently (Torre
0007 Wenaus, 2026-10-28, asking for considered curation instead of the raw
0008 list). An action no rule names is shown under Findings and decisions
0009 until it is given its place here.
0010 """
0011 import logging
0012 from datetime import timedelta
0013
0014 from django.utils import timezone
0015
0016 from monitor_app.live_notices import (FAILURE_OUTCOMES, MAINTENANCE, OPERATION_LABELS,
0017 ROUTINE_SUCCESSES, _node_guard_name, board_text,
0018 failure_key, node_guard_text, site_node_guard_fields)
0019 from monitor_app.short_names import short_name
0020
0021 logger = logging.getLogger(__name__)
0022
0023 WINDOW_H = 24
0024 MAX_WINDOW_H = 168
0025 GROUPS = (
0026 ('problems', 'Problems',
0027 'Warnings and errors, one line per subject: the newest, how often it recurred, '
0028 'and whether it has recovered.'),
0029 ('findings', 'Findings and decisions',
0030 'Findings, settings, limits and other decisions, with who and why.'),
0031 ('tasks', 'Task operations',
0032 "Submissions, pauses, resumes, finishes and moves, with who and why; a task's "
0033 'moves share one line.'),
0034 ('feeds', 'Feeds', "Work released to a site by the front or the site's own feeder."),
0035 ('node_guard', 'Node guard', 'Nodes newly excluded.'),
0036 ('assessments', 'Assessments', 'Assessments registered, with their verdicts.'),
0037 )
0038 PROBLEM_LEVELS = frozenset({'WARNING', 'ERROR', 'CRITICAL'})
0039
0040 MACHINERY = MAINTENANCE | ROUTINE_SUCCESSES | frozenset({
0041 'gke_pilot_cycle', 'live_watch', 'es_closeout', 'front_cycle', 'rucio_arrivals',
0042 'segfault_finding_set', 'segfault_diagnosis', 'segfault_diagnosis_triggered',
0043 'evgen_register', 'evgen_register_request', 'evgen_events',
0044 'dataset_expected_events_set', 'registered_sample_intake', 'task_evgen_input',
0045 })
0046
0047
0048 STEPS = frozenset({'idle_move', 'task_failure_guard', 'live_cleared'})
0049
0050
0051 INFORMATIONAL = frozenset({'osg_throttle'})
0052 TASK_ACTIONS = frozenset({'panda_task_operation', 'evgen_task_submit'})
0053
0054 FEEDERS = {'front_decision': ('fed', 'Front'),
0055 'google_production': ('submitted', 'Google feeder'),
0056 'perlmutter_production': ('submitted', 'Perlmutter feeder')}
0057 AUTOMATION = {'front_cron': 'front', 'node_guard_cron': 'node guard',
0058 'osgsub01-reporter': 'OSG submit reporter'}
0059 ASSESSMENT_KINDS = {'daily': 'Daily', 'weekly': 'Weekly', 'live_watch': 'Live watch'}
0060
0061
0062 def group_of(levelname, extra):
0063 """The group a live record is shown in, or None when it is left out."""
0064 action = str(extra.get('action') or '')
0065 outcome = str(extra.get('outcome') or '')
0066 if action in INFORMATIONAL:
0067 return None
0068 if action == 'assessment_register':
0069 return 'assessments'
0070 if action == 'production_finding':
0071 return 'findings'
0072 if action == 'node_guard_decision':
0073
0074
0075 new = outcome == 'excluded' and extra.get('old_state') in (None, '', 'clear')
0076 return 'node_guard' if new else None
0077 if action in TASK_ACTIONS:
0078 return 'tasks'
0079 if action in FEEDERS and outcome == FEEDERS[action][0]:
0080 return 'feeds'
0081 if action in STEPS and outcome not in FAILURE_OUTCOMES:
0082 return None
0083 if levelname in PROBLEM_LEVELS:
0084 return 'problems'
0085 if action in MACHINERY or action in STEPS or action in FEEDERS:
0086 return None
0087 return 'findings'
0088
0089
0090 def curated_live(hours=WINDOW_H):
0091 """The page's record: the window, the groups with their lines, and the
0092 counts of live records read and left out."""
0093 from monitor_app.comp_snapshot import _pages
0094 from monitor_app.epicprod_logging import live_stream_q
0095 from monitor_app.models import AppLog
0096
0097 hours = max(1, min(int(hours), MAX_WINDOW_H))
0098 since = timezone.now() - timedelta(hours=hours)
0099 rows = list(_pages(AppLog.objects.filter(live_stream_q(), timestamp__gte=since)
0100 .order_by('-timestamp')
0101 .values('id', 'timestamp', 'levelname', 'message',
0102 'instance_name', 'extra_data')))
0103 sorted_rows = {key: [] for key, _, _ in GROUPS}
0104 left_out = 0
0105 for r in rows:
0106 r['extra_data'] = r['extra_data'] or {}
0107 group = group_of(r['levelname'], r['extra_data'])
0108 if group is None:
0109 left_out += 1
0110 else:
0111 sorted_rows[group].append(r)
0112 people = _people(rows)
0113 builders = {'problems': _problem_lines, 'findings': _plain_lines,
0114 'tasks': _task_lines, 'feeds': _feed_lines,
0115 'node_guard': _node_guard_lines, 'assessments': _assessment_lines}
0116 groups = []
0117 for key, title, description in GROUPS:
0118 try:
0119 lines = builders[key](sorted_rows[key], rows, people)
0120 except Exception:
0121
0122
0123 logger.exception('curated live page: group %s failed', key)
0124 lines = _plain_lines(sorted_rows[key], rows, people)
0125 groups.append({'key': key, 'title': title, 'description': description,
0126 'records': len(sorted_rows[key]), 'lines': lines})
0127 return {'hours': hours, 'since': since, 'records': len(rows),
0128 'left_out': left_out, 'groups': groups}
0129
0130
0131 def _people(rows):
0132 """{username: full name} for the people named in the window."""
0133 from django.contrib.auth import get_user_model
0134 names = {str(r['extra_data'].get('username') or '') for r in rows} - {''}
0135 return {u.username: u.get_full_name() for u in
0136 get_user_model().objects.filter(username__in=names) if u.get_full_name()}
0137
0138
0139 def _who(username, people):
0140 u = str(username or '')
0141 if u in AUTOMATION:
0142 return AUTOMATION[u]
0143 if u == 'cron' or u.endswith('_cron'):
0144 return 'cron'
0145 return people.get(u) or u
0146
0147
0148 def _line(r, people, title=None, detail=None, level=None, who=None, note=''):
0149 t, d = board_text(r['message'], r['extra_data'])
0150 return {'id': r['id'], 'at': r['timestamp'], 'level': level or r['levelname'],
0151 'title': _plain(title or t), 'detail': _plain(detail or d), 'note': _plain(note),
0152 'who': who if who is not None else _who(r['extra_data'].get('username'), people)}
0153
0154
0155 def _plain_lines(group_rows, rows, people):
0156 return [_line(r, people) for r in group_rows]
0157
0158
0159 def _problem_lines(group_rows, rows, people):
0160 """One line per subject, the newest record, as the dashboard's panel
0161 keeps them (comp_snapshot._subject), noting the repeats and whether the
0162 failure has recovered, ended or been answered."""
0163 from monitor_app.comp_snapshot import _last_ok, _subject, _superseded
0164 from monitor_app.models import AppLog
0165 cleared = {}
0166 for c in (AppLog.objects.filter(app_name='epicprod', extra_data__action='live_cleared',
0167 timestamp__gte=group_rows[-1]['timestamp'])
0168 .values_list('extra_data', flat=True) if group_rows else ()):
0169 for i in (c or {}).get('cleared_ids') or ():
0170 cleared[int(i)] = str((c or {}).get('reason') or '')
0171 subjects = {}
0172 for r in group_rows:
0173 subjects.setdefault(_subject(r['instance_name'], r['extra_data']), []).append(r)
0174 lines = []
0175 for same in subjects.values():
0176 r, extra = same[0], same[0]['extra_data']
0177 notes = []
0178 if len(same) > 1:
0179 notes.append(f"{len(same)} times since {_at(same[-1]['timestamp'])}")
0180 level = None
0181 outcome = str(extra.get('outcome') or '')
0182 if r['id'] in cleared:
0183 notes.append(f"answered: {cleared[r['id']]}")
0184 level = 'CLEARED'
0185 elif outcome in FAILURE_OUTCOMES:
0186 ok = _last_ok(r['instance_name'], extra, failure_key(r['instance_name'], extra))
0187 if ok and ok > r['timestamp']:
0188 notes.append(f'recovered {_at(ok)}')
0189 level = 'CLEARED'
0190 elif _superseded(r['instance_name'], extra, r['timestamp']):
0191 notes.append('ended')
0192 level = 'CLEARED'
0193 lines.append(_line(r, people, level=level, note='; '.join(notes)))
0194 return lines
0195
0196
0197 def _operation_who_why(op, username, people, idle):
0198 """(who, why) for a task operation from its operation record
0199 (PandaTaskOperation): the front's task guard and its trip, the front's
0200 idle move (``idle``: its ``idle_move`` record by operation id), a person
0201 and their reason; the action record's ``username`` when it has none."""
0202 if op is None:
0203 return _who(username, people), ''
0204 evidence = op.evidence or {}
0205 source = str(op.source or '')
0206 if source.startswith('front_task_failure_guard'):
0207 from monitor_app.comp_snapshot import _guard_reason
0208 return 'front task guard', _guard_reason(evidence) or ''
0209 if source == 'front_idle_move':
0210 move = idle.get(str(op.id)) or {}
0211 why = f"{short_name(move.get('to') or evidence.get('site'))} idle"
0212 if move.get('rows') and move.get('from'):
0213 why += f"; {int(move['rows']):,} unstarted jobs waiting at {short_name(move['from'])}"
0214 return 'front', why
0215 return (_who(op.requested_by, people),
0216 str(evidence.get('reason') or evidence.get('note') or ''))
0217
0218
0219 def task_operation_who_why(extra, at):
0220 """(who, why) for the task operation one action record reports, read as
0221 the curated page reads it; the dashboard's epicprod-live panel uses it
0222 for a Move (COMP_SNAPSHOT.md, Live)."""
0223 from monitor_app.epicprod_logging import EPICPROD_APP_NAME
0224 from monitor_app.models import AppLog, PandaTaskOperation
0225 task = str(extra.get('subject_key') or '')
0226 op = (PandaTaskOperation.objects
0227 .filter(jedi_task_id=int(task), operation=str(extra.get('operation') or ''),
0228 requested_at__lte=at)
0229 .order_by('-requested_at').first() if task.isdigit() else None)
0230 idle = {}
0231 if op is not None and str(op.source or '') == 'front_idle_move':
0232 move = (AppLog.objects.filter(app_name=EPICPROD_APP_NAME,
0233 extra_data__action='idle_move',
0234 extra_data__operation_id=str(op.id))
0235 .values_list('extra_data', flat=True).first())
0236 if move:
0237 idle[str(op.id)] = move
0238 names = {str(extra.get('username') or ''), str(getattr(op, 'requested_by', '') or '')}
0239 people = _people([{'extra_data': {'username': n}} for n in names - {''}])
0240 return _operation_who_why(op, extra.get('username'), people, idle)
0241
0242
0243 def _task_lines(group_rows, rows, people):
0244 """A line per task operation with who and why from its operation record
0245 (PandaTaskOperation), the pending and final records of one operation
0246 shown once at their final outcome; a task's moves on one line."""
0247 from monitor_app.models import PandaTaskOperation
0248 task_ids = set()
0249 for r in group_rows:
0250 x = r['extra_data']
0251 task_ids.update(str(t) for t in x.get('jedi_task_ids') or ())
0252 if x.get('subject_type') == 'panda_task' and x.get('subject_key'):
0253 task_ids.add(str(x['subject_key']))
0254 ops = {}
0255 since = (group_rows[-1]['timestamp'] - timedelta(hours=1)) if group_rows else None
0256 for op in (PandaTaskOperation.objects.filter(
0257 jedi_task_id__in=[int(t) for t in task_ids if t.isdigit()],
0258 requested_at__gte=since).order_by('requested_at') if task_ids else ()):
0259 ops.setdefault((str(op.jedi_task_id), op.operation), []).append(op)
0260 idle = {str(r['extra_data'].get('operation_id')): r['extra_data']
0261 for r in rows if r['extra_data'].get('action') == 'idle_move'}
0262
0263 def operation_of(task, operation, at):
0264 found = None
0265 for op in ops.get((task, operation), ()):
0266 if op.requested_at <= at:
0267 found = op
0268 return found
0269
0270 shown, moves, lines = set(), {}, []
0271 for r in group_rows:
0272 x = r['extra_data']
0273 if x.get('action') == 'evgen_task_submit':
0274 task = x.get('jedi_task_id')
0275 lines.append(_line(r, people, title=f"Submit · task {task}" if task else 'Submit',
0276 detail=str(x.get('subject_key') or r['message'])))
0277 continue
0278 operation = str(x.get('operation') or '')
0279 tasks = [str(t) for t in x.get('jedi_task_ids') or ()] or [str(x.get('subject_key'))]
0280 op = operation_of(tasks[0], operation, r['timestamp'])
0281 key = op.id if op is not None else r['id']
0282 if key in shown:
0283 continue
0284 shown.add(key)
0285 who, why = _operation_who_why(op, x.get('username'), people, idle)
0286 if operation == 'reassign' and len(tasks) == 1:
0287 moves.setdefault(tasks[0], []).append((r, who))
0288 continue
0289 label = OPERATION_LABELS.get(operation, operation.capitalize() or 'Operation')
0290 title = (f"{label} · tasks {', '.join(tasks)}" if len(tasks) > 1
0291 else f'{label} · task {tasks[0]}')
0292 name = x.get('task_name') or ''
0293 status = '' if x.get('outcome') == 'ok' else str(x.get('reason') or x.get('outcome') or '')
0294 detail = '; '.join(p for p in (why, status) if p)
0295 lines.append(_line(r, people, title=title, who=who,
0296 detail=f'{name}: {detail}' if name and detail else (name or detail
0297 or r['message'])))
0298 for task, made in moves.items():
0299 r = made[0][0]
0300 steps = []
0301 for m, who in reversed(made):
0302 mx = m['extra_data']
0303 step = (f"{short_name(mx.get('previous_site')) or '?'} to "
0304 f"{short_name(mx.get('site')) or '?'} {_at(m['timestamp'])}, {who}")
0305 if mx.get('outcome') != 'ok':
0306 step += f" ({mx.get('reason') or mx.get('outcome')})"
0307 steps.append(step)
0308 worst = max((m['levelname'] for m, _ in made), key=_level_rank)
0309 title = f'Move ×{len(made)} · task {task}' if len(made) > 1 else f'Move · task {task}'
0310 name = r['extra_data'].get('task_name') or ''
0311 lines.append(_line(r, people, title=title, level=worst,
0312 who=', '.join(dict.fromkeys(w for _, w in made)),
0313 detail=(f'{name}: ' if name else '') + '; '.join(steps)))
0314 lines.sort(key=lambda line: line['at'], reverse=True)
0315 return lines
0316
0317
0318 def _feed_lines(group_rows, rows, people):
0319 lines = []
0320 for r in group_rows:
0321 x = r['extra_data']
0322 label = FEEDERS[x['action']][1]
0323 queue = x.get('queue') or x.get('subject_key')
0324 if x['action'] == 'front_decision':
0325 title = f'Front · {short_name(queue)}' if queue else label
0326 detail, who = str(x.get('candidate') or r['message']), 'front'
0327 else:
0328 events = x.get('events')
0329 title, who = label, label
0330 detail = (f"{x.get('task') or ''}: {x.get('jobs')} jobs"
0331 + (f', {int(events):,} events' if events else ''))
0332 lines.append(_line(r, people, title=title, detail=detail, who=who))
0333 return lines
0334
0335
0336 def _node_guard_lines(group_rows, rows, people):
0337 ranks = {}
0338 if group_rows:
0339 from monitor_app.panda.node_guard import exclusion_ranks
0340 try:
0341 ranks = exclusion_ranks()
0342 except Exception:
0343 logger.exception('curated live page: node guard blacklist unreadable')
0344 lines = []
0345 for r in group_rows:
0346 fields = site_node_guard_fields(r['extra_data'], ranks)
0347 lines.append(_line(r, people, title=_node_guard_name(fields),
0348 detail=node_guard_text(fields), who='node guard'))
0349 return lines
0350
0351
0352 def _assessment_lines(group_rows, rows, people):
0353 lines = []
0354 for r in group_rows:
0355 x = r['extra_data']
0356 kind = str(x.get('assessment_kind') or x.get('subject_type') or '')
0357 verdict = str(x.get('verdict') or '').replace('_', ' ')
0358 report = str(x.get('report_title') or x.get('subject_key') or '')
0359 if verdict and verdict not in report.replace('_', ' '):
0360 report += f': {verdict}'
0361 line = _line(r, people,
0362 title=f"Assessment · {ASSESSMENT_KINDS.get(kind, kind.replace('_', ' '))}",
0363 detail=report,
0364 level={'warning': 'WARNING', 'error': 'ERROR', 'critical': 'ERROR'}
0365 .get(str(x.get('severity') or ''), r['levelname']))
0366 line['path'] = x.get('report_path') or x.get('url')
0367 lines.append(line)
0368 return lines
0369
0370
0371 def _plain(text):
0372 """Page text without the long dash some records carry."""
0373 return str(text or '').replace(' — ', ', ').replace('—', ', ')
0374
0375
0376 def _level_rank(level):
0377 return {'CRITICAL': 3, 'ERROR': 2, 'WARNING': 1}.get(level, 0)
0378
0379
0380 def _at(ts):
0381 """'10-28 07:05', Eastern Time."""
0382 return timezone.localtime(ts).strftime('%m-%d %H:%M')