1
mirror of https://github.com/home-assistant/core synced 2024-07-21 14:24:50 +02:00

Dynamic panels (#24184)

* Don't require all panel urls to be registered

* Allow removing panels, fire event when panels updated
This commit is contained in:
Paulus Schoutsen 2019-05-30 04:37:01 -07:00 committed by Pascal Vizeli
parent 59ce31f44f
commit 1a3a38d370
14 changed files with 133 additions and 58 deletions

View File

@ -36,7 +36,7 @@ async def async_setup(hass, config):
hass.http.register_view(CalendarEventView(component))
# Doesn't work in prod builds of the frontend: home-assistant-polymer#1289
# await hass.components.frontend.async_register_built_in_panel(
# hass.components.frontend.async_register_built_in_panel(
# 'calendar', 'calendar', 'hass:calendar')
await component.async_setup(config)

View File

@ -30,7 +30,7 @@ ON_DEMAND = ('zwave',)
async def async_setup(hass, config):
"""Set up the config component."""
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'config', 'config', 'hass:settings', require_admin=True)
async def setup_panel(panel_name):

View File

@ -1,5 +1,4 @@
"""Handle the frontend for Home Assistant."""
import asyncio
import json
import logging
import os
@ -26,6 +25,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url'
CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5'
CONF_FRONTEND_REPO = 'development_repo'
CONF_JS_VERSION = 'javascript_version'
EVENT_PANELS_UPDATED = 'panels_updated'
DEFAULT_THEME_COLOR = '#03A9F4'
@ -97,6 +97,28 @@ SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
})
def generate_negative_index_regex():
"""Generate regex for index."""
skip = [
# files
"service_worker.js",
"robots.txt",
"onboarding.html",
"manifest.json",
]
for folder in (
"static",
"frontend_latest",
"frontend_es5",
"local",
"auth",
"api",
):
# Regex matching static, static/, static/index.html
skip.append("{}(/|/.+|)".format(folder))
return r"(?!(" + "|".join(skip) + r")).*"
class Panel:
"""Abstract class for panels."""
@ -128,15 +150,6 @@ class Panel:
self.config = config
self.require_admin = require_admin
@callback
def async_register_index_routes(self, router, index_view):
"""Register routes for panel to be served by index view."""
router.add_route(
'get', '/{}'.format(self.frontend_url_path), index_view.get)
router.add_route(
'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path),
index_view.get)
@callback
def to_response(self):
"""Panel as dictionary."""
@ -151,26 +164,36 @@ class Panel:
@bind_hass
async def async_register_built_in_panel(hass, component_name,
sidebar_title=None, sidebar_icon=None,
frontend_url_path=None, config=None,
require_admin=False):
@callback
def async_register_built_in_panel(hass, component_name,
sidebar_title=None, sidebar_icon=None,
frontend_url_path=None, config=None,
require_admin=False):
"""Register a built-in panel."""
panel = Panel(component_name, sidebar_title, sidebar_icon,
frontend_url_path, config, require_admin)
panels = hass.data.get(DATA_PANELS)
if panels is None:
panels = hass.data[DATA_PANELS] = {}
panels = hass.data.setdefault(DATA_PANELS, {})
if panel.frontend_url_path in panels:
_LOGGER.warning("Overwriting component %s", panel.frontend_url_path)
if DATA_FINALIZE_PANEL in hass.data:
hass.data[DATA_FINALIZE_PANEL](panel)
panels[panel.frontend_url_path] = panel
hass.bus.async_fire(EVENT_PANELS_UPDATED)
@bind_hass
@callback
def async_remove_panel(hass, frontend_url_path):
"""Remove a built-in panel."""
panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None)
if panel is None:
_LOGGER.warning("Removing unknown panel %s", frontend_url_path)
hass.bus.async_fire(EVENT_PANELS_UPDATED)
@bind_hass
@callback
@ -233,28 +256,14 @@ async def async_setup(hass, config):
if os.path.isdir(local):
hass.http.register_static_path("/local", local, not is_dev)
index_view = IndexView(repo_path)
hass.http.register_view(index_view)
hass.http.register_view(IndexView(repo_path))
@callback
def async_finalize_panel(panel):
"""Finalize setup of a panel."""
panel.async_register_index_routes(hass.http.app.router, index_view)
for panel in ('kiosk', 'states', 'profile'):
async_register_built_in_panel(hass, panel)
await asyncio.wait(
[async_register_built_in_panel(hass, panel) for panel in (
'kiosk', 'states', 'profile')])
await asyncio.wait(
[async_register_built_in_panel(hass, panel, require_admin=True)
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
'dev-template', 'dev-mqtt')])
hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel
# Finalize registration of panels that registered before frontend was setup
# This includes the built-in panels from line above.
for panel in hass.data[DATA_PANELS].values():
async_finalize_panel(panel)
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
'dev-template', 'dev-mqtt'):
async_register_built_in_panel(hass, panel, require_admin=True)
if DATA_EXTRA_HTML_URL not in hass.data:
hass.data[DATA_EXTRA_HTML_URL] = set()
@ -324,6 +333,9 @@ class IndexView(HomeAssistantView):
url = '/'
name = 'frontend:index'
requires_auth = False
extra_urls = [
"/{extra:%s}" % generate_negative_index_regex()
]
def __init__(self, repo_path):
"""Initialize the frontend view."""
@ -349,6 +361,10 @@ class IndexView(HomeAssistantView):
"""Serve the index view."""
hass = request.app['hass']
if (request.path != '/' and
request.url.parts[1] not in hass.data[DATA_PANELS]):
raise web.HTTPNotFound
if not hass.components.onboarding.async_is_onboarded():
return web.Response(status=302, headers={
'location': '/onboarding.html'

View File

@ -61,7 +61,7 @@ class HassIOAddonPanel(HomeAssistantView):
async def delete(self, request, addon):
"""Handle remove add-on panel requests."""
# Currently not supported by backend / frontend
self.hass.components.frontend.async_remove_panel(addon)
return web.Response()
async def get_panels(self):

View File

@ -252,7 +252,7 @@ async def async_setup(hass, config):
use_include_order = conf.get(CONF_ORDER)
hass.http.register_view(HistoryPeriodView(filters, use_include_order))
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'history', 'history', 'hass:poll-box')
return True

View File

@ -102,7 +102,7 @@ async def async_setup(hass, config):
hass.http.register_view(LogbookView(config.get(DOMAIN, {})))
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'logbook', 'logbook', 'hass:format-list-bulleted-type')
hass.services.async_register(

View File

@ -53,7 +53,7 @@ async def async_setup(hass, config):
# Pass in default to `get` because defaults not set if loaded as dep
mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE)
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
DOMAIN, config={
'mode': mode
})

View File

@ -30,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass, config):
"""Track states and offer events for mailboxes."""
mailboxes = []
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'mailbox', 'mailbox', 'mdi:mailbox')
hass.http.register_view(MailboxPlatformsView(mailboxes))
hass.http.register_view(MailboxMessageView(mailboxes))

View File

@ -4,6 +4,6 @@ DOMAIN = 'map'
async def async_setup(hass, config):
"""Register the built-in map panel."""
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'map', 'map', 'hass:tooltip-account')
return True

View File

@ -112,7 +112,7 @@ async def async_register_panel(
config['_panel_custom'] = custom_panel_config
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
component_name='custom',
sidebar_title=sidebar_title,
sidebar_icon=sidebar_icon,

View File

@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({
async def async_setup(hass, config):
"""Set up the iFrame frontend panels."""
for url_path, info in config[DOMAIN].items():
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'iframe', info.get(CONF_TITLE), info.get(CONF_ICON),
url_path, {'url': info[CONF_URL]},
require_admin=info[CONF_REQUIRE_ADMIN])

View File

@ -117,7 +117,7 @@ def async_setup(hass, config):
'What is on my shopping list'
])
yield from hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'shopping-list', 'shopping_list', 'mdi:cart')
hass.components.websocket_api.async_register_command(

View File

@ -14,11 +14,13 @@ from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.components.frontend import EVENT_PANELS_UPDATED
# These are events that do not contain any sensitive data
# Except for state_changed, which is handled accordingly.
SUBSCRIBE_WHITELIST = {
EVENT_COMPONENT_LOADED,
EVENT_PANELS_UPDATED,
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED,
EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED,

View File

@ -8,10 +8,11 @@ import pytest
from homeassistant.setup import async_setup_component
from homeassistant.components.frontend import (
DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL,
CONF_EXTRA_HTML_URL_ES5)
CONF_EXTRA_HTML_URL_ES5, generate_negative_index_regex,
EVENT_PANELS_UPDATED)
from homeassistant.components.websocket_api.const import TYPE_RESULT
from tests.common import mock_coro
from tests.common import mock_coro, async_capture_events
CONFIG_THEMES = {
@ -232,12 +233,21 @@ def test_extra_urls(mock_http_client_with_urls, mock_onboarded):
assert text.find('href="https://domain.com/my_extra_url.html"') >= 0
async def test_get_panels(hass, hass_ws_client):
async def test_get_panels(hass, hass_ws_client, mock_http_client):
"""Test get_panels command."""
await async_setup_component(hass, 'frontend', {})
await hass.components.frontend.async_register_built_in_panel(
events = async_capture_events(hass, EVENT_PANELS_UPDATED)
resp = await mock_http_client.get('/map')
assert resp.status == 404
hass.components.frontend.async_register_built_in_panel(
'map', 'Map', 'mdi:tooltip-account', require_admin=True)
resp = await mock_http_client.get('/map')
assert resp.status == 200
assert len(events) == 1
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
@ -255,14 +265,21 @@ async def test_get_panels(hass, hass_ws_client):
assert msg['result']['map']['title'] == 'Map'
assert msg['result']['map']['require_admin'] is True
hass.components.frontend.async_remove_panel('map')
resp = await mock_http_client.get('/map')
assert resp.status == 404
assert len(events) == 2
async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user):
"""Test get_panels command."""
hass_admin_user.groups = []
await async_setup_component(hass, 'frontend', {})
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'map', 'Map', 'mdi:tooltip-account', require_admin=True)
await hass.components.frontend.async_register_built_in_panel(
hass.components.frontend.async_register_built_in_panel(
'history', 'History', 'mdi:history')
client = await hass_ws_client(hass)
@ -331,3 +348,43 @@ async def test_auth_authorize(mock_http_client):
resp = await mock_http_client.get(authorizejs.groups(0)[0])
assert resp.status == 200
assert 'public' in resp.headers.get('cache-control')
def test_index_regex():
"""Test the index regex."""
pattern = re.compile('/' + generate_negative_index_regex())
for should_match in (
'/',
'/lovelace',
'/lovelace/default_view',
'/map',
'/config',
):
assert pattern.match(should_match), should_match
for should_not_match in (
'/service_worker.js',
'/manifest.json',
'/onboarding.html',
'/manifest.json',
'static',
'static/',
'static/index.html',
'frontend_latest',
'frontend_latest/',
'frontend_latest/index.html',
'frontend_es5',
'frontend_es5/',
'frontend_es5/index.html',
'local',
'local/',
'local/index.html',
'auth',
'auth/',
'auth/index.html',
'/api',
'/api/',
'/api/logbook',
):
assert not pattern.match(should_not_match), should_not_match