diff --git a/contrib/README.rst b/contrib/README.rst
new file mode 100644
index 000000000..30e77c078
--- /dev/null
+++ b/contrib/README.rst
@@ -0,0 +1,41 @@
+labgrid-webapp
+==============
+
+labgrid-webapp implements a browser interface to access some of labgrid's
+information.
+
+Quick Start
+-----------
+
+.. code-block:: bash
+
+ $ cd labgrid/
+ $ source venv/bin/activate
+ venv $ pip install -r contrib/requirements-webapp.txt
+ venv $ ./contrib/labgrid-webapp --help
+ usage: labgrid-webapp [-h] [--crossbar URL] [--port PORT] [--proxy PROXY]
+
+ Labgrid webapp
+
+ options:
+ -h, --help show this help message and exit
+ --crossbar URL, -x URL
+ Crossbar websocket URL (default: ws://127.0.0.1:20408/ws)
+ --port PORT Port to serve on
+ --proxy PROXY, -P PROXY
+
+ venv $ ./contrib/labgrid-webapp --help
+ INFO: Available routes:
+ INFO: - /labgrid/graph
+ INFO: Started server process [2378028]
+ INFO: Waiting for application startup.
+ INFO: Application startup complete.
+ INFO: Uvicorn running on http://0.0.0.0:8800 (Press CTRL+C to quit)
+ ...
+
+Please note that the graph feature relies on a valid `graphviz` system
+installation.
+
+By default the application will start on port 8800.
+
+See http://0.0.0.0:8800/docs for more information on available endpoints.
diff --git a/contrib/labgrid-webapp b/contrib/labgrid-webapp
new file mode 100755
index 000000000..bd4a22178
--- /dev/null
+++ b/contrib/labgrid-webapp
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+import argparse
+import logging
+import os
+import sys
+from typing import Dict
+
+import graphviz
+import uvicorn
+from fastapi import FastAPI
+from fastapi.responses import Response
+
+from labgrid.remote.client import ClientSession, start_session
+from labgrid.remote.common import Place
+from labgrid.resource import Resource
+from labgrid.util.proxy import proxymanager
+
+
+async def do_graph(session: ClientSession) -> bytes:
+ '''Generate a graphviz graph of the current configuration.
+
+ Graph displays:
+ - all resources, grouped by groupname and exporter.
+ - all places, with a list of tags
+ - solid edges between places and acquired resources
+ - dotted edges between places and unacquired resources
+ - edges between resources and places carry the match name if any.
+ '''
+ def res_node_attr(name: str, resource: Resource) -> Dict[str, str]:
+ return {
+ 'shape': 'plaintext',
+ 'label': f'''<
+
+
+ Resource |
+
+
+ {resource.cls} |
+ {name} |
+
+
>''',
+ }
+
+ def place_node_attr(name: str, place: Place) -> Dict[str, str]:
+ acquired = ''
+ bgcolor = 'lightblue'
+ if place.acquired:
+ bgcolor = 'cornflowerblue'
+ acquired = f'{place.acquired} | '
+
+ tags = 'Tags |
' if place.tags else ''
+ for k, v in place.tags.items():
+ tags += f' | {k}={v} |
'
+
+ return {
+ 'shape': 'plaintext',
+ 'label': f'''<
+
+
+ Place |
+ {acquired}
+
+
+ {name} |
+
+ {tags}
+
>''',
+ }
+
+ g = graphviz.Digraph('G')
+ g.attr(rankdir='LR')
+
+ paths = {}
+ for exporter, groups in session.resources.items():
+ g_exporter = graphviz.Digraph(f'cluster_{exporter}')
+ g_exporter.attr(label=exporter)
+
+ for group, resources in groups.items():
+ g_group = graphviz.Digraph(f'cluster_{group}')
+ g_group.attr(label=group)
+
+ for r_name, entry in resources.items():
+ res_node = f'{exporter}/{group}/{entry.cls}/{r_name}'.replace(':', '_')
+ paths[res_node] = [exporter, group, entry.cls, r_name]
+ g_group.node(res_node, **res_node_attr(r_name, entry))
+
+ g_exporter.subgraph(g_group)
+
+ g.subgraph(g_exporter)
+
+ for p_node, place in session.places.items():
+ g.node(p_node, **place_node_attr(p_node, place))
+
+ for m in place.matches:
+ for node, p in paths.items():
+ if m.ismatch(p):
+ g.edge(
+ f'{node}:name', p_node,
+ style='solid' if place.acquired else 'dotted',
+ label=m.rename if m.rename else None,
+ )
+
+ return g.pipe(format='svg')
+
+
+def main():
+ app = FastAPI()
+ logger = logging.getLogger('uvicorn')
+
+ @app.get('/labgrid/graph')
+ async def get_graph() -> str:
+ '''Show a graph of the current infrastructure.'''
+ svg = await do_graph(session)
+ return Response(content=svg, media_type='image/svg+xml')
+
+ parser = argparse.ArgumentParser(
+ description='Labgrid webapp',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ '--crossbar',
+ '-x',
+ metavar='URL',
+ default=os.environ.get('LG_CROSSBAR', 'ws://127.0.0.1:20408/ws'),
+ help='Crossbar websocket URL (default: %(default)s)',
+ )
+ parser.add_argument('--port', type=int, default=8800, help='Port to serve on')
+ parser.add_argument('--proxy', '-P', help='Proxy connections via given ssh host')
+
+ args = parser.parse_args()
+
+ if args.proxy:
+ proxymanager.force_proxy(args.proxy)
+
+ try:
+ session = start_session(
+ args.crossbar, os.environ.get('LG_CROSSBAR_REALM', 'realm1'), {},
+ )
+ except ConnectionRefusedError:
+ logger.fatal('Unable to connect to labgrid crossbar')
+ return
+
+ server = uvicorn.Server(config=uvicorn.Config(
+ loop=session.loop,
+ host='0.0.0.0',
+ port=args.port,
+ app=app,
+ ))
+
+ logger.info('Available routes:')
+ for route in app.routes:
+ reserved_routes = ['/openapi.json', '/docs', '/docs/oauth2-redirect', '/redoc']
+ if route.path not in reserved_routes:
+ logger.info(f' - {route.path}')
+
+ session.loop.run_until_complete(server.serve())
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/contrib/requirements-webapp.txt b/contrib/requirements-webapp.txt
new file mode 100644
index 000000000..539b76d5e
--- /dev/null
+++ b/contrib/requirements-webapp.txt
@@ -0,0 +1,3 @@
+fastapi
+graphviz
+uvicorn