Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(317)

Unified Diff: appengine/swarming/handlers_frontend.py

Issue 2500503002: Redirecting old ui to new ui (Closed)
Patch Set: Remove post handlers Created 4 years, 1 month ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
« no previous file with comments | « no previous file | appengine/swarming/handlers_test.py » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Index: appengine/swarming/handlers_frontend.py
diff --git a/appengine/swarming/handlers_frontend.py b/appengine/swarming/handlers_frontend.py
index 187695bedd1e85a46fd45a788ac07f3cee357c14..2106555e46851efbdf9216a04b7a59a67c0ba861 100644
--- a/appengine/swarming/handlers_frontend.py
+++ b/appengine/swarming/handlers_frontend.py
@@ -183,588 +183,58 @@ class RestrictedLaunchMapReduceJob(auth.AuthenticatingHandler):
class BotsListHandler(auth.AuthenticatingHandler):
- """Presents the list of known bots."""
- ACCEPTABLE_BOTS_SORTS = {
- 'last_seen_ts': 'Last Seen',
- '-quarantined': 'Quarantined',
- '__key__': 'ID',
- }
- SORT_OPTIONS = [
- SortOptions(k, v) for k, v in sorted(ACCEPTABLE_BOTS_SORTS.iteritems())
- ]
+ """Redirects to a list of known bots."""
- @auth.autologin
- @auth.require(acl.is_privileged_user)
+ @auth.public
def get(self):
limit = int(self.request.get('limit', 100))
- cursor = datastore_query.Cursor(urlsafe=self.request.get('cursor'))
- sort_by = self.request.get('sort_by', '__key__')
- if sort_by not in self.ACCEPTABLE_BOTS_SORTS:
- self.abort(400, 'Invalid sort_by query parameter')
-
- if sort_by[0] == '-':
- order = datastore_query.PropertyOrder(
- sort_by[1:], datastore_query.PropertyOrder.DESCENDING)
- else:
- order = datastore_query.PropertyOrder(
- sort_by, datastore_query.PropertyOrder.ASCENDING)
dimensions = (
l.strip() for l in self.request.get('dimensions', '').splitlines()
)
dimensions = [i for i in dimensions if i]
- now = utils.utcnow()
- cutoff = now - datetime.timedelta(
- seconds=config.settings().bot_death_timeout_secs)
-
- # TODO(maruel): Counting becomes an issue at the 10k range, at that point it
- # should be prepopulated in an entity and updated via a cron job.
- num_bots_busy_future = bot_management.BotInfo.query(
- bot_management.BotInfo.is_busy == True).count_async()
- num_bots_dead_future = bot_management.BotInfo.query(
- bot_management.BotInfo.last_seen_ts < cutoff).count_async()
- num_bots_quarantined_future = bot_management.BotInfo.query(
- bot_management.BotInfo.quarantined == True).count_async()
- num_bots_total_future = bot_management.BotInfo.query().count_async()
- q = bot_management.BotInfo.query().order(order)
- for d in dimensions:
- q = q.filter(bot_management.BotInfo.dimensions_flat == d)
- fetch_future = q.fetch_page_async(limit, start_cursor=cursor)
-
- # TODO(maruel): self.request.host_url should be the default AppEngine url
- # version and not the current one. It is only an issue when
- # version-dot-appid.appspot.com urls are used to access this page.
- # TODO(aludwin): Display both gRPC and non-gRPC versions
- version = bot_code.get_bot_version(self.request.host_url)
- bots, cursor, more = fetch_future.get_result()
- # Prefetch the tasks. We don't actually use the value here, it'll be
- # implicitly used by ndb local's cache when refetched by the html template.
- tasks = filter(None, (b.task for b in bots))
- ndb.get_multi(tasks)
- num_bots_busy = num_bots_busy_future.get_result()
- num_bots_dead = num_bots_dead_future.get_result()
- num_bots_quarantined = num_bots_quarantined_future.get_result()
- num_bots_total = num_bots_total_future.get_result()
- try_link = '/botlist?l=%d' % limit
+ new_ui_link = '/botlist?l=%d' % limit
if dimensions:
- try_link += '&f=' + '&f='.join(dimensions)
- params = {
- 'bots': bots,
- 'current_version': version,
- 'cursor': cursor.urlsafe() if cursor and more else '',
- 'dimensions': '\n'.join(dimensions),
- 'is_admin': acl.is_admin(),
- 'is_privileged_user': acl.is_privileged_user(),
- 'limit': limit,
- 'now': now,
- 'num_bots_alive': num_bots_total - num_bots_dead,
- 'num_bots_busy': num_bots_busy,
- 'num_bots_dead': num_bots_dead,
- 'num_bots_quarantined': num_bots_quarantined,
- 'try_link': try_link,
- 'sort_by': sort_by,
- 'sort_options': self.SORT_OPTIONS,
- 'xsrf_token': self.generate_xsrf_token(),
- }
- self.response.write(
- template.render('swarming/restricted_botslist.html', params))
+ new_ui_link += '&f=' + '&f='.join(dimensions)
+
+ self.redirect(new_ui_link)
class BotHandler(auth.AuthenticatingHandler):
- """Returns data about the bot, including last tasks and events."""
+ """Redirects to a page about the bot, including last tasks and events."""
- @auth.autologin
- @auth.require(acl.is_privileged_user)
+ @auth.public
def get(self, bot_id):
- # pagination is currently for tasks, not events.
- limit = int(self.request.get('limit', 100))
- cursor = datastore_query.Cursor(urlsafe=self.request.get('cursor'))
- run_results_future = task_result.TaskRunResult.query(
- task_result.TaskRunResult.bot_id == bot_id).order(
- -task_result.TaskRunResult.started_ts).fetch_page_async(
- limit, start_cursor=cursor)
- bot_future = bot_management.get_info_key(bot_id).get_async()
- events_future = bot_management.get_events_query(
- bot_id, True).fetch_async(100)
-
- now = utils.utcnow()
-
- # Calculate the time this bot was idle.
- idle_time = datetime.timedelta()
- run_time = datetime.timedelta()
- run_results, cursor, more = run_results_future.get_result()
- if run_results:
- run_time = run_results[0].duration_now(now) or datetime.timedelta()
- if not cursor and run_results[0].state != task_result.State.RUNNING:
- # Add idle time since last task completed. Do not do this when a cursor
- # is used since it's not representative.
- idle_time = now - run_results[0].ended_ts
- for index in xrange(1, len(run_results)):
- # .started_ts will always be set by definition but .ended_ts may be None
- # if the task was abandoned. We can't count idle time since the bot may
- # have been busy running *another task*.
- # TODO(maruel): One option is to add a third value "broken_time".
- # Looking at timestamps specifically could help too, e.g. comparing
- # ended_ts of this task vs the next one to see if the bot was assigned
- # two tasks simultaneously.
- if run_results[index].ended_ts:
- idle_time += (
- run_results[index-1].started_ts - run_results[index].ended_ts)
- # We are taking the whole time the bot was doing work, not just the
- # duration associated with the task.
- duration = run_results[index].duration_as_seen_by_server
- if duration:
- run_time += duration
-
- events = events_future.get_result()
- bot = bot_future.get_result()
- if not bot and events:
- # If there is not BotInfo, look if there are BotEvent child of this
- # entity. If this is the case, it means the bot was deleted but it's
- # useful to show information about it to the user even if the bot was
- # deleted. For example, it could be an auto-scaled bot.
- bot = bot_management.BotInfo(
- key=bot_management.get_info_key(bot_id),
- dimensions_flat=bot_management.dimensions_to_flat(
- events[0].dimensions),
- state=events[0].state,
- external_ip=events[0].external_ip,
- authenticated_as=events[0].authenticated_as,
- version=events[0].version,
- quarantined=events[0].quarantined,
- task_id=events[0].task_id,
- last_seen_ts=events[0].ts)
-
- params = {
- 'bot': bot,
- 'bot_id': bot_id,
- # TODO(aludwin): Use the bot's correct gRPC status to determine the
- # version
- 'current_version': bot_code.get_bot_version(self.request.host_url),
- 'cursor': cursor.urlsafe() if cursor and more else None,
- 'events': events,
- 'idle_time': idle_time,
- 'is_admin': acl.is_admin(),
- 'limit': limit,
- 'now': now,
- 'run_results': run_results,
- 'run_time': run_time,
- 'try_link': '/bot?id=%s' % bot_id,
- 'xsrf_token': self.generate_xsrf_token(),
- }
- self.response.write(
- template.render('swarming/restricted_bot.html', params))
-
-
-class BotDeleteHandler(auth.AuthenticatingHandler):
- """Deletes a known bot.
-
- This only deletes the BotInfo, not BotRoot, BotEvent's nor BotSettings.
-
- This is sufficient so the bot doesn't show up on the Bots page while keeping
- historical data.
- """
-
- @auth.require(acl.is_admin)
- def post(self, bot_id):
- bot_key = bot_management.get_info_key(bot_id)
- if bot_key.get():
- bot_key.delete()
- self.redirect('/restricted/bots')
+ self.redirect('/bot?id=%s' % bot_id)
### User accessible pages.
class TasksHandler(auth.AuthenticatingHandler):
- """Lists all requests and allows callers to manage them."""
- # Each entry is an item in the Sort column.
- # Each entry is (key, text, hover)
- SORT_CHOICES = [
- ('created_ts', 'Created', 'Most recently created tasks are shown first.'),
- ('modified_ts', 'Active',
- 'Shows the most recently active tasks first. Using this order resets '
- 'state to \'All\'.'),
- ('completed_ts', 'Completed',
- 'Shows the most recently completed tasks first. Using this order resets '
- 'state to \'All\'.'),
- ('abandoned_ts', 'Abandoned',
- 'Shows the most recently abandoned tasks first. Using this order resets '
- 'state to \'All\'.'),
- ]
+ """Redirects to a list of all task requests."""
- # Each list is one column in the Task state filtering column.
- # Each sublist is the checkbox item in this column.
- # Each entry is (key, text, hover)
- # TODO(maruel): Evaluate what the categories the users would like for
- # diagnosis, then adapt the DB to enable efficient queries.
- STATE_CHOICES = [
- [
- ('all', 'All', 'All tasks ever requested independent of their state.'),
- ('pending', 'Pending',
- 'Tasks that are still ready to be assigned to a bot. Using this order '
- 'resets order to \'Created\'.'),
- ('running', 'Running',
- 'Tasks being currently executed by a bot. Using this order resets '
- 'order to \'Created\'.'),
- ('pending_running', 'Pending|running',
- 'Tasks either \'pending\' or \'running\'. Using this order resets '
- 'order to \'Created\'.'),
- ],
- [
- ('completed', 'Completed',
- 'All tasks that are completed, independent if the task itself '
- 'succeeded or failed. This excludes tasks that had an infrastructure '
- 'failure. Using this order resets order to \'Created\'.'),
- ('completed_success', 'Successes',
- 'Tasks that completed successfully. Using this order resets order to '
- '\'Created\'.'),
- ('completed_failure', 'Failures',
- 'Tasks that were executed successfully but failed, e.g. exit code is '
- 'non-zero. Using this order resets order to \'Created\'.'),
- ('timed_out', 'Timed out',
- 'The execution timed out, so it was forcibly killed.'),
- ],
- [
- ('bot_died', 'Bot died',
- 'The bot stopped sending updates while running the task, causing the '
- 'task execution to time out. This is considered an infrastructure '
- 'failure and the usual reason is that the bot BSOD\'ed or '
- 'spontaneously rebooted. Using this order resets order to '
- '\'Created\'.'),
- ('expired', 'Expired',
- 'The task was not assigned a bot until its expiration timeout, causing '
- 'the task to never being assigned to a bot. This can happen when the '
- 'dimension filter was not available or overloaded with a low priority. '
- 'Either fix the priority or bring up more bots with these dimensions. '
- 'Using this order resets order to \'Created\'.'),
- ('canceled', 'Canceled',
- 'The task was explictly canceled by a user before it started '
- 'executing. Using this order resets order to \'Created\'.'),
- ],
- ]
-
- @auth.autologin
- @auth.require(acl.is_user)
+ @auth.public
def get(self):
- cursor_str = self.request.get('cursor')
limit = int(self.request.get('limit', 100))
- sort = self.request.get('sort', self.SORT_CHOICES[0][0])
- state = self.request.get('state', self.STATE_CHOICES[0][0][0])
- counts = self.request.get('counts', '').strip()
task_tags = [
line for line in self.request.get('task_tag', '').splitlines() if line
]
- if not any(sort == i[0] for i in self.SORT_CHOICES):
- self.abort(400, 'Invalid sort')
- if not any(any(state == i[0] for i in j) for j in self.STATE_CHOICES):
- self.abort(400, 'Invalid state')
-
- if sort != 'created_ts':
- # Zap all filters in this case to reduce the number of required indexes.
- # Revisit according to the user requests.
- state = 'all'
-
- now = utils.utcnow()
- # "Temporarily" disable the count. This is too slow on the prod server
- # (>10s). The fix is to have the web page do a XHR query to get the values
- # asynchronously.
- counts_future = None
- if counts == 'true':
- counts_future = self._get_counts_future(now)
-
- try:
- if task_tags:
- # Enforce created_ts when tags are used.
- sort = 'created_ts'
- query = task_result.get_result_summaries_query(
- None, None, sort, state, task_tags)
- tasks, cursor_str = datastore_utils.fetch_page(query, limit, cursor_str)
-
- # Prefetch the TaskRequest all at once, so that ndb's in-process cache has
- # it instead of fetching them one at a time indirectly when using
- # TaskResultSummary.request_key.get().
- futures = ndb.get_multi_async(t.request_key for t in tasks)
-
- # Evaluate the counts to print the filtering columns with the associated
- # numbers.
- state_choices = self._get_state_choices(counts_future)
- except ValueError as e:
- self.abort(400, str(e))
-
- def safe_sum(items):
- return sum(items, datetime.timedelta())
-
- def avg(items):
- if not items:
- return 0.
- return safe_sum(items) / len(items)
-
- def median(items):
- if not items:
- return 0.
- middle = len(items) / 2
- if len(items) % 2:
- return items[middle]
- return (items[middle-1]+items[middle]) / 2
-
- gen = (t.duration_now(now) for t in tasks)
- durations = sorted(t for t in gen if t is not None)
- gen = (t.pending_now(now) for t in tasks)
- pendings = sorted(t for t in gen if t is not None)
- total_cost_usd = sum(t.cost_usd for t in tasks)
- total_cost_saved_usd = sum(
- t.cost_saved_usd for t in tasks if t.cost_saved_usd)
- # Include the overhead in the total amount of time saved, since it's
- # overhead saved.
- # In theory, t.duration_as_seen_by_server should always be set when
- # t.deduped_from is set but there has some broken entities in the datastore.
- total_saved = safe_sum(
- t.duration_as_seen_by_server for t in tasks
- if t.deduped_from and t.duration_as_seen_by_server)
- duration_sum = safe_sum(durations)
- total_saved_percent = (
- (100. * total_saved.total_seconds() / duration_sum.total_seconds())
- if duration_sum else 0.)
-
- try_link = '/tasklist?l=%d' % limit
+ new_ui_link = '/tasklist?l=%d' % limit
if task_tags:
- try_link += '&f=' + '&f='.join(task_tags)
- params = {
- 'cursor': cursor_str,
- 'duration_average': avg(durations),
- 'duration_median': median(durations),
- 'duration_sum': duration_sum,
- 'has_pending': any(t.is_pending for t in tasks),
- 'has_running': any(t.is_running for t in tasks),
- 'is_admin': acl.is_admin(),
- 'is_privileged_user': acl.is_privileged_user(),
- 'limit': limit,
- 'now': now,
- 'pending_average': avg(pendings),
- 'pending_median': median(pendings),
- 'pending_sum': safe_sum(pendings),
- 'show_footer': bool(pendings or durations),
- 'sort': sort,
- 'sort_choices': self.SORT_CHOICES,
- 'state': state,
- 'state_choices': state_choices,
- 'task_tag': '\n'.join(task_tags),
- 'tasks': tasks,
- 'total_cost_usd': total_cost_usd,
- 'total_cost_saved_usd': total_cost_saved_usd,
- 'total_saved': total_saved,
- 'total_saved_percent': total_saved_percent,
- 'try_link': try_link,
- 'xsrf_token': self.generate_xsrf_token(),
- }
- # TODO(maruel): If admin or if the user is task's .user, show the Cancel
- # button. Do not show otherwise.
- self.response.write(template.render('swarming/user_tasks.html', params))
+ new_ui_link += '&f=' + '&f='.join(task_tags)
- # Do not let dangling futures linger around.
- ndb.Future.wait_all(futures)
+ self.redirect(new_ui_link)
- def _get_counts_future(self, now):
- """Returns all the counting futures in parallel."""
- counts_future = {}
- last_24h = now - datetime.timedelta(days=1)
- for state_key, _, _ in itertools.chain.from_iterable(self.STATE_CHOICES):
- query = task_result.get_result_summaries_query(
- last_24h, None, 'created_ts', state_key, None)
- counts_future[state_key] = query.count_async()
- return counts_future
- def _get_state_choices(self, counts_future):
- """Converts STATE_CHOICES with _get_counts_future() into nice text."""
- # Appends the number of tasks for each filter. It gives a sense of how much
- # things are going on.
- if counts_future:
- counts = {k: v.get_result() for k, v in counts_future.iteritems()}
- state_choices = []
- for choice_list in self.STATE_CHOICES:
- state_choices.append([])
- for state_key, name, title in choice_list:
- if counts_future:
- name += ' (%d)' % counts[state_key]
- state_choices[-1].append((state_key, name, title))
- return state_choices
+class TaskHandler(auth.AuthenticatingHandler):
+ """Redirects to a page containing task request and result."""
-
-class BaseTaskHandler(auth.AuthenticatingHandler):
- """Handler that acts on a single task.
-
- Ensures that the user has access to the task.
- """
- def get_request_and_result(self, task_id, with_secret_bytes=False):
- """Retrieves the TaskRequest for 'task_id' and enforces the ACL.
-
- Supports both TaskResultSummary (ends with 0) or TaskRunResult (ends with 1
- or 2).
-
- Returns:
- tuple(TaskRequest, SecretBytes, result): result can be either for
- a TaskRunResult or a TaskResultSummay.
- """
- try:
- key = task_pack.unpack_result_summary_key(task_id)
- request_key = task_pack.result_summary_key_to_request_key(key)
- except ValueError:
- try:
- key = task_pack.unpack_run_result_key(task_id)
- request_key = task_pack.result_summary_key_to_request_key(
- task_pack.run_result_key_to_result_summary_key(key))
- except ValueError:
- self.abort(404, 'Invalid key format.')
- if with_secret_bytes:
- sb_key = task_pack.request_key_to_secret_bytes_key(request_key)
- request, result, secret_bytes = ndb.get_multi((request_key, key, sb_key))
- else:
- request, result = ndb.get_multi((request_key, key))
- secret_bytes = None
- if not request or not result:
- self.abort(404, '%s not found.' % key.id())
- if not request.has_access:
- self.abort(403, '%s is not accessible.' % key.id())
- return request, secret_bytes, result
-
-
-class TaskHandler(BaseTaskHandler):
- """Show the full text of a task request and its result."""
-
- @staticmethod
- def packages_grouped_by_path(flat_packages):
- """Returns sorted [(path, [PinInfo, ...])].
-
- Used by user_task.html.
- """
- retval = collections.defaultdict(list)
- for pkg in flat_packages:
- retval[pkg.path].append(pkg)
- return sorted(retval.iteritems())
-
- @auth.autologin
- @auth.require(acl.is_user)
+ @auth.public
def get(self, task_id):
- request, _, result = self.get_request_and_result(task_id)
- parent_task_future = None
- if request.parent_task_id:
- parent_key = task_pack.unpack_run_result_key(request.parent_task_id)
- parent_task_future = parent_key.get_async()
- children_tasks_futures = [
- task_pack.unpack_result_summary_key(c).get_async()
- for c in result.children_task_ids
- ]
-
- bot_id = result.bot_id
- following_task_future = None
- previous_task_future = None
- if result.started_ts:
- # Use a shortcut name because it becomes unwieldy otherwise.
- cls = task_result.TaskRunResult
-
- # Note that the links will be to the TaskRunResult, not to
- # TaskResultSummary.
- following_task_future = cls.query(
- cls.bot_id == bot_id,
- cls.started_ts > result.started_ts,
- ).order(cls.started_ts).get_async()
- previous_task_future = cls.query(
- cls.bot_id == bot_id,
- cls.started_ts < result.started_ts,
- ).order(-cls.started_ts).get_async()
-
- bot_future = (
- bot_management.get_info_key(bot_id).get_async() if bot_id else None)
-
- following_task = None
- if following_task_future:
- following_task = following_task_future.get_result()
-
- previous_task = None
- if previous_task_future:
- previous_task = previous_task_future.get_result()
-
- parent_task = None
- if parent_task_future:
- parent_task = parent_task_future.get_result()
- children_tasks = [c.get_result() for c in children_tasks_futures]
-
- cipd = None
- if request.properties.cipd_input:
- cipd = {
- 'server': request.properties.cipd_input.server,
- 'client_package': request.properties.cipd_input.client_package,
- 'packages': self.packages_grouped_by_path(
- request.properties.cipd_input.packages),
- }
-
- cipd_pins = None
- if result.cipd_pins:
- cipd_pins = {
- 'client_package': result.cipd_pins.client_package,
- 'packages': self.packages_grouped_by_path(result.cipd_pins.packages),
- }
-
- params = {
- 'bot': bot_future.get_result() if bot_future else None,
- 'children_tasks': children_tasks,
- 'cipd': cipd,
- 'cipd_pins': cipd_pins,
- 'is_admin': acl.is_admin(),
- 'is_gae_admin': users.is_current_user_admin(),
- 'is_privileged_user': acl.is_privileged_user(),
- 'following_task': following_task,
- 'full_appid': os.environ['APPLICATION_ID'],
- 'host_url': self.request.host_url,
- 'is_running': result.state == task_result.State.RUNNING,
- 'parent_task': parent_task,
- 'previous_task': previous_task,
- 'request': request,
- 'task': result,
- 'try_link': '/task?id=%s' % task_id,
- 'xsrf_token': self.generate_xsrf_token(),
- }
- self.response.write(template.render('swarming/user_task.html', params))
-
-
-class TaskCancelHandler(BaseTaskHandler):
- """Cancel a task."""
-
- @auth.require(acl.is_user)
- def post(self, task_id):
- request, _, result = self.get_request_and_result(task_id)
- if not task_scheduler.cancel_task(request, result.key)[0]:
- self.abort(400, 'Task cancelation error')
- # The cancel button appears at both the /tasks and /task pages. Redirect to
- # the right place.
- if self.request.get('redirect_to', '') == 'listing':
- self.redirect('/user/tasks')
- else:
- self.redirect('/user/task/%s' % task_id)
-
-
-class TaskRetryHandler(BaseTaskHandler):
- """Retries the same task but with new metadata.
-
- Retrying a task forcibly make it not idempotent so the task is unconditionally
- scheduled.
- """
-
- @auth.require(acl.is_user)
- def post(self, task_id):
- original_request, secret_bytes, _ = self.get_request_and_result(
- task_id, with_secret_bytes=True)
- # Retrying a task is essentially reusing the same task request as the
- # original one, but with new parameters.
- new_request = task_request.new_request_clone(
- original_request, secret_bytes,
- allow_high_priority=acl.can_schedule_high_priority_tasks())
- result_summary = task_scheduler.schedule_request(
- new_request, secret_bytes)
- self.redirect('/user/task/%s' % result_summary.task_id)
+ self.redirect('/task?id=%s' % task_id)
### Public pages.
@@ -840,13 +310,10 @@ def create_application(debug):
# User pages.
('/user/tasks', TasksHandler),
('/user/task/<task_id:[0-9a-fA-F]+>', TaskHandler),
- ('/user/task/<task_id:[0-9a-fA-F]+>/cancel', TaskCancelHandler),
- ('/user/task/<task_id:[0-9a-fA-F]+>/retry', TaskRetryHandler),
# Privileged user pages.
('/restricted/bots', BotsListHandler),
('/restricted/bot/<bot_id:[^/]+>', BotHandler),
- ('/restricted/bot/<bot_id:[^/]+>/delete', BotDeleteHandler),
# Admin pages.
('/restricted/config', RestrictedConfigHandler),
« no previous file with comments | « no previous file | appengine/swarming/handlers_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698