The little things give you away... A collection of various small helper stuff
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

217 lines
9.4 KiB

  1. #!/usr/bin/env python3
  2. import argparse
  3. import datetime
  4. import json
  5. import math
  6. import re
  7. import sys
  8. import time
  9. import urllib.request
  10. # Column definitions
  11. columns = {
  12. 'jobid': (lambda job, pipelines: job["job_data"]["ident"], ()),
  13. 'url': (lambda job, pipelines: job["job_data"]["url"], ()),
  14. 'user': (lambda job, pipelines: job["job_data"]["started_by"], ()),
  15. 'pipenick': (lambda job, pipelines: pipelines[job["job_data"]["pipeline_id"]] if job["job_data"]["pipeline_id"] in pipelines else "unknown", ()),
  16. 'queued': (lambda job, pipelines: job["job_data"]["queued_at"], ('date',)),
  17. 'started': (lambda job, pipelines: job["job_data"]["started_at"], ('date',)),
  18. 'last active': (lambda job, pipelines: int(job["ts"]), ('date', 'coloured')),
  19. 'dl urls': (lambda job, pipelines: job["job_data"]["items_downloaded"], ()),
  20. 'dl size': (lambda job, pipelines: job["job_data"]["bytes_downloaded"], ('size',)),
  21. 'queue': (lambda job, pipelines: job["job_data"]["items_queued"] - job["job_data"]["items_downloaded"], ()),
  22. 'con': (lambda job, pipelines: job["job_data"]["concurrency"], ()),
  23. 'delay min': (lambda job, pipelines: int(job["job_data"]["delay_min"]), ('hidden',)),
  24. 'delay max': (lambda job, pipelines: int(job["job_data"]["delay_max"]), ('hidden',)),
  25. 'delay': (lambda job, pipelines: str(int(job["job_data"]["delay_min"])) + '-' + str(int(job["job_data"]["delay_max"])) if job["job_data"]["delay_min"] != job["job_data"]["delay_max"] else str(int(job["job_data"]["delay_min"])), ()),
  26. }
  27. defaultSort = 'jobid'
  28. # Parse arguments
  29. class FilterAction(argparse.Action):
  30. def __call__(self, parser, namespace, values, optionString = None):
  31. global columns
  32. match = re.match(r"^(?P<column>[A-Za-z ]+)(?P<op>[=<>^*$~])(?P<value>.*)$", values[0])
  33. if not match:
  34. raise argparse.ArgumentError('Invalid filter')
  35. filterDict = match.groupdict()
  36. filterDict["column"] = filterDict["column"].lower()
  37. assert filterDict["column"] in columns
  38. transform = (lambda x: x.lower() if isinstance(x, str) else x) if optionString in ('--ifilter', '-i') else (lambda x: x)
  39. setattr(namespace, self.dest, (filterDict, transform))
  40. def parse_sort(value):
  41. global columns
  42. sortDesc = value.startswith('-')
  43. if sortDesc:
  44. value = value[1:]
  45. value = value.lower()
  46. if value not in columns:
  47. raise argparse.ArgumentError('Invalid column name')
  48. return (value, sortDesc)
  49. class SortAction(argparse.Action):
  50. def __call__(self, parser, namespace, values, optionString = None):
  51. result = parse_sort(values[0])
  52. if getattr(namespace, self.dest, None) is None:
  53. setattr(namespace, self.dest, [])
  54. getattr(namespace, self.dest).append(result)
  55. parser = argparse.ArgumentParser(formatter_class = argparse.RawTextHelpFormatter)
  56. parser.add_argument('--filter', '-f', nargs = 1, type = str, action = FilterAction, help = '\n'.join([
  57. 'Filter the table for rows where a COLUMN has a certain VALUE. If specified multiple times, only the last value is used.',
  58. 'FILTER has the format COLUMN{=|<|>|^|*|$|~}VALUE',
  59. ' = means the value must be exactly as specified.',
  60. ' < and > mean it must be less/greater than the specified.',
  61. ' ^ and $ mean it must start/end with the specified.',
  62. ' * means it must contain the specified.',
  63. ' ~ means it must match the specified regex.',
  64. ]))
  65. parser.add_argument('--ifilter', '-i', nargs = 1, type = str, action = FilterAction, dest = 'filter', help = 'Like --filter but case-insensitive')
  66. parser.add_argument('--sort', '-s', nargs = 1, type = str, action = SortAction, help = "Sort the table by a COLUMN (descending if preceded by '-'). This can be used multiple times to refine the sorting.")
  67. parser.add_argument('--mode', choices = ('table', 'dashboard-regex', 'con-d-commands', 'format'), default = 'table', help = '\n'.join([
  68. 'Output modes:',
  69. ' table: print a table of the matched jobs',
  70. ' dashboard-regex: compose a regular expression that can be used on the dashboard to actively watch the jobs matched by the filter',
  71. ' con-d-commands: print !con and !d commands for the current settings',
  72. ' format: print some output for each job, separated by newlines; this requires the --format option',
  73. ]))
  74. parser.add_argument('--no-colours', '--no-colors', action = 'store_true', help = "Don't colourise the last activity column if it's been a while. (Table mode only)")
  75. parser.add_argument('--no-table', action = 'store_true', help = 'Raw output without feeding through column(1); columns are separated by tabs. (Table mode only)')
  76. parser.add_argument('--dates', action = 'store_true', help = 'Print dates instead of elapsed times for queued/started/last active columns. (Table mode only)')
  77. parser.add_argument('--format', help = 'Output format for the format mode; this must be a Python format string and can use any column name in lower-case with spaces replaced by underscores; e.g. "{url} {last_active}". (Format mode only)')
  78. args = parser.parse_args()
  79. if args.mode == 'format' and not args.format:
  80. print('Error: when using format mode, --format is required.', file = sys.stderr)
  81. sys.exit(1)
  82. if not args.sort:
  83. args.sort = [parse_sort(defaultSort)]
  84. # Retrieve
  85. def fetch(url):
  86. req = urllib.request.Request(url)
  87. req.add_header('Accept', 'application/json')
  88. with urllib.request.urlopen(req) as f:
  89. if f.getcode() != 200:
  90. raise RuntimeError('Could not fetch job data')
  91. return json.load(f)
  92. jobdata = fetch('http://dashboard.at.ninjawedding.org/logs/recent?count=1')
  93. pipelinedata = fetch('http://dashboard.at.ninjawedding.org/pipelines')
  94. currentTime = time.time()
  95. # Process
  96. pipelines = {p["id"]: p["nickname"] for p in pipelinedata["pipelines"]}
  97. jobs = []
  98. for job in jobdata:
  99. jobs.append({column: columnFunc(job, pipelines) for column, (columnFunc, _) in columns.items()})
  100. if not jobs:
  101. # Nothing to do
  102. sys.exit(0)
  103. # Filter
  104. if args.filter:
  105. filterDict, transform = args.filter
  106. compFunc = {
  107. "=": lambda a, b: a == b,
  108. "<": lambda a, b: a < b,
  109. ">": lambda a, b: a > b,
  110. "^": lambda a, b: a.startswith(b),
  111. "*": lambda a, b: b in a,
  112. "$": lambda a, b: a.endswith(b),
  113. "~": lambda a, b: re.search(b, a) is not None,
  114. }[filterDict["op"]]
  115. if isinstance(jobs[0][filterDict["column"]], (int, float)):
  116. filterDict["value"] = float(filterDict["value"])
  117. jobs = [job for job in jobs if compFunc(transform(job[filterDict["column"]]), transform(filterDict["value"]))]
  118. if not jobs:
  119. sys.exit(0)
  120. # Sort
  121. class reversor: # https://stackoverflow.com/a/56842689
  122. def __init__(self, obj):
  123. self.obj = obj
  124. def __eq__(self, other):
  125. return other.obj == self.obj
  126. def __lt__(self, other):
  127. return other.obj < self.obj
  128. sortColumns = tuple((column, descending, columns[column]) for column, descending in args.sort)
  129. if not args.dates:
  130. # Reverse sorting order for columns which have a date attribute since the column will have elapsed time
  131. sortColumns = tuple((column, not descending if 'date' in columnInfo[1] else descending, columnInfo) for column, descending, columnInfo in sortColumns)
  132. jobs = sorted(jobs, key = lambda job: tuple(job[column] if not descending else reversor(job[column]) for column, descending, _ in sortColumns))
  133. # Non-table output modes
  134. if args.mode == 'dashboard-regex':
  135. print('^(' + '|'.join(re.escape(job['url']) for job in jobs) + ')$')
  136. sys.exit(0)
  137. elif args.mode == 'con-d-commands':
  138. for job in jobs:
  139. print(f'!con {job["jobid"]} {job["con"]}')
  140. print(f'!d {job["jobid"]} {job["delay min"]} {job["delay max"]}')
  141. sys.exit(0)
  142. elif args.mode == 'format':
  143. for job in jobs:
  144. print(args.format.format(**{key.replace(' ', '_'): value for key, value in job.items()}))
  145. sys.exit(0)
  146. # Renderers
  147. def render_date(ts, coloured = False):
  148. global args, currentTime
  149. diff = currentTime - ts
  150. colourStr = f"\x1b[{0 if diff < 6 * 3600 else 7};31m" if coloured and diff >= 300 else ""
  151. colourEndStr = "\x1b[0m" if colourStr else ""
  152. if args.dates:
  153. return (colourStr, datetime.datetime.fromtimestamp(ts).isoformat(sep = " "), colourEndStr)
  154. if diff <= 0:
  155. return "now"
  156. elif diff < 60:
  157. return "<1 min ago"
  158. elif diff < 86400:
  159. return (colourStr, (f"{diff // 3600:.0f}h " if diff >= 3600 else "") + f"{(diff % 3600) // 60:.0f}mn ago", colourEndStr)
  160. else:
  161. return (colourStr, f"{diff // 86400:.0f}d {(diff % 86400) // 3600:.0f}h ago", colourEndStr)
  162. def render_size(size):
  163. units = ('B', 'KiB', 'MiB', 'GiB', 'TiB')
  164. unitIdx = min(int(math.log(size, 1024)), len(units) - 1) if size >= 1 else 0
  165. if unitIdx == 0:
  166. return f'{size} B' # No decimal places
  167. return f'{size / 1024 ** unitIdx:.1f} {units[unitIdx]}'
  168. renderers = {}
  169. for column, (_, columnAttr) in columns.items():
  170. if "date" in columnAttr:
  171. if "coloured" in columnAttr:
  172. renderers[column] = lambda x: render_date(x, coloured = not args.no_colours)
  173. else:
  174. renderers[column] = render_date
  175. elif "size" in columnAttr:
  176. renderers[column] = render_size
  177. elif isinstance(jobs[0][column], (int, float)):
  178. renderers[column] = str
  179. # Print
  180. output = []
  181. output.append(tuple(column.upper() for column in columns if "hidden" not in columns[column][1]))
  182. for job in jobs:
  183. for column in renderers:
  184. job[column] = renderers[column](job[column])
  185. output.append(tuple(job[column] for column in columns if "hidden" not in columns[column][1]))
  186. if not args.no_table:
  187. widths = tuple(max(len(field) if isinstance(field, str) else len(field[1]) for field in column) for column in zip(*output))
  188. for row in output:
  189. print(' '.join((value.ljust(width) if isinstance(value, str) else ''.join((value[0], value[1], value[2], ' ' * (width - len(value[1]))))) for value, width in zip(row, widths)))
  190. else:
  191. for row in output:
  192. print('\t'.join(field if isinstance(field, str) else ''.join(field) for field in row))