Compare commits

...

47 Commits

Author SHA1 Message Date
Zack
191f81d9fe Add Redirect for Server Controls 2022-04-27 16:54:17 -05:00
Philip Allgaier
2751f8f33b Add some bottom padding to YAML conf dev tools page (#12477)
Co-authored-by: Zack Barett <zackbarett@hey.com>
2022-04-27 22:18:25 +02:00
Philip Allgaier
57f2df3b3e Visual tweaks to YAML validation results (#12479) 2022-04-27 19:57:41 +00:00
Zack Barett
6822f0d067 Small config fixes (#12472) 2022-04-27 12:22:57 -07:00
Zack Barett
cfba957313 Fix YAML Config Invalid button (#12476) 2022-04-27 13:57:57 -05:00
Yosi Levy
3149ffbf19 RTL fix for log buttons (#12474) 2022-04-27 12:26:19 -05:00
Philip Allgaier
4cd8b76d7e Safeguard against non-existant area in device handling (#12475) 2022-04-27 12:25:13 -05:00
Joakim Sørensen
4b644d8bc5 Add supervisor redirects to m keyboard shortcut (#12466) 2022-04-27 13:36:47 +00:00
Joakim Sørensen
307cd5ad8c Use startsWith for m shortcut for partial match (#12464) 2022-04-27 08:10:38 -05:00
Joakim Sørensen
ebc807a6a4 Add hass-quick-bar-trigger event to trigger quickbar from supervisor (#12467) 2022-04-27 08:08:45 -05:00
Philip Allgaier
66adecdfc9 Make helper option button more user friendly (#12468) 2022-04-27 08:07:57 -05:00
Philip Allgaier
2cc6432a0f Use correct label for update config menu (#12465) 2022-04-27 06:37:50 -05:00
Paulus Schoutsen
a2c0c0474a Bumped version to 20220427.0 2022-04-26 22:13:16 -07:00
Zack Barett
27884b9a54 Move Restart to Overflow and yaml config advanced (#12446)
* Move Restart to Overflow and yaml config advanced

* Move around YAML Config page

* Move to developer tools

* Make card actions

* Update Translations
2022-04-26 22:12:44 -07:00
Paulus Schoutsen
293df61872 Add a tip for my shortcut (#12462) 2022-04-27 05:01:40 +00:00
Paulus Schoutsen
f82dada3e5 Fix icon alignment in nav list (#12463) 2022-04-26 21:58:26 -07:00
Paulus Schoutsen
e5824c4794 Fix my link for config dashboard and profile (#12461)
* Fix my link for config dashboard and profile

* add server control redirect

Co-authored-by: Zack <zackbarett@hey.com>
2022-04-27 04:58:18 +00:00
Paulus Schoutsen
186550229c Tweak menu descriptions (#12460) 2022-04-27 04:53:42 +00:00
Zack Barett
7877dd8e6b Move Zones Edit to General config + add general config page (#12452)
* Move Zones Edit to General config + add general

* Update src/translations/en.json

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* add paper tooltip back for yaml

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-04-26 21:53:29 -07:00
Zack Barett
b03abc249b Fix Updates Page Toast - Move to overflow (#12453) 2022-04-26 21:52:22 -07:00
Paulus Schoutsen
fda03918b9 Move the analytics link (#12459) 2022-04-27 04:40:01 +00:00
Zack Barett
6747375a1b Move Provider Selection to Menu on top header (#12443) 2022-04-26 23:27:15 -05:00
Zack Barett
53b6e31881 Update Configuration badge color to be accent color to match (#12455) 2022-04-26 21:12:09 -07:00
Zack Barett
fa004de2d1 Fix more info input number #12396 (#12456) 2022-04-26 21:11:54 -07:00
Zack Barett
3605f7b70f Fix when creating new area in picker #11392 (#12457) 2022-04-26 21:11:38 -07:00
Zack Barett
5348c54c91 Update the hint for key C (#12458) 2022-04-26 21:11:18 -07:00
Zack Barett
684e4421bc Fix for backup overflow (#12454) 2022-04-26 21:10:35 -07:00
Zack Barett
28f5611df5 Small edits on config menu (#12440) 2022-04-26 21:07:53 -07:00
Johann Vanackere
8da73d49d7 Terms based entities search (#10991) 2022-04-26 19:39:58 -05:00
Joakim Sørensen
049ddd5f84 Add "m" keyboard shortcut to get to the create my link page (#12451) 2022-04-27 00:11:09 +02:00
Bram Kragten
8ae2d4e93a Fix integration page on mobile (#12447) 2022-04-26 14:38:59 -05:00
Philip Allgaier
824bb9ba35 Add title to backups config page (#12442) 2022-04-26 21:04:32 +02:00
Philip Allgaier
d550b1a18e Fix content display for ha-network after #12438 (#12445)
* Fix content display for `ha-network` after #12438

* Add var default
2022-04-26 20:41:19 +02:00
Bram Kragten
dea6c0e761 Add header to supervisor backups page (#12444) 2022-04-26 17:53:32 +00:00
Philip Allgaier
9caee357c0 Fix incorrect text if no backups are found (#12441) 2022-04-26 12:32:04 -05:00
Bram Kragten
35d892c418 Set border radius in config to 8px (#12437) 2022-04-26 11:50:36 -05:00
Bram Kragten
9572a2a46b Dont show tabs when less than 2 (#12439) 2022-04-26 15:39:50 +00:00
Bram Kragten
8996361b26 Fix settings row width (#12438) 2022-04-26 15:17:00 +00:00
Joakim Sørensen
02ee731602 Add join/leave beta to updates panel (#12436) 2022-04-26 16:39:37 +02:00
Joakim Sørensen
bb1e6bf35b Fix backup back path (#12435) 2022-04-26 15:29:56 +02:00
Joakim Sørensen
c1b65285c1 Redirect hassio system my links to new locations (#12429) 2022-04-26 13:15:29 +02:00
Bram Kragten
8b8d6e5fa3 Resources lovelace should just go back (#12432) 2022-04-26 11:12:14 +00:00
Joakim Sørensen
c34fe184e8 Fix log syntax highlight when fetching logs from supervisor (#12430) 2022-04-26 06:09:39 -05:00
Joakim Sørensen
7363838f86 Move unsupported and unhealthy alerts (#12431) 2022-04-26 12:24:55 +02:00
Jaroslav Hanslík
3081425ccd Typo in en.json (#12428) 2022-04-26 12:20:26 +02:00
Joakim Sørensen
95d494a54c Guard against non OS installation (#12427) 2022-04-26 12:18:43 +02:00
J. Nick Koston
145e5d7bc6 Format sensors with state class duration (#12426) 2022-04-26 02:07:11 +00:00
67 changed files with 2007 additions and 2197 deletions

View File

@@ -2,6 +2,7 @@ import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-ansi-to-html";
import "../../../../src/components/ha-card";
import {
fetchHassioAddonLogs,
@@ -11,7 +12,6 @@ import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
import { haStyle } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
import "../../components/hassio-ansi-to-html";
import { hassioStyle } from "../../resources/hassio-style";
@customElement("hassio-addon-logs")
@@ -40,9 +40,9 @@ class HassioAddonLogs extends LitElement {
: ""}
<div class="card-content">
${this._content
? html`<hassio-ansi-to-html
? html`<ha-ansi-to-html
.content=${this._content}
></hassio-ansi-to-html>`
></ha-ansi-to-html>`
: ""}
</div>
<div class="card-actions">

View File

@@ -1,7 +1,7 @@
import "@material/mwc-button";
import { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js";
import { mdiBackupRestore, mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js";
import {
css,
CSSResultGroup,
@@ -166,7 +166,15 @@ export class HassioBackups extends LitElement {
}
return html`
<hass-tabs-subpage-data-table
.tabs=${supervisorTabs(this.hass)}
.tabs=${atLeastVersion(this.hass.config.version, 2022, 5)
? [
{
translationKey: "panel.backups",
path: `/hassio/backups`,
iconPath: mdiBackupRestore,
},
]
: supervisorTabs(this.hass)}
.hass=${this.hass}
.localizeFunc=${this.supervisor.localize}
.searchLabel=${this.supervisor.localize("search")}
@@ -182,7 +190,9 @@ export class HassioBackups extends LitElement {
selectable
hasFab
.mainPage=${!atLeastVersion(this.hass.config.version, 2021, 12)}
back-path="/config"
back-path=${atLeastVersion(this.hass.config.version, 2022, 5)
? "/config/system"
: "/config"}
supervisor
>
<ha-button-menu

View File

@@ -3,8 +3,8 @@ import { customElement, property } from "lit/decorators";
import { atLeastVersion } from "../../src/common/config/version";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { fireEvent } from "../../src/common/dom/fire_event";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { mainWindow } from "../../src/common/dom/get_main_window";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { navigate } from "../../src/common/navigate";
import { HassioPanelInfo } from "../../src/data/hassio/supervisor";
import { Supervisor } from "../../src/data/supervisor/supervisor";
@@ -73,6 +73,14 @@ export class HassioMain extends SupervisorBaseElement {
});
});
// Forward keydown events to the main window for quickbar access
document.body.addEventListener("keydown", (ev) => {
// @ts-ignore
fireEvent(mainWindow, "hass-quick-bar-trigger", ev, {
bubbles: false,
});
});
makeDialogManager(this, this.shadowRoot!);
}

View File

@@ -15,7 +15,7 @@ import {
} from "../../src/panels/my/ha-panel-my";
import { HomeAssistant, Route } from "../../src/types";
const REDIRECTS: Redirects = {
export const REDIRECTS: Redirects = {
supervisor: {
redirect: "/hassio/dashboard",
},

View File

@@ -23,6 +23,10 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../src/dialogs/generic/show-dialog-box";
import {
UNHEALTHY_REASON_URL,
UNSUPPORTED_REASON_URL,
} from "../../../src/panels/config/system-health/ha-config-system-health";
import { haStyle } from "../../../src/resources/styles";
import { HomeAssistant } from "../../../src/types";
import { bytesToString } from "../../../src/util/bytes-to-string";
@@ -30,11 +34,6 @@ import { documentationUrl } from "../../../src/util/documentation-url";
import "../components/supervisor-metric";
import { hassioStyle } from "../resources/hassio-style";
const UNSUPPORTED_REASON_URL = {};
const UNHEALTHY_REASON_URL = {
privileged: "/more-info/unsupported/privileged",
};
@customElement("hassio-supervisor-info")
class HassioSupervisorInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;

View File

@@ -1,3 +1,4 @@
import "../../../src/components/ha-ansi-to-html";
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -11,7 +12,6 @@ import { Supervisor } from "../../../src/data/supervisor/supervisor";
import "../../../src/layouts/hass-loading-screen";
import { haStyle } from "../../../src/resources/styles";
import { HomeAssistant } from "../../../src/types";
import "../components/hassio-ansi-to-html";
import { hassioStyle } from "../resources/hassio-style";
interface LogProvider {
@@ -89,8 +89,8 @@ class HassioSupervisorLog extends LitElement {
<div class="card-content" id="content">
${this._content
? html`<hassio-ansi-to-html .content=${this._content}>
</hassio-ansi-to-html>`
? html`<ha-ansi-to-html .content=${this._content}>
</ha-ansi-to-html>`
: html`<hass-loading-screen no-toolbar></hass-loading-screen>`}
</div>
<div class="card-actions">

View File

@@ -106,6 +106,7 @@
"deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"fuzzysort": "^1.2.1",
"google-timezones-json": "^1.0.2",
"hls.js": "^1.1.5",
"home-assistant-js-websocket": "^7.0.3",

View File

@@ -1,6 +1,6 @@
[metadata]
name = home-assistant-frontend
version = 20220425.0
version = 20220427.0
author = The Home Assistant Authors
author_email = hello@home-assistant.io
license = Apache-2.0

View File

@@ -0,0 +1,16 @@
import secondsToDuration from "./seconds_to_duration";
const DAY_IN_SECONDS = 86400;
const HOUR_IN_SECONDS = 3600;
const MINUTE_IN_SECONDS = 60;
export const UNIT_TO_SECOND_CONVERT = {
s: 1,
min: MINUTE_IN_SECONDS,
h: HOUR_IN_SECONDS,
d: DAY_IN_SECONDS,
};
export const formatDuration = (duration: string, units: string): string =>
secondsToDuration(parseFloat(duration) * UNIT_TO_SECOND_CONVERT[units]) ||
"0";

View File

@@ -13,6 +13,7 @@ import { formatNumber, isNumericState } from "../number/format_number";
import { LocalizeFunc } from "../translations/localize";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration";
export const computeStateDisplay = (
localize: LocalizeFunc,
@@ -28,6 +29,21 @@ export const computeStateDisplay = (
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (isNumericState(stateObj)) {
// state is duration
if (
stateObj.attributes.device_class === "duration" &&
stateObj.attributes.unit_of_measurement &&
UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement]
) {
try {
return formatDuration(
compareState,
stateObj.attributes.unit_of_measurement
);
} catch (_err) {
// fallback to default
}
}
if (stateObj.attributes.device_class === "monetary") {
try {
return formatNumber(compareState, locale, {

View File

@@ -1,244 +0,0 @@
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/
/**
* An inlined enum containing useful character codes (to be used with String.charCodeAt).
* Please leave the const keyword such that it gets inlined when compiled to JavaScript!
*/
export enum CharCode {
Null = 0,
/**
* The `\b` character.
*/
Backspace = 8,
/**
* The `\t` character.
*/
Tab = 9,
/**
* The `\n` character.
*/
LineFeed = 10,
/**
* The `\r` character.
*/
CarriageReturn = 13,
Space = 32,
/**
* The `!` character.
*/
ExclamationMark = 33,
/**
* The `"` character.
*/
DoubleQuote = 34,
/**
* The `#` character.
*/
Hash = 35,
/**
* The `$` character.
*/
DollarSign = 36,
/**
* The `%` character.
*/
PercentSign = 37,
/**
* The `&` character.
*/
Ampersand = 38,
/**
* The `'` character.
*/
SingleQuote = 39,
/**
* The `(` character.
*/
OpenParen = 40,
/**
* The `)` character.
*/
CloseParen = 41,
/**
* The `*` character.
*/
Asterisk = 42,
/**
* The `+` character.
*/
Plus = 43,
/**
* The `,` character.
*/
Comma = 44,
/**
* The `-` character.
*/
Dash = 45,
/**
* The `.` character.
*/
Period = 46,
/**
* The `/` character.
*/
Slash = 47,
Digit0 = 48,
Digit1 = 49,
Digit2 = 50,
Digit3 = 51,
Digit4 = 52,
Digit5 = 53,
Digit6 = 54,
Digit7 = 55,
Digit8 = 56,
Digit9 = 57,
/**
* The `:` character.
*/
Colon = 58,
/**
* The `;` character.
*/
Semicolon = 59,
/**
* The `<` character.
*/
LessThan = 60,
/**
* The `=` character.
*/
Equals = 61,
/**
* The `>` character.
*/
GreaterThan = 62,
/**
* The `?` character.
*/
QuestionMark = 63,
/**
* The `@` character.
*/
AtSign = 64,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
/**
* The `[` character.
*/
OpenSquareBracket = 91,
/**
* The `\` character.
*/
Backslash = 92,
/**
* The `]` character.
*/
CloseSquareBracket = 93,
/**
* The `^` character.
*/
Caret = 94,
/**
* The `_` character.
*/
Underline = 95,
/**
* The ``(`)`` character.
*/
BackTick = 96,
a = 97,
b = 98,
c = 99,
d = 100,
e = 101,
f = 102,
g = 103,
h = 104,
i = 105,
j = 106,
k = 107,
l = 108,
m = 109,
n = 110,
o = 111,
p = 112,
q = 113,
r = 114,
s = 115,
t = 116,
u = 117,
v = 118,
w = 119,
x = 120,
y = 121,
z = 122,
/**
* The `{` character.
*/
OpenCurlyBrace = 123,
/**
* The `|` character.
*/
Pipe = 124,
/**
* The `}` character.
*/
CloseCurlyBrace = 125,
/**
* The `~` character.
*/
Tilde = 126,
}

View File

@@ -1,551 +0,0 @@
/* eslint-disable no-console */
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { CharCode } from "./char-code";
const _debug = false;
export interface Match {
start: number;
end: number;
}
const _maxLen = 128;
function initTable() {
const table: number[][] = [];
const row: number[] = [];
for (let i = 0; i <= _maxLen; i++) {
row[i] = 0;
}
for (let i = 0; i <= _maxLen; i++) {
table.push(row.slice(0));
}
return table;
}
function isSeparatorAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.codePointAt(index);
switch (code) {
case CharCode.Underline:
case CharCode.Dash:
case CharCode.Period:
case CharCode.Space:
case CharCode.Slash:
case CharCode.Backslash:
case CharCode.SingleQuote:
case CharCode.DoubleQuote:
case CharCode.Colon:
case CharCode.DollarSign:
case CharCode.LessThan:
case CharCode.OpenParen:
case CharCode.OpenSquareBracket:
return true;
case undefined:
return false;
default:
if (isEmojiImprecise(code)) {
return true;
}
return false;
}
}
function isWhitespaceAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.charCodeAt(index);
switch (code) {
case CharCode.Space:
case CharCode.Tab:
return true;
default:
return false;
}
}
function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean {
return word[pos] !== wordLow[pos];
}
export function isPatternInWord(
patternLow: string,
patternPos: number,
patternLen: number,
wordLow: string,
wordPos: number,
wordLen: number,
fillMinWordPosArr = false
): boolean {
while (patternPos < patternLen && wordPos < wordLen) {
if (patternLow[patternPos] === wordLow[wordPos]) {
if (fillMinWordPosArr) {
// Remember the min word position for each pattern position
_minWordMatchPos[patternPos] = wordPos;
}
patternPos += 1;
}
wordPos += 1;
}
return patternPos === patternLen; // pattern must be exhausted
}
enum Arrow {
Diag = 1,
Left = 2,
LeftLeft = 3,
}
/**
* An array representing a fuzzy match.
*
* 0. the score
* 1. the offset at which matching started
* 2. `<match_pos_N>`
* 3. `<match_pos_1>`
* 4. `<match_pos_0>` etc
*/
// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number];
export type FuzzyScore = Array<number>;
export function fuzzyScore(
pattern: string,
patternLow: string,
patternStart: number,
word: string,
wordLow: string,
wordStart: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined {
const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length;
const wordLen = word.length > _maxLen ? _maxLen : word.length;
if (
patternStart >= patternLen ||
wordStart >= wordLen ||
patternLen - patternStart > wordLen - wordStart
) {
return undefined;
}
// Run a simple check if the characters of pattern occur
// (in order) at all in word. If that isn't the case we
// stop because no match will be possible
if (
!isPatternInWord(
patternLow,
patternStart,
patternLen,
wordLow,
wordStart,
wordLen,
true
)
) {
return undefined;
}
// Find the max matching word position for each pattern position
// NOTE: the min matching word position was filled in above, in the `isPatternInWord` call
_fillInMaxWordMatchPos(
patternLen,
wordLen,
patternStart,
wordStart,
patternLow,
wordLow
);
let row: number;
let column = 1;
let patternPos: number;
let wordPos: number;
const hasStrongFirstMatch = [false];
// There will be a match, fill in tables
for (
row = 1, patternPos = patternStart;
patternPos < patternLen;
row++, patternPos++
) {
// Reduce search space to possible matching word positions and to possible access from next row
const minWordMatchPos = _minWordMatchPos[patternPos];
const maxWordMatchPos = _maxWordMatchPos[patternPos];
const nextMaxWordMatchPos =
patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen;
for (
column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos;
wordPos < nextMaxWordMatchPos;
column++, wordPos++
) {
let score = Number.MIN_SAFE_INTEGER;
let canComeDiag = false;
if (wordPos <= maxWordMatchPos) {
score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos,
wordLen,
wordStart,
_diag[row - 1][column - 1] === 0,
hasStrongFirstMatch
);
}
let diagScore = 0;
if (score !== Number.MAX_SAFE_INTEGER) {
canComeDiag = true;
diagScore = score + _table[row - 1][column - 1];
}
const canComeLeft = wordPos > minWordMatchPos;
const leftScore = canComeLeft
? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0)
: 0; // penalty for a gap start
const canComeLeftLeft =
wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0;
const leftLeftScore = canComeLeftLeft
? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (
canComeLeftLeft &&
(!canComeLeft || leftLeftScore >= leftScore) &&
(!canComeDiag || leftLeftScore >= diagScore)
) {
// always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word
_table[row][column] = leftLeftScore;
_arrows[row][column] = Arrow.LeftLeft;
_diag[row][column] = 0;
} else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) {
// always prefer choosing left since that means a match is earlier in the word
_table[row][column] = leftScore;
_arrows[row][column] = Arrow.Left;
_diag[row][column] = 0;
} else if (canComeDiag) {
_table[row][column] = diagScore;
_arrows[row][column] = Arrow.Diag;
_diag[row][column] = _diag[row - 1][column - 1] + 1;
} else {
throw new Error(`not possible`);
}
}
}
if (_debug) {
printTables(pattern, patternStart, word, wordStart);
}
if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) {
return undefined;
}
row--;
column--;
const result: FuzzyScore = [_table[row][column], wordStart];
let backwardsDiagLength = 0;
let maxMatchColumn = 0;
while (row >= 1) {
// Find the column where we go diagonally up
let diagColumn = column;
do {
const arrow = _arrows[row][diagColumn];
if (arrow === Arrow.LeftLeft) {
diagColumn -= 2;
} else if (arrow === Arrow.Left) {
diagColumn -= 1;
} else {
// found the diagonal
break;
}
} while (diagColumn >= 1);
// Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match
if (
backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters
patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally
!isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase
backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match
) {
diagColumn = column;
}
if (diagColumn === column) {
// this is a contiguous match
backwardsDiagLength++;
} else {
backwardsDiagLength = 1;
}
if (!maxMatchColumn) {
// remember the last matched column
maxMatchColumn = diagColumn;
}
row--;
column = diagColumn - 1;
result.push(column);
}
if (wordLen === patternLen) {
// the word matches the pattern with all characters!
// giving the score a total match boost (to come up ahead other words)
result[0] += 2;
}
// Add 1 penalty for each skipped character in the word
const skippedCharsCount = maxMatchColumn - patternLen;
result[0] -= skippedCharsCount;
return result;
}
function _doScore(
pattern: string,
patternLow: string,
patternPos: number,
patternStart: number,
word: string,
wordLow: string,
wordPos: number,
wordLen: number,
wordStart: number,
newMatchStart: boolean,
outFirstMatchStrong: boolean[]
): number {
if (patternLow[patternPos] !== wordLow[wordPos]) {
return Number.MIN_SAFE_INTEGER;
}
let score = 1;
let isGapLocation = false;
if (wordPos === patternPos - patternStart) {
// common prefix: `foobar <-> foobaz`
// ^^^^^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
} else if (
isUpperCaseAtPos(wordPos, word, wordLow) &&
(wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow))
) {
// hitting upper-case: `foo <-> forOthers`
// ^^ ^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
isGapLocation = true;
} else if (
isSeparatorAtPos(wordLow, wordPos) &&
(wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1))
) {
// hitting a separator: `. <-> foo.bar`
// ^
score = 5;
} else if (
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1)
) {
// post separator: `foo <-> bar_foo`
// ^^^
score = 5;
isGapLocation = true;
}
if (score > 1 && patternPos === patternStart) {
outFirstMatchStrong[0] = true;
}
if (!isGapLocation) {
isGapLocation =
isUpperCaseAtPos(wordPos, word, wordLow) ||
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1);
}
//
if (patternPos === patternStart) {
// first character in pattern
if (wordPos > wordStart) {
// the first pattern character would match a word character that is not at the word start
// so introduce a penalty to account for the gap preceding this match
score -= isGapLocation ? 3 : 5;
}
} else if (newMatchStart) {
// this would be the beginning of a new match (i.e. there would be a gap before this location)
score += isGapLocation ? 2 : 0;
} else {
// this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location
score += isGapLocation ? 0 : 1;
}
if (wordPos + 1 === wordLen) {
// we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word
// so pretend there is a gap after the last character in the word to normalize things
score -= isGapLocation ? 3 : 5;
}
return score;
}
function printTable(
table: number[][],
pattern: string,
patternLen: number,
word: string,
wordLen: number
): string {
function pad(s: string, n: number, _pad = " ") {
while (s.length < n) {
s = _pad + s;
}
return s;
}
let ret = ` | |${word
.split("")
.map((c) => pad(c, 3))
.join("|")}\n`;
for (let i = 0; i <= patternLen; i++) {
if (i === 0) {
ret += " |";
} else {
ret += `${pattern[i - 1]}|`;
}
ret +=
table[i]
.slice(0, wordLen + 1)
.map((n) => pad(n.toString(), 3))
.join("|") + "\n";
}
return ret;
}
function printTables(
pattern: string,
patternStart: number,
word: string,
wordStart: number
): void {
pattern = pattern.substr(patternStart);
word = word.substr(wordStart);
console.log(printTable(_table, pattern, pattern.length, word, word.length));
console.log(printTable(_arrows, pattern, pattern.length, word, word.length));
console.log(printTable(_diag, pattern, pattern.length, word, word.length));
}
const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position
const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position
const _diag = initTable(); // the length of a contiguous diagonal match
const _table = initTable();
const _arrows = <Arrow[][]>initTable();
function initArr(maxLen: number) {
const row: number[] = [];
for (let i = 0; i <= maxLen; i++) {
row[i] = 0;
}
return row;
}
function _fillInMaxWordMatchPos(
patternLen: number,
wordLen: number,
patternStart: number,
wordStart: number,
patternLow: string,
wordLow: string
) {
let patternPos = patternLen - 1;
let wordPos = wordLen - 1;
while (patternPos >= patternStart && wordPos >= wordStart) {
if (patternLow[patternPos] === wordLow[wordPos]) {
_maxWordMatchPos[patternPos] = wordPos;
patternPos--;
}
wordPos--;
}
}
export interface FuzzyScorer {
(
pattern: string,
lowPattern: string,
patternPos: number,
word: string,
lowWord: string,
wordPos: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined;
}
export function createMatches(score: undefined | FuzzyScore): Match[] {
if (typeof score === "undefined") {
return [];
}
const res: Match[] = [];
const wordPos = score[1];
for (let i = score.length - 1; i > 1; i--) {
const pos = score[i] + wordPos;
const last = res[res.length - 1];
if (last && last.end === pos) {
last.end = pos + 1;
} else {
res.push({ start: pos, end: pos + 1 });
}
}
return res;
}
/**
* A fast function (therefore imprecise) to check if code points are emojis.
* Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js
*/
export function isEmojiImprecise(x: number): boolean {
return (
(x >= 0x1f1e6 && x <= 0x1f1ff) ||
x === 8986 ||
x === 8987 ||
x === 9200 ||
x === 9203 ||
(x >= 9728 && x <= 10175) ||
x === 11088 ||
x === 11093 ||
(x >= 127744 && x <= 128591) ||
(x >= 128640 && x <= 128764) ||
(x >= 128992 && x <= 129003) ||
(x >= 129280 && x <= 129535) ||
(x >= 129648 && x <= 129750)
);
}

View File

@@ -1,52 +1,4 @@
import { fuzzyScore } from "./filter";
/**
* Determine whether a sequence of letters exists in another string,
* in that order, allowing for skipping. Ex: "chdr" exists in "chandelier")
*
* @param {string} filter - Sequence of letters to check for
* @param {ScorableTextItem} item - Item against whose strings will be checked
*
* @return {number} Score representing how well the word matches the filter. Return of 0 means no match.
*/
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
0,
word,
word.toLowerCase(),
0,
true
);
if (!scores) {
continue;
}
// The VS Code implementation of filter returns a 0 for a weak match.
// But if .filter() sees a "0", it considers that a failed match and will remove it.
// So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) {
topScore = score;
}
}
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return topScore;
};
import fuzzysort from "fuzzysort";
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
@@ -66,18 +18,48 @@ export interface ScorableTextItem {
strings: string[];
}
type FuzzyFilterSort = <T extends ScorableTextItem>(
export type FuzzyFilterSort = <T extends ScorableTextItem>(
filter: string,
items: T[]
) => T[];
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) =>
items
export function fuzzyMatcher(search: string | null): (string) => boolean {
const scorer = fuzzyScorer(search);
return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY;
}
export function fuzzyScorer(
search: string | null
): (values: string[]) => number {
const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g);
if (!searchTerms) {
return () => 0;
}
return (values) =>
searchTerms
.map((term) => {
const resultsForTerm = fuzzysort.go(term, values, {
allowTypo: true,
});
if (resultsForTerm.length > 0) {
return Math.max(...resultsForTerm.map((result) => result.score));
}
return Number.NEGATIVE_INFINITY;
})
.reduce((partial, current) => partial + current, 0);
}
export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => {
const scorer = fuzzyScorer(filter);
return items
.map((item) => {
item.score = fuzzySequentialMatch(filter, item);
item.score = scorer(item.strings);
return item;
})
.filter((item) => item.score !== undefined)
.filter((item) => item.score !== undefined && item.score > -100000)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
);
};
export const defaultFuzzyFilterSort = fuzzySortFilterSort;

View File

@@ -7,25 +7,26 @@ import type {
SortableColumnContainer,
SortingDirection,
} from "./ha-data-table";
import { fuzzyMatcher } from "../../common/string/filter/sequence-matching";
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = filter.toUpperCase();
const matcher = fuzzyMatcher(filter);
return data.filter((row) =>
Object.entries(columns).some((columnEntry) => {
const [key, column] = columnEntry;
if (column.filterable) {
if (
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
matcher(
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
)
.toUpperCase()
.includes(filter)
) {
return true;
}

View File

@@ -198,9 +198,10 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
this.hass,
deviceEntityLookup[device.id]
),
area: device.area_id
? areaLookup[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
area:
device.area_id && areaLookup[device.area_id]
? areaLookup[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
}));
if (!outputDevices.length) {
return [

View File

@@ -15,6 +15,7 @@ import type { HaComboBox } from "../ha-combo-box";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching";
interface HassEntityWithCachedName extends HassEntity {
friendly_name: string;
@@ -336,11 +337,18 @@ export class HaEntityPicker extends LitElement {
}
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value.toLowerCase();
(this.comboBox as any).filteredItems = this._states.filter(
(entityState) =>
entityState.entity_id.toLowerCase().includes(filterString) ||
computeStateName(entityState).toLowerCase().includes(filterString)
const filterString = ev.detail.value;
const sortableEntityStates = this._states.map((entityState) => ({
strings: [entityState.entity_id, computeStateName(entityState)],
entityState: entityState,
}));
const sortedEntityStates = defaultFuzzyFilterSort(
filterString,
sortableEntityStates
);
(this.comboBox as any).filteredItems = sortedEntityStates.map(
(sortableItem) => sortableItem.entityState
);
}

View File

@@ -10,8 +10,8 @@ interface State {
backgroundColor: null | string;
}
@customElement("hassio-ansi-to-html")
class HassioAnsiToHtml extends LitElement {
@customElement("ha-ansi-to-html")
class HaAnsiToHtml extends LitElement {
@property() public content!: string;
protected render(): TemplateResult | void {
@@ -241,6 +241,6 @@ class HassioAnsiToHtml extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"hassio-ansi-to-html": HassioAnsiToHtml;
"ha-ansi-to-html": HaAnsiToHtml;
}
}

View File

@@ -409,7 +409,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
name,
});
this._areas = [...this._areas!, area];
(this.comboBox as any).items = this._getAreas(
(this.comboBox as any).filteredItems = this._getAreas(
this._areas!,
this._devices!,
this._entities!,

View File

@@ -56,6 +56,9 @@ class HaNavigationList extends LitElement {
}
static styles: CSSResultGroup = css`
:host {
--mdc-list-vertical-padding: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
@@ -68,6 +71,7 @@ class HaNavigationList extends LitElement {
color: var(--secondary-text-color);
height: 24px;
width: 24px;
display: block;
}
ha-svg-icon {
padding: 8px;

View File

@@ -163,6 +163,9 @@ export class HaNetwork extends LitElement {
ha-settings-row {
padding: 0;
--paper-time-input-justify-content: flex-end;
--settings-row-content-display: contents;
--settings-row-prefix-display: contents;
}
span[slot="heading"],

View File

@@ -472,6 +472,7 @@ export class HaServiceControl extends LitElement {
ha-settings-row {
--paper-time-input-justify-content: flex-end;
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)

View File

@@ -47,7 +47,7 @@ export class HaSettingsRow extends LitElement {
display: contents;
}
:host(:not([narrow])) .content {
display: flex;
display: var(--settings-row-content-display, flex);
justify-content: flex-end;
flex: 1;
padding: 16px 0;
@@ -68,7 +68,7 @@ export class HaSettingsRow extends LitElement {
white-space: normal;
}
.prefix-wrap {
display: contents;
display: var(--settings-row-prefix-display);
}
:host([narrow]) .prefix-wrap {
display: flex;

View File

@@ -1051,9 +1051,6 @@ class HaSidebar extends LitElement {
padding: 0px 6px;
color: var(--text-accent-color, var(--text-primary-color));
}
.configuration-badge {
background-color: var(--primary-color);
}
ha-svg-icon + .notification-badge,
ha-svg-icon + .configuration-badge {
position: absolute;

View File

@@ -1,10 +1,15 @@
import type {
HassEntities,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { BINARY_STATE_ON } from "../common/const";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
export const UPDATE_SUPPORT_INSTALL = 1;
export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2;
@@ -47,3 +52,75 @@ export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
type: "update/release_notes",
entity_id: entityId,
});
export const filterUpdateEntities = (entities: HassEntities) =>
(
Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || ""
);
});
export const filterUpdateEntitiesWithInstall = (
entities: HassEntities,
showSkipped = false
) =>
filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity, showSkipped)
);
export const checkForEntityUpdates = async (
element: HTMLElement,
hass: HomeAssistant
) => {
const entities = filterUpdateEntities(hass.states).map(
(entity) => entity.entity_id
);
if (!entities.length) {
showAlertDialog(element, {
title: hass.localize("ui.panel.config.updates.no_update_entities.title"),
text: hass.localize(
"ui.panel.config.updates.no_update_entities.description"
),
warning: true,
});
return;
}
await hass.callService("homeassistant", "update_entity", {
entity_id: entities,
});
if (filterUpdateEntitiesWithInstall(hass.states).length) {
showToast(element, {
message: hass.localize("ui.panel.config.updates.updates_refreshed"),
});
} else {
showToast(element, {
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
};

View File

@@ -24,7 +24,7 @@ import { domainIcon } from "../../common/entity/domain_icon";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
fuzzyFilterSort,
defaultFuzzyFilterSort,
ScorableTextItem,
} from "../../common/string/filter/sequence-matching";
import { debounce } from "../../common/util/debounce";
@@ -694,7 +694,7 @@ export class QuickBar extends LitElement {
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
defaultFuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
static get styles() {

View File

@@ -99,6 +99,7 @@ class HassSubpage extends LitElement {
ha-icon-button-arrow-prev,
::slotted([slot="toolbar-icon"]) {
pointer-events: auto;
color: var(--sidebar-icon-color);
}
.main-title {

View File

@@ -283,6 +283,9 @@ export class HaTabsSubpageDataTable extends LitElement {
height: calc(100vh - 1px - var(--header-height));
display: block;
}
:host([narrow]) hass-tabs-subpage {
--main-title-margin: 0;
}
.table-header {
display: flex;
align-items: center;

View File

@@ -82,6 +82,16 @@ class HassTabsSubpage extends LitElement {
(!page.advancedOnly || showAdvanced)
);
if (shownTabs.length < 2) {
if (shownTabs.length === 1) {
const page = shownTabs[0];
return [
page.translationKey ? localizeFunc(page.translationKey) : page.name,
];
}
return [""];
}
return shownTabs.map(
(page) =>
html`
@@ -134,7 +144,7 @@ class HassTabsSubpage extends LitElement {
this.narrow,
this.localizeFunc || this.hass.localize
);
const showTabs = tabs.length > 1 || !this.narrow;
const showTabs = tabs.length > 1;
return html`
<div class="toolbar">
${this.mainPage || (!this.backPath && history.state?.root)
@@ -159,8 +169,10 @@ class HassTabsSubpage extends LitElement {
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow
? html`<div class="main-title"><slot name="header"></slot></div>`
${this.narrow || !showTabs
? html`<div class="main-title">
<slot name="header">${!showTabs ? tabs[0] : ""}</slot>
</div>`
: ""}
${showTabs
? html`
@@ -283,6 +295,7 @@ class HassTabsSubpage extends LitElement {
max-height: var(--header-height);
line-height: 20px;
color: var(--sidebar-text-color);
margin: var(--main-title-margin, 0 0 0 24px);
}
.content {

View File

@@ -1,11 +1,11 @@
import "@material/mwc-button/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import "../../hassio/src/components/hassio-ansi-to-html";
import { showBackupUploadDialog } from "../../hassio/src/dialogs/backup/show-dialog-backup-upload";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import "../components/ha-ansi-to-html";
import { fetchInstallationType } from "../data/onboarding";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { ProvideHassLitMixin } from "../mixins/provide-hass-lit-mixin";
@@ -86,7 +86,7 @@ class OnboardingRestoreBackup extends ProvideHassLitMixin(LitElement) {
padding: 4px;
margin-top: 8px;
}
hassio-ansi-to-html {
ha-ansi-to-html {
display: block;
line-height: 22px;
padding: 0 8px;

View File

@@ -332,6 +332,7 @@ export class HaBlueprintAutomationEditor extends LitElement {
ha-settings-row {
--paper-time-input-justify-content: flex-end;
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: 1px solid var(--divider-color);
}
`,

View File

@@ -80,6 +80,7 @@ class HaConfigBackup extends LitElement {
actions: {
title: "",
width: "15%",
type: "overflow-menu",
template: (_: string, backup: BackupContent) =>
html`<ha-icon-overflow-menu
.hass=${this.hass}
@@ -126,17 +127,23 @@ class HaConfigBackup extends LitElement {
return html`
<hass-tabs-subpage-data-table
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_bakcups")}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<span slot="header"
>${this.hass.localize("ui.panel.config.backup.caption")}</span
>
<ha-fab
slot="fab"
?disabled=${this._backupData.backing_up}

View File

@@ -58,9 +58,9 @@ class ConfigAnalytics extends LitElement {
"ui.panel.config.core.section.core.core_config.save_button"
)}
</mwc-button>
${analyticsLearnMore(this.hass)}
</div>
</ha-card>
<div class="footer">${analyticsLearnMore(this.hass)}</div>
`;
}
@@ -117,6 +117,10 @@ class ConfigAnalytics extends LitElement {
justify-content: space-between;
align-items: center;
}
.footer {
padding: 32px 0 16px;
text-align: center;
}
`, // row-reverse so we tab first to "save"
];
}

View File

@@ -0,0 +1,361 @@
import "@material/mwc-list/mwc-list-item";
import timezones from "google-timezones-json";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate";
import "../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import { currencies } from "../../../components/currency-datalist";
import "../../../components/ha-card";
import "../../../components/ha-formfield";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-select";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { SYMBOL_TO_ISO } from "../../../data/currency";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-section-general")
class HaConfigSectionGeneral extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
@state() private _submitting = false;
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _currency?: string;
@state() private _name?: string;
@state() private _elevation?: number;
@state() private _timeZone?: string;
@state() private _location?: [number, number];
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
this.hass.config.config_source
);
const disabled = this._submitting || !canEdit;
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.core.caption")}
>
<div class="content">
<ha-card outlined>
<div class="card-content">
${!canEdit
? html`
<p>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_requires_storage"
)}
</p>
`
: ""}
<ha-textfield
name="name"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.location_name"
)}
.disabled=${disabled}
.value=${this._name}
@change=${this._handleChange}
></ha-textfield>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone"
)}
name="timeZone"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._timeZone}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${Object.keys(timezones).map(
(tz) =>
html`<mwc-list-item value=${tz}
>${timezones[tz]}</mwc-list-item
>`
)}
</ha-select>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation"
)}
name="elevation"
type="number"
.disabled=${disabled}
.value=${this._elevation}
@change=${this._handleChange}
>
<span slot="suffix">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
</span>
</ha-textfield>
<div>
<div>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system"
)}
</div>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
)}
</div>
`}
>
<ha-radio
name="unit_system"
value="metric"
.checked=${this._unitSystem === "metric"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.imperial_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_imperial"
)}
</div>
`}
>
<ha-radio
name="unit_system"
value="imperial"
.checked=${this._unitSystem === "imperial"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
</div>
<div>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}
name="currency"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._currency}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${currencies.map(
(currency) =>
html`<mwc-list-item .value=${currency}
>${currency}</mwc-list-item
>`
)}</ha-select
>
<a
href="https://en.wikipedia.org/wiki/ISO_4217#Active_codes"
target="_blank"
rel="noopener noreferrer"
>${this.hass.localize(
"ui.panel.config.core.section.core.core_config.find_currency_value"
)}</a
>
</div>
</div>
${this.narrow
? html`
<ha-locations-editor
.hass=${this.hass}
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude,
this._location
)}
@location-updated=${this._locationChanged}
></ha-locations-editor>
`
: html`
<ha-settings-row>
<div slot="heading">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location"
)}
</div>
<div slot="description" class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location_description"
)}
</div>
<mwc-button @click=${this._editLocation}
>${this.hass.localize("ui.common.edit")}</mwc-button
>
</ha-settings-row>
`}
<div class="card-actions">
<ha-progress-button @click=${this._updateEntry}>
${this.hass!.localize("ui.panel.config.zone.detail.update")}
</ha-progress-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
protected firstUpdated(): void {
this._unitSystem =
this.hass.config.unit_system.temperature === UNIT_C
? "metric"
: "imperial";
this._currency = this.hass.config.currency;
this._elevation = this.hass.config.elevation;
this._timeZone = this.hass.config.time_zone;
this._name = this.hass.config.location_name;
}
private _handleChange(ev) {
const target = ev.currentTarget;
let value = target.value;
if (target.name === "currency" && value) {
if (value in SYMBOL_TO_ISO) {
value = SYMBOL_TO_ISO[value];
}
}
this[`_${target.name}`] = value;
}
private _unitSystemChanged(ev: CustomEvent) {
this._unitSystem = (ev.target as HaRadio).value as "metric" | "imperial";
}
private _locationChanged(ev: CustomEvent) {
this._location = ev.detail.location;
}
private async _updateEntry(ev: CustomEvent) {
const button = ev.target as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
try {
await saveCoreConfig(this.hass, {
currency: this._currency,
elevation: Number(this._elevation),
unit_system: this._unitSystem,
time_zone: this._timeZone,
location_name: this._name,
});
button.actionSuccess();
} catch (err: any) {
button.actionError();
alert(`Error saving config: ${err.message}`);
} finally {
button.progress = false;
}
}
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private _editLocation() {
navigate("/config/zone");
}
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px 16px 0 16px;
}
.card-actions {
text-align: right;
height: 48px;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
}
.card-content > * {
display: block;
margin-top: 16px;
}
ha-select {
display: block;
}
ha-locations-editor {
display: block;
height: 400px;
padding: 16px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-general": HaConfigSectionGeneral;
}
}

View File

@@ -1,22 +1,35 @@
import type { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import { HassEntities } from "home-assistant-js-websocket";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-metric";
import { updateCanInstall, UpdateEntity } from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioSupervisorInfo,
HassioSupervisorInfo,
reloadSupervisor,
setSupervisorOption,
SupervisorOptions,
} from "../../../data/hassio/supervisor";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
} from "../../../data/update";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
import "./ha-config-analytics";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -26,7 +39,17 @@ class HaConfigSectionUpdates extends LitElement {
@state() private _showSkipped = false;
private _notifyUpdates = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) {
fetchHassioSupervisorInfo(this.hass).then((data) => {
this._supervisorInfo = data;
});
}
}
protected render(): TemplateResult {
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
@@ -41,22 +64,38 @@ class HaConfigSectionUpdates extends LitElement {
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
>
<ha-button-menu
corner="BOTTOM_START"
slot="toolbar-icon"
@action=${this._toggleSkipped}
>
<div slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.panel.config.info.copy_menu")}
.path=${mdiDotsVertical}
.label=${this.hass.localize(
"ui.panel.config.updates.check_updates"
)}
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<mwc-list-item>
${this._showSkipped
? this.hass.localize("ui.panel.config.updates.hide_skipped")
: this.hass.localize("ui.panel.config.updates.show_skipped")}
</mwc-list-item>
</ha-button-menu>
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item id="skipped">
${this._showSkipped
? this.hass.localize("ui.panel.config.updates.hide_skipped")
: this.hass.localize("ui.panel.config.updates.show_skipped")}
</mwc-list-item>
${this._supervisorInfo?.channel !== "dev"
? html`
<mwc-list-item id="beta">
${this._supervisorInfo?.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</mwc-list-item>
`
: ""}
</ha-button-menu>
</div>
<div class="content">
<ha-card outlined>
<div class="card-content">
@@ -73,107 +112,68 @@ class HaConfigSectionUpdates extends LitElement {
${this.hass.localize("ui.panel.config.updates.no_updates")}
`}
</div>
<div class="card-actions">
<mwc-button @click=${this._checkUpdates}>
${this.hass.localize("ui.panel.config.updates.check_updates")}
</mwc-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
protected override updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (
!changedProps.has("hass") ||
!this._notifyUpdates ||
!changedProps.has("_showSkipped")
) {
return;
}
this._notifyUpdates = false;
if (
this._filterUpdateEntitiesWithInstall(this.hass.states, this._showSkipped)
.length
) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.updates.updates_refreshed"
),
});
} else {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.no_new_updates"),
});
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._showSkipped = !this._showSkipped;
break;
case 1:
this._toggleBeta();
break;
}
}
private _toggleSkipped(): void {
this._showSkipped = !this._showSkipped;
private async _toggleBeta(): Promise<void> {
if (this._supervisorInfo!.channel === "stable") {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.dialogs.join_beta_channel.title"),
text: html`${this.hass.localize("ui.dialogs.join_beta_channel.warning")}
<br />
<b> ${this.hass.localize("ui.dialogs.join_beta_channel.backup")} </b>
<br /><br />
${this.hass.localize("ui.dialogs.join_beta_channel.release_items")}
<ul>
<li>Home Assistant Core</li>
<li>Home Assistant Supervisor</li>
<li>Home Assistant Operating System</li>
</ul>
<br />
${this.hass.localize("ui.dialogs.join_beta_channel.confirm")}`,
confirmText: this.hass.localize("ui.panel.config.updates.join_beta"),
dismissText: this.hass.localize("ui.common.cancel"),
});
if (!confirmed) {
return;
}
}
try {
const data: Partial<SupervisorOptions> = {
channel: this._supervisorInfo!.channel === "stable" ? "beta" : "stable",
};
await setSupervisorOption(this.hass, data);
await reloadSupervisor(this.hass);
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private async _checkUpdates(): Promise<void> {
const _entities = this._filterUpdateEntities(this.hass.states).map(
(entity) => entity.entity_id
);
if (_entities.length) {
this._notifyUpdates = true;
await this.hass.callService("homeassistant", "update_entity", {
entity_id: _entities,
});
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.updates.no_update_entities.title"
),
text: this.hass.localize(
"ui.panel.config.updates.no_update_entities.description"
),
warning: true,
});
checkForEntityUpdates(this, this.hass);
}
private _filterUpdateEntities = memoizeOne((entities: HassEntities) =>
(
Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || ""
);
})
);
private _filterUpdateEntitiesWithInstall = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
this._filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity, showSkipped)
)
filterUpdateEntitiesWithInstall(entities, showSkipped)
);
static styles = css`
@@ -183,7 +183,7 @@ class HaConfigSectionUpdates extends LitElement {
margin: 0 auto;
}
ha-card {
max-width: 500px;
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
@@ -191,19 +191,12 @@ class HaConfigSectionUpdates extends LitElement {
display: flex;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
.card-actions {
height: 48px;
border-top: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px 16px 0 16px;
padding: 16px;
}
`;
}

View File

@@ -1,9 +1,12 @@
import { ActionDetail } from "@material/mwc-list";
import { mdiDotsVertical } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { canShowPage } from "../../../common/config/can_show_page";
import "../../../components/ha-card";
import "../../../components/ha-navigation-list";
import { CloudStatus } from "../../../data/cloud";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -38,17 +41,28 @@ class HaConfigSystemNavigation extends LitElement {
back-path="/config"
.header=${this.hass.localize("ui.panel.config.dashboard.system.main")}
>
<ha-button-menu
corner="BOTTOM_START"
@action=${this._handleAction}
slot="toolbar-icon"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.system_dashboard.restart_homeassistant"
)}
</mwc-list-item>
</ha-button-menu>
<ha-config-section
.narrow=${this.narrow}
.isWide=${this.isWide}
full-width
>
<ha-card outlined>
${this.narrow
? html`<div class="title">
${this.hass.localize("ui.panel.config.dashboard.system.main")}
</div>`
: ""}
<ha-navigation-list
.hass=${this.hass}
.narrow=${this.narrow}
@@ -60,13 +74,25 @@ class HaConfigSystemNavigation extends LitElement {
`;
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.system_dashboard.confirm_restart"
),
confirm: () => {
this.hass.callService("homeassistant", "restart");
},
});
break;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-card {
margin-bottom: env(safe-area-inset-bottom);
}
:host(:not([narrow])) ha-card {
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
@@ -79,6 +105,8 @@ class HaConfigSystemNavigation extends LitElement {
ha-card {
overflow: hidden;
margin-bottom: 24px;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
ha-card a {

View File

@@ -1,9 +1,9 @@
import type { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiCloudLock, mdiDotsVertical, mdiMagnify, mdiNewBox } from "@mdi/js";
import { mdiCloudLock, mdiDotsVertical, mdiMagnify } from "@mdi/js";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import type { HassEntities } from "home-assistant-js-websocket";
import { HassEntities } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
@@ -15,8 +15,6 @@ import {
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
@@ -25,15 +23,17 @@ import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tip";
import { CloudStatus } from "../../../data/cloud";
import { updateCanInstall, UpdateEntity } from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
UpdateEntity,
} from "../../../data/update";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import "../../../layouts/ha-app-layout";
import { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import "./ha-config-navigation";
@@ -81,12 +81,12 @@ const randomTip = (hass: HomeAssistant) => {
rel="noreferrer"
>Newsletter</a
>
<ha-svg-icon class="new" .path=${mdiNewBox}></ha-svg-icon
></span>`
</span>`
),
weight: 2,
},
{ content: hass.localize("ui.tips.key_c_hint"), weight: 1 },
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1 },
];
tips.forEach((tip) => {
@@ -113,8 +113,6 @@ class HaConfigDashboard extends LitElement {
@state() private _tip?: string;
private _notifyUpdates = false;
private _pages = memoizeOne((clouStatus, isLoaded) => {
const pages: PageNavigation[] = [];
if (clouStatus && isLoaded) {
@@ -219,60 +217,12 @@ class HaConfigDashboard extends LitElement {
if (!this._tip && changedProps.has("hass")) {
this._tip = randomTip(this.hass);
}
if (!changedProps.has("hass") || !this._notifyUpdates) {
return;
}
this._notifyUpdates = false;
if (this._filterUpdateEntitiesWithInstall(this.hass.states).length) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.updates.updates_refreshed"
),
});
} else {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
}
private _filterUpdateEntities = memoizeOne((entities: HassEntities) =>
(
Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || ""
);
})
);
private _filterUpdateEntitiesWithInstall = memoizeOne(
(entities: HassEntities): [UpdateEntity[], number] => {
const updates = this._filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity)
);
const updates = filterUpdateEntitiesWithInstall(entities);
return [
updates.slice(0, updates.length === 3 ? updates.length : 2),
updates.length,
@@ -288,27 +238,9 @@ class HaConfigDashboard extends LitElement {
}
private async _handleMenuAction(ev: CustomEvent<ActionDetail>) {
const _entities = this._filterUpdateEntities(this.hass.states).map(
(entity) => entity.entity_id
);
switch (ev.detail.index) {
case 0:
if (_entities.length) {
this._notifyUpdates = true;
await this.hass.callService("homeassistant", "update_entity", {
entity_id: _entities,
});
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.updates.no_update_entities.title"
),
text: this.hass.localize(
"ui.panel.config.updates.no_update_entities.description"
),
warning: true,
});
checkForEntityUpdates(this, this.hass);
break;
}
}

View File

@@ -1,6 +1,6 @@
import "@material/mwc-button/mwc-button";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -8,7 +8,7 @@ import "../../../components/entity/state-badge";
import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import type { UpdateEntity } from "../../../data/update";
import { HomeAssistant } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-updates")
class HaConfigUpdates extends LitElement {
@@ -35,39 +35,44 @@ class HaConfigUpdates extends LitElement {
count: this.total || this.updateEntities.length,
})}
</div>
${updates.map(
(entity) => html`
<paper-icon-item
@click=${this._openMoreInfo}
.entity_id=${entity.entity_id}
class=${entity.attributes.skipped_version ? "skipped" : ""}
>
<span slot="item-icon" class="icon">
<mwc-list>
${updates.map(
(entity) => html`
<mwc-list-item
twoline
graphic="avatar"
class=${entity.attributes.skipped_version ? "skipped" : ""}
.entity_id=${entity.entity_id}
.hasMeta=${!this.narrow}
@click=${this._openMoreInfo}
>
<state-badge
slot="graphic"
.title=${entity.attributes.title ||
entity.attributes.friendly_name}
.stateObj=${entity}
slot="item-icon"
></state-badge>
</span>
<paper-item-body two-line>
${entity.attributes.title || entity.attributes.friendly_name}
<div secondary>
<span
>${entity.attributes.title ||
entity.attributes.friendly_name}</span
>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.updates.version_available",
{
version_available: entity.attributes.latest_version,
}
)}
${entity.attributes.skipped_version
)}${entity.attributes.skipped_version
? `(${this.hass.localize("ui.panel.config.updates.skipped")})`
: ""}
</div>
</paper-item-body>
${!this.narrow ? html`<ha-icon-next></ha-icon-next>` : ""}
</paper-icon-item>
`
)}
</span>
${!this.narrow
? html`<ha-icon-next slot="meta"></ha-icon-next>`
: ""}
</mwc-list-item>
`
)}
</mwc-list>
`;
}
@@ -80,6 +85,9 @@ class HaConfigUpdates extends LitElement {
static get styles(): CSSResultGroup[] {
return [
css`
:host {
--mdc-list-vertical-padding: 0;
}
.title {
font-size: 16px;
padding: 16px;
@@ -88,11 +96,6 @@ class HaConfigUpdates extends LitElement {
.skipped {
background: var(--secondary-background-color);
}
.icon {
display: inline-flex;
height: 100%;
align-items: center;
}
ha-icon-next {
color: var(--secondary-text-color);
height: 24px;
@@ -114,8 +117,9 @@ class HaConfigUpdates extends LitElement {
outline: none;
text-decoration: underline;
}
paper-icon-item {
mwc-list-item {
cursor: pointer;
font-size: 16px;
}
`,
];

View File

@@ -197,7 +197,10 @@ export class HaConfigDeviceDashboard extends LitElement {
),
model: device.model || "<unknown>",
manufacturer: device.manufacturer || "<unknown>",
area: device.area_id ? areaLookup[device.area_id].name : "—",
area:
device.area_id && areaLookup[device.area_id]
? areaLookup[device.area_id].name
: "—",
integration: device.config_entries.length
? device.config_entries
.filter((entId) => entId in entryLookup)

View File

@@ -356,6 +356,25 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
)}
</ha-select>`
: ""}
${this._helperConfigEntry
? html`
<div class="row">
<mwc-button
@click=${this._showOptionsFlow}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state",
"integration",
domainToName(
this.hass.localize,
this._helperConfigEntry.domain
)
)}
</mwc-button>
</div>
`
: ""}
<ha-textfield
error-message="Domain needs to stay the same"
.value=${this._entityId}
@@ -373,20 +392,6 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@value-changed=${this._areaPicked}
></ha-area-picker>`
: ""}
${this._helperConfigEntry
? html`
<div class="row">
<mwc-button
@click=${this._showOptionsFlow}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state"
)}
</mwc-button>
</div>
`
: ""}
<ha-expansion-panel
.header=${this.hass.localize(

View File

@@ -20,7 +20,6 @@ import {
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiServer,
mdiShape,
mdiSofa,
mdiTools,
@@ -255,14 +254,6 @@ export const configSections: { [name: string]: PageNavigation[] } = {
},
],
general: [
{
component: "server_control",
path: "/config/server_control",
translationKey: "ui.panel.config.server_control.caption",
iconPath: mdiServer,
iconColor: "#4A5963",
core: true,
},
{
path: "/config/updates",
translationKey: "ui.panel.config.updates.caption",
@@ -324,6 +315,13 @@ export const configSections: { [name: string]: PageNavigation[] } = {
iconColor: "#507FfE",
components: ["system_health", "hassio"],
},
{
path: "/config/general",
translationKey: "ui.panel.config.core.caption",
iconPath: mdiCog,
iconColor: "#653249",
core: true,
},
],
about: [
{
@@ -347,6 +345,8 @@ class HaPanelConfig extends HassRouterPage {
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
beforeRender: (page) =>
page === "server_control" ? "../developer-tools/yaml" : undefined,
routes: {
analytics: {
tag: "ha-config-section-analytics",
@@ -438,10 +438,6 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-helpers",
load: () => import("./helpers/ha-config-helpers"),
},
server_control: {
tag: "ha-config-server-control",
load: () => import("./server_control/ha-config-server-control"),
},
storage: {
tag: "ha-config-section-storage",
load: () => import("./storage/ha-config-section-storage"),
@@ -462,6 +458,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-zone",
load: () => import("./zone/ha-config-zone"),
},
general: {
tag: "ha-config-section-general",
load: () => import("./core/ha-config-section-general"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -540,6 +540,10 @@ class HaPanelConfig extends HassRouterPage {
"--app-header-border-bottom",
"1px solid var(--divider-color)"
);
this.style.setProperty(
"--ha-card-border-radius",
"var(--ha-config-card-border-radius, 8px)"
);
}
protected updatePageEl(el) {

View File

@@ -218,7 +218,7 @@ class HaConfigHardware extends LitElement {
margin: 0 auto;
}
ha-card {
max-width: 500px;
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
@@ -237,7 +237,6 @@ class HaConfigHardware extends LitElement {
}
.card-actions {
height: 48px;
border-top: none;
display: flex;
justify-content: space-between;
align-items: center;

View File

@@ -79,9 +79,11 @@ class HaInputSelectForm extends LitElement {
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-picker>
${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.options"
)}:
<div class="header">
${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.options"
)}:
</div>
${this._options.length
? this._options.map(
(option, index) => html`
@@ -206,6 +208,10 @@ class HaInputSelectForm extends LitElement {
#option_input {
margin-top: 8px;
}
.header {
margin-top: 8px;
margin-bottom: 8px;
}
`,
];
}

View File

@@ -67,7 +67,7 @@ class HaConfigInfo extends LitElement {
Home Assistant Supervisor ${this._hassioInfo.supervisor}
</h2>`
: ""}
${this._osInfo
${this._osInfo?.version
? html`<h2>Home Assistant OS ${this._osInfo.version}</h2>`
: ""}
${this._hostInfo

View File

@@ -111,7 +111,7 @@ const groupByIntegration = (
class HaConfigIntegrations extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean;
@@ -709,6 +709,9 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
return [
haStyle,
css`
:host([narrow]) hass-tabs-subpage {
--main-title-margin: 0;
}
ha-button-menu {
margin-left: 8px;
}

View File

@@ -12,55 +12,32 @@ import {
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-alert";
import "../../../components/ha-ansi-to-html";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-select";
import { fetchErrorLog, LogProvider } from "../../../data/error_log";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
import { fetchErrorLog } from "../../../data/error_log";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import { fetchHassioLogs } from "../../../data/hassio/supervisor";
import { HomeAssistant } from "../../../types";
const logProviders: LogProvider[] = [
{
key: "supervisor",
name: "Supervisor",
},
{
key: "core",
name: "Home Assistant Core",
},
{
key: "host",
name: "Host",
},
{
key: "dns",
name: "DNS",
},
{
key: "audio",
name: "Audio",
},
{
key: "multicast",
name: "Multicast",
},
];
@customElement("error-log-card")
class ErrorLogCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter = "";
@property() public provider!: string;
@property({ type: Boolean, attribute: true }) public show = false;
@state() private _isLogLoaded = false;
@state() private _logHTML!: TemplateResult[] | string;
@state() private _logHTML?: TemplateResult[] | TemplateResult | string;
@state() private _error?: string;
@state() private _selectedLogProvider?: string;
protected render(): TemplateResult {
return html`
<div class="error-log-intro">
@@ -71,26 +48,9 @@ class ErrorLogCard extends LitElement {
? html`
<ha-card outlined>
<div class="header">
${this.hass.userData?.showAdvanced &&
isComponentLoaded(this.hass, "hassio")
? html`
<ha-select
.label=${this.hass.localize(
"ui.panel.config.logs.log_provider"
)}
@selected=${this._setLogProvider}
.value=${this._selectedLogProvider}
>
${logProviders.map(
(provider) => html`
<mwc-list-item .value=${provider.key}>
${provider.name}
</mwc-list-item>
`
)}
</ha-select>
`
: ""}
<h2>
${this.hass.localize("ui.panel.config.logs.full_logs")}
</h2>
<ha-icon-button
.path=${mdiRefresh}
@click=${this._refresh}
@@ -103,8 +63,12 @@ class ErrorLogCard extends LitElement {
: ""}
${!this._logHTML
? html`
<mwc-button raised @click=${this._refreshLogs}>
${this.hass.localize("ui.panel.config.logs.load_full_log")}
<mwc-button
raised
@click=${this._refreshLogs}
dir=${computeRTLDirection(this.hass)}
>
${this.hass.localize("ui.panel.config.logs.load_logs")}
</mwc-button>
`
: ""}
@@ -115,7 +79,7 @@ class ErrorLogCard extends LitElement {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
if (this.hass?.config.safe_mode) {
if (this.hass?.config.safe_mode || this.show) {
this.hass.loadFragmentTranslation("config");
this._refreshLogs();
}
@@ -124,21 +88,19 @@ class ErrorLogCard extends LitElement {
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("filter") && this._isLogLoaded) {
if (changedProps.has("provider")) {
this._logHTML = undefined;
}
if (
(changedProps.has("filter") && this._isLogLoaded) ||
(changedProps.has("show") && this.show) ||
(changedProps.has("provider") && this.show)
) {
this._refreshLogs();
}
}
private async _setLogProvider(ev): Promise<void> {
const provider = ev.target.value;
if (provider === this._selectedLogProvider) {
return;
}
this._selectedLogProvider = provider;
this._refreshLogs();
}
private async _refresh(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
@@ -151,18 +113,18 @@ class ErrorLogCard extends LitElement {
this._logHTML = this.hass.localize("ui.panel.config.logs.loading_log");
let log: string;
if (!this._selectedLogProvider && isComponentLoaded(this.hass, "hassio")) {
this._selectedLogProvider = "core";
}
if (this._selectedLogProvider) {
if (isComponentLoaded(this.hass, "hassio")) {
try {
log = await fetchHassioLogs(this.hass, this._selectedLogProvider);
log = await fetchHassioLogs(this.hass, this.provider);
this._logHTML = html`<ha-ansi-to-html .content=${log}>
</ha-ansi-to-html>`;
this._isLogLoaded = true;
return;
} catch (err: any) {
this._error = this.hass.localize(
"ui.panel.config.logs.failed_get_logs",
"provider",
this._selectedLogProvider,
this.provider,
"error",
extractApiErrorMessage(err)
);

View File

@@ -1,7 +1,11 @@
import { mdiChevronDown } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-button-menu";
import "../../../components/search-input";
import { LogProvider } from "../../../data/error_log";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
@@ -10,22 +14,51 @@ import "./error-log-card";
import "./system-log-card";
import type { SystemLogCard } from "./system-log-card";
const logProviders: LogProvider[] = [
{
key: "core",
name: "Home Assistant Core",
},
{
key: "supervisor",
name: "Supervisor",
},
{
key: "host",
name: "Host",
},
{
key: "dns",
name: "DNS",
},
{
key: "audio",
name: "Audio",
},
{
key: "multicast",
name: "Multicast",
},
];
@customElement("ha-config-logs")
export class HaConfigLogs extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property({ type: Boolean }) public narrow!: boolean;
@property() public isWide!: boolean;
@property({ type: Boolean }) public isWide!: boolean;
@property() public showAdvanced!: boolean;
@property({ type: Boolean }) public showAdvanced!: boolean;
@property() public route!: Route;
@property({ attribute: false }) public route!: Route;
@state() private _filter = extractSearchParam("filter") || "";
@query("system-log-card", true) private systemLog?: SystemLogCard;
@state() private _selectedLogProvider = "core";
public connectedCallback() {
super.connectedCallback();
if (this.systemLog && this.systemLog.loaded) {
@@ -68,21 +101,60 @@ export class HaConfigLogs extends LitElement {
.header=${this.hass.localize("ui.panel.config.logs.caption")}
back-path="/config/system"
>
${isComponentLoaded(this.hass, "hassio") &&
this.hass.userData?.showAdvanced
? html`
<ha-button-menu corner="BOTTOM_START" slot="toolbar-icon">
<mwc-button
slot="trigger"
.label=${logProviders.find(
(p) => p.key === this._selectedLogProvider
)!.name}
>
<ha-svg-icon
slot="trailingIcon"
.path=${mdiChevronDown}
></ha-svg-icon>
</mwc-button>
${logProviders.map(
(provider) => html`
<mwc-list-item
?selected=${provider.key === this._selectedLogProvider}
.provider=${provider.key}
@click=${this._selectProvider}
>
${provider.name}
</mwc-list-item>
`
)}
</ha-button-menu>
`
: ""}
${search}
<div class="content">
<system-log-card
.hass=${this.hass}
.filter=${this._filter}
></system-log-card>
${this._selectedLogProvider === "core"
? html`
<system-log-card
.hass=${this.hass}
.filter=${this._filter}
></system-log-card>
`
: ""}
<error-log-card
.hass=${this.hass}
.filter=${this._filter}
.provider=${this._selectedLogProvider}
.show=${this._selectedLogProvider !== "core"}
></error-log-card>
</div>
</hass-subpage>
`;
}
private _selectProvider(ev) {
this._selectedLogProvider = (ev.currentTarget as any).provider;
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -108,6 +180,11 @@ export class HaConfigLogs extends LitElement {
.content {
direction: ltr;
}
mwc-button[slot="trigger"] {
--mdc-theme-primary: var(--primary-text-color);
--mdc-icon-size: 36px;
}
`,
];
}

View File

@@ -18,6 +18,7 @@ import {
import { HomeAssistant } from "../../../types";
import { showSystemLogDetailDialog } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
@customElement("system-log-card")
export class SystemLogCard extends LitElement {
@@ -131,7 +132,7 @@ export class SystemLogCard extends LitElement {
`
)}
<div class="card-actions">
<div class="card-actions" dir=${computeRTLDirection(this.hass)}>
<ha-call-service-button
.hass=${this.hass}
domain="system_log"

View File

@@ -77,7 +77,6 @@ export class HaConfigLovelaceRescources extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${lovelaceTabs}
.columns=${this._columns(this.hass.language)}

View File

@@ -28,6 +28,7 @@ class HaConfigSectionNetwork extends LitElement {
${isComponentLoaded(this.hass, "hassio")
? html`<supervisor-hostname
.hass=${this.hass}
.narrow=${this.narrow}
></supervisor-hostname>
<supervisor-network .hass=${this.hass}></supervisor-network>`
: ""}

View File

@@ -14,6 +14,7 @@ import "../../../components/ha-header-bar";
import "../../../components/ha-icon-button";
import "../../../components/ha-radio";
import "../../../components/ha-related-items";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
@@ -22,12 +23,13 @@ import {
} from "../../../data/hassio/host";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-settings-row";
@customElement("supervisor-hostname")
export class HassioHostname extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) narrow!: boolean;
@state() private _processing = false;
@state() private _hostname?: string;
@@ -48,11 +50,12 @@ export class HassioHostname extends LitElement {
return html`
<ha-card
class="no-padding"
outlined
.header=${this.hass.localize("ui.panel.config.network.hostname.title")}
>
<div class="card-content">
<ha-settings-row>
<div>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">Hostname</span>
<span slot="description"
>The name your instance will have on your network</span
@@ -98,21 +101,17 @@ export class HassioHostname extends LitElement {
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-textfield {
width: 100%;
}
.card-actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
`,
];
}
static styles: CSSResultGroup = css`
ha-textfield {
width: 100%;
}
.card-actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
`;
}
declare global {

View File

@@ -1,269 +0,0 @@
import "@material/mwc-button";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { componentsWithService } from "../../../common/config/components_with_service";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/ha-card";
import { checkCoreConfig } from "../../../data/core";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
@customElement("ha-config-server-control")
export class HaConfigServerControl extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide!: boolean;
@property({ type: Boolean }) public narrow!: boolean;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public showAdvanced!: boolean;
@state() private _validating = false;
@state() private _reloadableDomains: string[] = [];
private _validateLog = "";
private _isValid: boolean | null = null;
protected updated(changedProperties) {
const oldHass = changedProperties.get("hass");
if (
changedProperties.has("hass") &&
(!oldHass || oldHass.config.components !== this.hass.config.components)
) {
this._reloadableDomains = componentsWithService(
this.hass,
"reload"
).sort();
}
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.showAdvanced=${this.showAdvanced}
.header=${this.hass.localize("ui.panel.config.server_control.caption")}
>
<div class="content">
${this.showAdvanced
? html`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.server_control.section.validation.heading"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.server_control.section.validation.introduction"
)}
${!this._validateLog
? html`
<div
class="validate-container layout vertical center-center"
>
${!this._validating
? html`
${this._isValid
? html` <div
class="validate-result"
id="result"
>
${this.hass.localize(
"ui.panel.config.server_control.section.validation.valid"
)}
</div>`
: ""}
<mwc-button
raised
@click=${this._validateConfig}
>
${this.hass.localize(
"ui.panel.config.server_control.section.validation.check_config"
)}
</mwc-button>
`
: html`
<ha-circular-progress
active
></ha-circular-progress>
`}
</div>
`
: html`
<div class="config-invalid">
<span class="text">
${this.hass.localize(
"ui.panel.config.server_control.section.validation.invalid"
)}
</span>
<mwc-button raised @click=${this._validateConfig}>
${this.hass.localize(
"ui.panel.config.server_control.section.validation.check_config"
)}
</mwc-button>
</div>
<div id="configLog" class="validate-log">
${this._validateLog}
</div>
`}
</div>
</ha-card>
`
: ""}
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.server_control.section.server_management.heading"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.server_control.section.server_management.introduction"
)}
</div>
<div class="card-actions warning">
<ha-call-service-button
class="warning"
.hass=${this.hass}
domain="homeassistant"
service="restart"
.confirmation=${this.hass.localize(
"ui.panel.config.server_control.section.server_management.confirm_restart"
)}
>${this.hass.localize(
"ui.panel.config.server_control.section.server_management.restart"
)}
</ha-call-service-button>
</div>
</ha-card>
${this.showAdvanced
? html`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.server_control.section.reloading.heading"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.server_control.section.reloading.introduction"
)}
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="homeassistant"
service="reload_core_config"
>${this.hass.localize(
"ui.panel.config.server_control.section.reloading.core"
)}
</ha-call-service-button>
</div>
${this._reloadableDomains.map(
(domain) =>
html`
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
.domain=${domain}
service="reload"
>${this.hass.localize(
`ui.panel.config.server_control.section.reloading.${domain}`
) ||
this.hass.localize(
"ui.panel.config.server_control.section.reloading.reload",
"domain",
domainToName(this.hass.localize, domain)
)}
</ha-call-service-button>
</div>
`
)}
</ha-card>
`
: ""}
</div>
</hass-subpage>
`;
}
private async _validateConfig() {
this._validating = true;
this._validateLog = "";
this._isValid = null;
const configCheck = await checkCoreConfig(this.hass);
this._validating = false;
this._isValid = configCheck.result === "valid";
if (configCheck.errors) {
this._validateLog = configCheck.errors;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.validate-container {
height: 140px;
}
.validate-result {
color: var(--success-color);
font-weight: 500;
margin-bottom: 1em;
}
.config-invalid {
margin: 1em 0;
}
.config-invalid .text {
color: var(--error-color);
font-weight: 500;
}
.config-invalid mwc-button {
float: right;
}
.validate-log {
white-space: pre-line;
direction: ltr;
}
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
margin-top: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-server-control": HaConfigServerControl;
}
}

View File

@@ -1,3 +1,4 @@
import { mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@@ -40,6 +41,22 @@ class HaConfigSectionStorage extends LitElement {
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.storage.caption")}
>
${this._hostInfo
? html`
<ha-button-menu corner="BOTTOM_START" slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.overflow")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item @click=${this._moveDatadisk}>
${this.hass.localize(
"ui.panel.config.storage.datadisk.title"
)}
</mwc-list-item>
</ha-button-menu>
`
: ""}
<div class="content">
${this._error
? html`
@@ -79,13 +96,6 @@ class HaConfigSectionStorage extends LitElement {
`
: ""}
</div>
<div class="card-actions">
<mwc-button @click=${this._moveDatadisk}>
${this.hass.localize(
"ui.panel.config.storage.datadisk.title"
)}
</mwc-button>
</div>
</ha-card>
`
: ""}
@@ -118,26 +128,16 @@ class HaConfigSectionStorage extends LitElement {
margin: 0 auto;
}
ha-card {
max-width: 500px;
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
}
.card-actions {
height: 48px;
border-top: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px 16px 0 16px;
}
`;
}

View File

@@ -14,15 +14,21 @@ import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import "../../../components/ha-metric";
import { fetchHassioStats, HassioStats } from "../../../data/hassio/common";
import {
fetchHassioResolution,
HassioResolution,
} from "../../../data/hassio/resolution";
import { domainToName } from "../../../data/integration";
import {
subscribeSystemHealthInfo,
SystemCheckValueObject,
SystemHealthInfo,
} from "../../../data/system_health";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
const sortKeys = (a: string, b: string) => {
@@ -41,6 +47,11 @@ const sortKeys = (a: string, b: string) => {
return 0;
};
export const UNSUPPORTED_REASON_URL = {};
export const UNHEALTHY_REASON_URL = {
privileged: "/more-info/unsupported/privileged",
};
@customElement("ha-config-system-health")
class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -51,6 +62,8 @@ class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
@state() private _supervisorStats?: HassioStats;
@state() private _resolutionInfo?: HassioResolution;
@state() private _coreStats?: HassioStats;
@state() private _error?: { code: string; message: string };
@@ -79,6 +92,9 @@ class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
10000
)
);
fetchHassioResolution(this.hass).then((data) => {
this._resolutionInfo = data;
});
}
return subs;
@@ -219,6 +235,35 @@ class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
`
: ""}
<div class="content">
${this._resolutionInfo
? html`${this._resolutionInfo.unhealthy.length
? html`<ha-alert alert-type="error">
${this.hass.localize("ui.dialogs.unhealthy.title")}
<mwc-button
slot="action"
.label=${this.hass.localize(
"ui.panel.config.common.learn_more"
)}
@click=${this._unhealthyDialog}
>
</mwc-button
></ha-alert>`
: ""}
${this._resolutionInfo.unsupported.length
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.dialogs.unsupported.title")}
<mwc-button
slot="action"
.label=${this.hass.localize(
"ui.panel.config.common.learn_more"
)}
@click=${this._unsupportedDialog}
>
</mwc-button>
</ha-alert>`
: ""} `
: ""}
<ha-card outlined>
<div class="card-content">${sections}</div>
</ha-card>
@@ -277,6 +322,64 @@ class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
`;
}
private async _unsupportedDialog(): Promise<void> {
await showAlertDialog(this, {
title: this.hass.localize("ui.dialogs.unsupported.title"),
text: html`${this.hass.localize("ui.dialogs.unsupported.description")}
<br /><br />
<ul>
${this._resolutionInfo!.unsupported.map(
(reason) => html`
<li>
<a
href=${documentationUrl(
this.hass,
UNSUPPORTED_REASON_URL[reason] ||
`/more-info/unsupported/${reason}`
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
`ui.dialogs.unsupported.reason.${reason}`
) || reason}
</a>
</li>
`
)}
</ul>`,
});
}
private async _unhealthyDialog(): Promise<void> {
await showAlertDialog(this, {
title: this.hass.localize("ui.dialogs.unhealthy.title"),
text: html`${this.hass.localize("ui.dialogs.unhealthy.description")}
<br /><br />
<ul>
${this._resolutionInfo!.unhealthy.map(
(reason) => html`
<li>
<a
href=${documentationUrl(
this.hass,
UNHEALTHY_REASON_URL[reason] ||
`/more-info/unhealthy/${reason}`
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
`ui.dialogs.unhealthy.reason.${reason}`
) || reason}
</a>
</li>
`
)}
</ul>`,
});
}
private async _copyInfo(ev: CustomEvent<ActionDetail>): Promise<void> {
const github = ev.detail.index === 1;
let haContent: string | undefined;
@@ -349,11 +452,17 @@ class HaConfigSystemHealth extends SubscribeMixin(LitElement) {
}
ha-card {
display: block;
max-width: 500px;
max-width: 600px;
margin: 0 auto;
padding-bottom: 16px;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
ha-alert {
display: block;
max-width: 500px;
margin: 0 auto;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
table {
width: 100%;
}

View File

@@ -1,284 +0,0 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import timezones from "google-timezones-json";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { UNIT_C } from "../../../common/const";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { currencies } from "../../../components/currency-datalist";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-formfield";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-select";
import "../../../components/ha-textfield";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { SYMBOL_TO_ISO } from "../../../data/currency";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("dialog-core-zone-detail")
class DialogZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _submitting = false;
@state() private _open = false;
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _currency?: string;
@state() private _name?: string;
@state() private _elevation?: number;
@state() private _timeZone?: string;
public showDialog(): void {
this._submitting = false;
this._unitSystem =
this.hass.config.unit_system.temperature === UNIT_C
? "metric"
: "imperial";
this._currency = this.hass.config.currency;
this._elevation = this.hass.config.elevation;
this._timeZone = this.hass.config.time_zone;
this._name = this.hass.config.location_name;
this._open = true;
}
public closeDialog(): void {
this._open = false;
this._currency = undefined;
this._elevation = undefined;
this._timeZone = undefined;
this._unitSystem = undefined;
this._name = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
this.hass.config.config_source
);
const disabled = this._submitting || !canEdit;
if (!this._open) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.zone.core_location_dialog")
)}
>
${!canEdit
? html`
<p>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_requires_storage"
)}
</p>
`
: ""}
<ha-textfield
name="name"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.location_name"
)}
.disabled=${disabled}
.value=${this._name}
@change=${this._handleChange}
></ha-textfield>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone"
)}
name="timeZone"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._timeZone}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${Object.keys(timezones).map(
(tz) =>
html`<mwc-list-item value=${tz}>${timezones[tz]}</mwc-list-item>`
)}
</ha-select>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation"
)}
name="elevation"
type="number"
.disabled=${disabled}
.value=${this._elevation}
@change=${this._handleChange}
>
<span slot="suffix">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
</span>
</ha-textfield>
<div>
<div>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system"
)}
</div>
<ha-formfield
.label=${html`${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
)}
<div class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
)}
</div>`}
>
<ha-radio
name="unit_system"
value="metric"
.checked=${this._unitSystem === "metric"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${html`${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_imperial"
)}
<div class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.imperial_example"
)}
</div>`}
>
<ha-radio
name="unit_system"
value="imperial"
.checked=${this._unitSystem === "imperial"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
</div>
<div>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}
name="currency"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._currency}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${currencies.map(
(currency) =>
html`<mwc-list-item .value=${currency}
>${currency}</mwc-list-item
>`
)}</ha-select
>
<a
href="https://en.wikipedia.org/wiki/ISO_4217#Active_codes"
target="_blank"
rel="noopener noreferrer"
>${this.hass.localize(
"ui.panel.config.core.section.core.core_config.find_currency_value"
)}</a
>
</div>
<mwc-button slot="primaryAction" @click=${this._updateEntry}>
${this.hass!.localize("ui.panel.config.zone.detail.update")}
</mwc-button>
</ha-dialog>
`;
}
private _handleChange(ev) {
const target = ev.currentTarget;
let value = target.value;
if (target.name === "currency" && value) {
if (value in SYMBOL_TO_ISO) {
value = SYMBOL_TO_ISO[value];
}
}
this[`_${target.name}`] = value;
}
private _unitSystemChanged(ev: CustomEvent) {
this._unitSystem = (ev.target as HaRadio).value as "metric" | "imperial";
}
private async _updateEntry() {
this._submitting = true;
try {
await saveCoreConfig(this.hass, {
currency: this._currency,
elevation: Number(this._elevation),
unit_system: this._unitSystem,
time_zone: this._timeZone,
location_name: this._name,
});
} catch (err: any) {
alert(`Error saving config: ${err.message}`);
} finally {
this._submitting = false;
}
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: 600px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
}
}
.card-actions {
text-align: right;
}
ha-dialog > * {
display: block;
margin-top: 16px;
}
ha-select {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-core-zone-detail": DialogZoneDetail;
}
}

View File

@@ -46,7 +46,6 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { showCoreZoneDetailDialog } from "./show-dialog-core-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
@customElement("ha-config-zone")
@@ -188,30 +187,26 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<div style="display:inline-block">
<ha-icon-button
.entityId=${stateObject.entity_id}
@click=${this._openCoreConfig}
.noEdit=${stateObject.entity_id !== "zone.home" ||
!this._canEditCore}
.path=${stateObject.entity_id === "zone.home" &&
this._canEditCore
? mdiPencil
: mdiPencilOff}
.label=${hass.localize(
"ui.panel.config.zone.edit_zone"
)}
.label=${stateObject.entity_id === "zone.home"
? hass.localize("ui.panel.config.zone.edit_home")
: hass.localize("ui.panel.config.zone.edit_zone")}
@click=${this._openCoreConfig}
></ha-icon-button>
<paper-tooltip animation-delay="0" position="left">
${stateObject.entity_id === "zone.home"
? hass.localize(
`ui.panel.config.zone.${
this.narrow
? "edit_home_zone_narrow"
: "edit_home_zone"
}`
)
: hass.localize(
"ui.panel.config.zone.configured_in_yaml"
)}
</paper-tooltip>
${stateObject.entity_id !== "zone.home"
? html`
<paper-tooltip animation-delay="0" position="left">
${hass.localize(
"ui.panel.config.zone.configured_in_yaml"
)}
</paper-tooltip>
`
: ""}
</div>
</paper-icon-item>
`
@@ -397,7 +392,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
});
return;
}
showCoreZoneDetailDialog(this);
navigate("/config/general");
}
private async _createEntry(values: ZoneMutableParams) {

View File

@@ -1,12 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const loadCoreZoneDetailDialog = () =>
import("./dialog-core-zone-detail");
export const showCoreZoneDetailDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-core-zone-detail",
dialogImport: loadCoreZoneDetailDialog,
dialogParams: {},
});
};

View File

@@ -41,6 +41,10 @@ class DeveloperToolsRouter extends HassRouterPage {
tag: "developer-tools-statistics",
load: () => import("./statistics/developer-tools-statistics"),
},
yaml: {
tag: "developer-yaml-config",
load: () => import("./yaml_configuration/developer-yaml-config"),
},
},
};

View File

@@ -67,6 +67,9 @@ class PanelDeveloperTools extends LitElement {
"ui.panel.developer-tools.tabs.statistics.title"
)}
</paper-tab>
<paper-tab page-name="yaml">
${this.hass.localize("ui.panel.developer-tools.tabs.yaml.title")}
</paper-tab>
</ha-tabs>
</app-header>
<developer-tools-router

View File

@@ -0,0 +1,232 @@
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { componentsWithService } from "../../../common/config/components_with_service";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import { checkCoreConfig } from "../../../data/core";
import { domainToName } from "../../../data/integration";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@customElement("developer-yaml-config")
export class DeveloperYamlConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide!: boolean;
@property({ type: Boolean }) public narrow!: boolean;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public showAdvanced!: boolean;
@state() private _validating = false;
@state() private _reloadableDomains: string[] = [];
private _validateLog = "";
private _isValid: boolean | null = null;
protected updated(changedProperties) {
const oldHass = changedProperties.get("hass");
if (
changedProperties.has("hass") &&
(!oldHass || oldHass.config.components !== this.hass.config.components)
) {
this._reloadableDomains = componentsWithService(
this.hass,
"reload"
).sort();
}
}
protected render(): TemplateResult {
return html`
<div class="content">
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.validation.heading"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.validation.introduction"
)}
${!this._validateLog
? html`
<div class="validate-container layout vertical center-center">
${!this._validating
? html`
${this._isValid
? html`<div class="validate-result" id="result">
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.validation.valid"
)}
</div>`
: ""}
`
: html`
<ha-circular-progress active></ha-circular-progress>
`}
</div>
`
: html`
<div class="config-invalid">
<span class="text">
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.validation.invalid"
)}
</span>
</div>
<div id="configLog" class="validate-log">
${this._validateLog}
</div>
`}
</div>
<div class="card-actions">
<mwc-button @click=${this._validateConfig}>
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.validation.check_config"
)}
</mwc-button>
<mwc-button
class="warning"
@click=${this._restart}
.disabled=${this._validateLog}
>
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.server_management.restart"
)}
</mwc-button>
</div>
</ha-card>
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.reloading.heading"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.reloading.introduction"
)}
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="homeassistant"
service="reload_core_config"
>${this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.reloading.core"
)}
</ha-call-service-button>
</div>
${this._reloadableDomains.map(
(domain) =>
html`
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
.domain=${domain}
service="reload"
>${this.hass.localize(
`ui.panel.developer-tools.tabs.yaml.section.reloading.${domain}`
) ||
this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.reloading.reload",
"domain",
domainToName(this.hass.localize, domain)
)}
</ha-call-service-button>
</div>
`
)}
</ha-card>
</div>
`;
}
private async _validateConfig() {
this._validating = true;
this._validateLog = "";
this._isValid = null;
const configCheck = await checkCoreConfig(this.hass);
this._validating = false;
this._isValid = configCheck.result === "valid";
if (configCheck.errors) {
this._validateLog = configCheck.errors;
}
}
private _restart() {
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.developer-tools.tabs.yaml.section.server_management.confirm_restart"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
confirm: () => {
this.hass.callService("homeassistant", "restart");
},
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.validate-container {
height: 60px;
}
.validate-result {
color: var(--success-color);
font-weight: 500;
}
.config-invalid {
margin: 1em 0;
text-align: center;
}
.config-invalid .text {
color: var(--error-color);
font-weight: 500;
}
.validate-log {
white-space: pre-line;
direction: ltr;
}
.content {
padding: 28px 20px 16px;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
margin-top: 24px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"developer-yaml-config": DeveloperYamlConfig;
}
}

View File

@@ -12,172 +12,183 @@ import "../../layouts/hass-error-screen";
import { HomeAssistant, Route } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({
developer_states: {
redirect: "/developer-tools/state",
},
developer_services: {
redirect: "/developer-tools/service",
},
developer_call_service: {
redirect: "/developer-tools/service",
params: {
service: "string",
},
},
developer_template: {
redirect: "/developer-tools/template",
},
developer_events: {
redirect: "/developer-tools/event",
},
developer_statistics: {
redirect: "/developer-tools/statistics",
},
config: {
redirect: "/config/dashboard",
},
cloud: {
component: "cloud",
redirect: "/config/cloud",
},
config_flow_start: {
redirect: "/config/integrations/add",
params: {
domain: "string",
},
},
integrations: {
redirect: "/config/integrations",
},
config_mqtt: {
component: "mqtt",
redirect: "/config/mqtt",
},
config_zha: {
component: "zha",
redirect: "/config/zha/dashboard",
},
config_zwave_js: {
component: "zwave_js",
redirect: "/config/zwave_js/dashboard",
},
config_energy: {
component: "energy",
redirect: "/config/energy/dashboard",
},
devices: {
redirect: "/config/devices/dashboard",
},
entities: {
redirect: "/config/entities",
},
energy: {
component: "energy",
redirect: "/energy",
},
areas: {
redirect: "/config/areas/dashboard",
},
blueprint_import: {
component: "blueprint",
redirect: "/config/blueprint/dashboard/import",
params: {
blueprint_url: "url",
},
},
blueprints: {
component: "blueprint",
redirect: "/config/blueprint/dashboard",
},
automations: {
component: "automation",
redirect: "/config/automation/dashboard",
},
scenes: {
component: "scene",
redirect: "/config/scene/dashboard",
},
scripts: {
component: "script",
redirect: "/config/script/dashboard",
},
helpers: {
redirect: "/config/helpers",
},
tags: {
component: "tag",
redirect: "/config/tags",
},
lovelace_dashboards: {
component: "lovelace",
redirect: "/config/lovelace/dashboards",
},
lovelace_resources: {
component: "lovelace",
redirect: "/config/lovelace/resources",
},
people: {
component: "person",
redirect: "/config/person",
},
zones: {
component: "zone",
redirect: "/config/zone",
},
users: {
redirect: "/config/users",
},
general: {
redirect: "/config/core",
},
server_controls: {
redirect: "/developer-tools/yaml",
},
logs: {
redirect: "/config/logs",
},
info: {
redirect: "/config/info",
},
customize: {
// customize was removed in 2021.12, fallback to dashboard
redirect: "/config/dashboard",
},
profile: {
redirect: "/profile",
},
logbook: {
component: "logbook",
redirect: "/logbook",
},
history: {
component: "history",
redirect: "/history",
},
media_browser: {
component: "media_source",
redirect: "/media-browser",
},
backup: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
supervisor_snapshots: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
supervisor_backups: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
supervisor_system: {
// Moved from Supervisor panel in 2022.5
redirect: "/config/system",
},
supervisor_logs: {
// Moved from Supervisor panel in 2022.5
redirect: "/config/logs",
},
supervisor_info: {
// Moved from Supervisor panel in 2022.5
redirect: "/config/info",
},
});
const getRedirect = (
path: string,
hasSupervisor: boolean
): Redirect | undefined =>
((
{
developer_states: {
redirect: "/developer-tools/state",
},
developer_services: {
redirect: "/developer-tools/service",
},
developer_call_service: {
redirect: "/developer-tools/service",
params: {
service: "string",
},
},
developer_template: {
redirect: "/developer-tools/template",
},
developer_events: {
redirect: "/developer-tools/event",
},
developer_statistics: {
redirect: "/developer-tools/statistics",
},
config: {
redirect: "/config",
},
cloud: {
component: "cloud",
redirect: "/config/cloud",
},
integrations: {
redirect: "/config/integrations",
},
config_flow_start: {
redirect: "/config/integrations/add",
params: {
domain: "string",
},
},
config_mqtt: {
component: "mqtt",
redirect: "/config/mqtt",
},
config_zha: {
component: "zha",
redirect: "/config/zha/dashboard",
},
config_zwave_js: {
component: "zwave_js",
redirect: "/config/zwave_js/dashboard",
},
config_energy: {
component: "energy",
redirect: "/config/energy/dashboard",
},
devices: {
redirect: "/config/devices/dashboard",
},
entities: {
redirect: "/config/entities",
},
energy: {
component: "energy",
redirect: "/energy",
},
areas: {
redirect: "/config/areas/dashboard",
},
blueprints: {
component: "blueprint",
redirect: "/config/blueprint/dashboard",
},
blueprint_import: {
component: "blueprint",
redirect: "/config/blueprint/dashboard/import",
params: {
blueprint_url: "url",
},
},
automations: {
component: "automation",
redirect: "/config/automation/dashboard",
},
scenes: {
component: "scene",
redirect: "/config/scene/dashboard",
},
scripts: {
component: "script",
redirect: "/config/script/dashboard",
},
helpers: {
redirect: "/config/helpers",
},
tags: {
component: "tag",
redirect: "/config/tags",
},
lovelace_dashboards: {
component: "lovelace",
redirect: "/config/lovelace/dashboards",
},
lovelace_resources: {
component: "lovelace",
redirect: "/config/lovelace/resources",
},
people: {
component: "person",
redirect: "/config/person",
},
zones: {
component: "zone",
redirect: "/config/zone",
},
users: {
redirect: "/config/users",
},
general: {
redirect: "/config/core",
},
server_controls: {
redirect: "/config/server_control",
},
logs: {
redirect: "/config/logs",
},
info: {
redirect: "/config/info",
},
customize: {
// customize was removed in 2021.12, fallback to dashboard
redirect: "/config/dashboard",
},
profile: {
redirect: "/profile/dashboard",
},
logbook: {
component: "logbook",
redirect: "/logbook",
},
history: {
component: "history",
redirect: "/history",
},
media_browser: {
component: "media_source",
redirect: "/media-browser",
},
backup: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
supervisor_snapshots: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
supervisor_backups: {
component: hasSupervisor ? "hassio" : "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup",
},
} as Redirects
)[path]);
): Redirect | undefined => getMyRedirects(hasSupervisor)?.[path];
export type ParamType = "url" | "string";

View File

@@ -30,7 +30,8 @@ class StateCardInputNumber extends mixinBehaviors(
.sliderstate {
min-width: 45px;
}
ha-slider[hidden] {
ha-slider[hidden],
ha-textfield[hidden] {
display: none !important;
}
ha-textfield {

View File

@@ -1,16 +1,20 @@
import type { PropertyValues } from "lit";
import tinykeys from "tinykeys";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { mainWindow } from "../common/dom/get_main_window";
import {
QuickBarParams,
showQuickBar,
} from "../dialogs/quick-bar/show-dialog-quick-bar";
import { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { showToast } from "../util/toast";
import { HassElement } from "./hass-element";
declare global {
interface HASSDomEvents {
"hass-quick-bar": QuickBarParams;
"hass-quick-bar-trigger": KeyboardEvent;
"hass-enable-shortcuts": HomeAssistant["enableShortcuts"];
}
}
@@ -25,6 +29,20 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
storeState(this.hass!);
});
mainWindow.addEventListener("hass-quick-bar-trigger", (ev) => {
switch (ev.detail.key) {
case "e":
this._showQuickBar(ev.detail);
break;
case "c":
this._showQuickBar(ev.detail, true);
break;
case "m":
this._createMyLink(ev.detail);
break;
}
});
this._registerShortcut();
}
@@ -32,6 +50,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
tinykeys(window, {
e: (ev) => this._showQuickBar(ev),
c: (ev) => this._showQuickBar(ev, true),
m: (ev) => this._createMyLink(ev),
});
}
@@ -43,6 +62,63 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
showQuickBar(this, { commandMode });
}
private async _createMyLink(e: KeyboardEvent) {
if (
!this.hass?.enableShortcuts ||
!this._canOverrideAlphanumericInput(e)
) {
return;
}
const targetPath = mainWindow.location.pathname;
const isHassio = isComponentLoaded(this.hass, "hassio");
const myParams = new URLSearchParams();
if (isHassio && targetPath.startsWith("/hassio")) {
const myPanelSupervisor = await import(
"../../hassio/src/hassio-my-redirect"
);
for (const [slug, redirect] of Object.entries(
myPanelSupervisor.REDIRECTS
)) {
if (targetPath.startsWith(redirect.redirect)) {
myParams.append("redirect", slug);
if (redirect.redirect === "/hassio/addon") {
myParams.append("addon", targetPath.split("/")[3]);
}
window.open(
`https://my.home-assistant.io/create-link/?${myParams.toString()}`,
"_blank"
);
return;
}
}
}
const myPanel = await import("../panels/my/ha-panel-my");
for (const [slug, redirect] of Object.entries(
myPanel.getMyRedirects(isHassio)
)) {
if (targetPath.startsWith(redirect.redirect)) {
myParams.append("redirect", slug);
window.open(
`https://my.home-assistant.io/create-link/?${myParams.toString()}`,
"_blank"
);
return;
}
}
showToast(this, {
message: this.hass.localize(
"ui.notification_toast.no_matching_link_found",
{
path: targetPath,
}
),
});
}
private _canShowQuickBar(e: KeyboardEvent) {
return (
this.hass?.user?.is_admin &&

View File

@@ -314,6 +314,7 @@
"undo": "Undo",
"move": "Move",
"save": "Save",
"edit": "Edit",
"submit": "Submit",
"rename": "Rename",
"yes": "Yes",
@@ -624,43 +625,43 @@
"quick-bar": {
"commands": {
"reload": {
"reload": "[%key:ui::panel::config::server_control::section::reloading::reload%]",
"core": "[%key:ui::panel::config::server_control::section::reloading::core%]",
"group": "[%key:ui::panel::config::server_control::section::reloading::group%]",
"automation": "[%key:ui::panel::config::server_control::section::reloading::automation%]",
"script": "[%key:ui::panel::config::server_control::section::reloading::script%]",
"scene": "[%key:ui::panel::config::server_control::section::reloading::scene%]",
"person": "[%key:ui::panel::config::server_control::section::reloading::person%]",
"zone": "[%key:ui::panel::config::server_control::section::reloading::zone%]",
"input_boolean": "[%key:ui::panel::config::server_control::section::reloading::input_boolean%]",
"input_text": "[%key:ui::panel::config::server_control::section::reloading::input_text%]",
"input_number": "[%key:ui::panel::config::server_control::section::reloading::input_number%]",
"input_datetime": "[%key:ui::panel::config::server_control::section::reloading::input_datetime%]",
"input_select": "[%key:ui::panel::config::server_control::section::reloading::input_select%]",
"template": "[%key:ui::panel::config::server_control::section::reloading::template%]",
"universal": "[%key:ui::panel::config::server_control::section::reloading::universal%]",
"rest": "[%key:ui::panel::config::server_control::section::reloading::rest%]",
"command_line": "[%key:ui::panel::config::server_control::section::reloading::command_line%]",
"filter": "[%key:ui::panel::config::server_control::section::reloading::filter%]",
"statistics": "[%key:ui::panel::config::server_control::section::reloading::statistics%]",
"generic": "[%key:ui::panel::config::server_control::section::reloading::generic%]",
"generic_thermostat": "[%key:ui::panel::config::server_control::section::reloading::generic_thermostat%]",
"homekit": "[%key:ui::panel::config::server_control::section::reloading::homekit%]",
"min_max": "[%key:ui::panel::config::server_control::section::reloading::min_max%]",
"history_stats": "[%key:ui::panel::config::server_control::section::reloading::history_stats%]",
"trend": "[%key:ui::panel::config::server_control::section::reloading::trend%]",
"ping": "[%key:ui::panel::config::server_control::section::reloading::ping%]",
"filesize": "[%key:ui::panel::config::server_control::section::reloading::filesize%]",
"telegram": "[%key:ui::panel::config::server_control::section::reloading::telegram%]",
"smtp": "[%key:ui::panel::config::server_control::section::reloading::smtp%]",
"mqtt": "[%key:ui::panel::config::server_control::section::reloading::mqtt%]",
"rpi_gpio": "[%key:ui::panel::config::server_control::section::reloading::rpi_gpio%]",
"themes": "[%key:ui::panel::config::server_control::section::reloading::themes%]"
"reload": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::reload%]",
"core": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::core%]",
"group": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::group%]",
"automation": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::automation%]",
"script": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::script%]",
"scene": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::scene%]",
"person": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::person%]",
"zone": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::zone%]",
"input_boolean": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_boolean%]",
"input_text": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_text%]",
"input_number": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_number%]",
"input_datetime": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_datetime%]",
"input_select": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::input_select%]",
"template": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::template%]",
"universal": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::universal%]",
"rest": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::rest%]",
"command_line": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::command_line%]",
"filter": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::filter%]",
"statistics": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::statistics%]",
"generic": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::generic%]",
"generic_thermostat": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::generic_thermostat%]",
"homekit": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::homekit%]",
"min_max": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::min_max%]",
"history_stats": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::history_stats%]",
"trend": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::trend%]",
"ping": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::ping%]",
"filesize": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::filesize%]",
"telegram": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::telegram%]",
"smtp": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::smtp%]",
"mqtt": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::mqtt%]",
"rpi_gpio": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::rpi_gpio%]",
"themes": "[%key:ui::panel::developer-tools::tabs::yaml::section::reloading::themes%]"
},
"server_control": {
"perform_action": "{action} server",
"restart": "[%key:ui::panel::config::server_control::section::server_management::restart%]",
"stop": "[%key:ui::panel::config::server_control::section::server_management::stop%]"
"restart": "[%key:ui::panel::developer-tools::tabs::yaml::section::server_management::restart%]",
"stop": "[%key:ui::panel::developer-tools::tabs::yaml::section::server_management::stop%]"
},
"types": {
"reload": "Reload",
@@ -686,12 +687,12 @@
"users": "[%key:ui::panel::config::users::caption%]",
"info": "[%key:ui::panel::config::info::caption%]",
"blueprint": "[%key:ui::panel::config::blueprint::caption%]",
"server_control": "[%key:ui::panel::config::server_control::caption%]"
"server_control": "[%key:ui::panel::developer-tools::tabs::yaml::title%]"
}
},
"filter_placeholder": "Entity Filter",
"title": "Quick Search",
"key_c_hint": "Press 'c' on any page to open this search bar",
"key_c_hint": "Press 'c' on any page to open the search bar",
"nothing_found": "Nothing found!"
},
"voice_command": {
@@ -857,7 +858,7 @@
"area_note": "By default the entities of a device are in the same area as the device. If you change the area of this entity, it will no longer follow the area of the device.",
"follow_device_area": "Follow device area",
"change_device_area": "Change device area",
"configure_state": "Configure State"
"configure_state": "{integration} options"
}
},
"helper_settings": {
@@ -994,6 +995,45 @@
"recent_tx_messages": "{n} most recently transmitted message(s)",
"show_as_yaml": "Show as YAML",
"triggers": "Triggers"
},
"unsupported": {
"title": "[%key:supervisor::system::supervisor::unsupported_title%]",
"description": "[%key:supervisor::system::supervisor::unsupported_description%]",
"reasons": {
"apparmor": "[%key:supervisor::system::supervisor::unsupported_reason::apparmor%]",
"content_trust": "[%key:supervisor::system::supervisor::unsupported_reason::content_trust%]",
"dbus": "[%key:supervisor::system::supervisor::unsupported_reason::dbus%]",
"docker_configuration": "[%key:supervisor::system::supervisor::unsupported_reason::docker_configuration%]",
"docker_version": "[%key:supervisor::system::supervisor::unsupported_reason::docker_version%]",
"job_conditions": "[%key:supervisor::system::supervisor::unsupported_reason::job_conditions%]",
"lxc": "[%key:supervisor::system::supervisor::unsupported_reason::lxc%]",
"network_manager": "[%key:supervisor::system::supervisor::unsupported_reason::network_manager%]",
"os": "[%key:supervisor::system::supervisor::unsupported_reason::os%]",
"os_agent": "[%key:supervisor::system::supervisor::unsupported_reason::os_agent%]",
"privileged": "[%key:supervisor::system::supervisor::unsupported_reason::privileged%]",
"software": "[%key:supervisor::system::supervisor::unsupported_reason::software%]",
"source_mods": "[%key:supervisor::system::supervisor::unsupported_reason::source_mods%]",
"systemd": "[%key:supervisor::system::supervisor::unsupported_reason::systemd%]",
"systemd_resolved": "[%key:supervisor::system::supervisor::unsupported_reason::systemd_resolved%]"
}
},
"unhealthy": {
"title": "[%key:supervisor::system::supervisor::unhealthy_title%]",
"description": "[%key:supervisor::system::supervisor::unhealthy_description%]",
"reasons": {
"privileged": "[%key:supervisor::system::supervisor::unhealthy_reason::privileged%]",
"supervisor": "[%key:supervisor::system::supervisor::unhealthy_reason::supervisor%]",
"setup": "[%key:supervisor::system::supervisor::unhealthy_reason::setup%]",
"docker": "[%key:supervisor::system::supervisor::unhealthy_reason::docker%]",
"untrusted": "[%key:supervisor::system::supervisor::unhealthy_reason::untrusted%]"
}
},
"join_beta_channel": {
"title": "Join the beta channel",
"warning": "[%key:supervisor::system::supervisor::beta_warning%]",
"backup": "[%key:supervisor::system::supervisor::beta_backup%]",
"release_items": "[%key:supervisor::system::supervisor::beta_release_items%]",
"confirm": "[%key:supervisor::system::supervisor::beta_join_confirm%]"
}
},
"duration": {
@@ -1044,7 +1084,8 @@
"wrapping_up_startup": "Wrapping up startup, not everything will be available until it is finished.",
"integration_starting": "Starting {integration}, not everything will be available until it is finished.",
"triggered": "Triggered {name}",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}"
},
"sidebar": {
"external_app_configuration": "App Configuration",
@@ -1071,7 +1112,7 @@
},
"automations": {
"main": "Automations & Scenes",
"secondary": "Manage automations, scenes, scripts and blueprints"
"secondary": "Automations, scenes, scripts and blueprints"
},
"backup": {
"main": "Backup",
@@ -1079,11 +1120,11 @@
},
"supervisor": {
"main": "Add-ons",
"secondary": "Extend the function around Home Assistant"
"secondary": "Run extra applications next to Home Assistant"
},
"dashboards": {
"main": "Dashboards",
"secondary": "Create customized sets of cards to control your home"
"secondary": "Organize how you interact with your home"
},
"energy": {
"main": "Energy",
@@ -1091,15 +1132,15 @@
},
"tags": {
"main": "Tags",
"secondary": "Trigger automations when an NFC tag, QR code, etc. is scanned"
"secondary": "Manage NFC tags and QR codes"
},
"people": {
"main": "People",
"secondary": "Manage the people that Home Assistant tracks"
"secondary": "Manage who can access your home"
},
"areas": {
"main": "Areas & Zones",
"secondary": "Manage areas and zones that Home Assistant tracks"
"secondary": "Manage locations in and around your house"
},
"companion": {
"main": "Companion App",
@@ -1137,6 +1178,8 @@
"show": "show",
"show_skipped": "Show skipped",
"hide_skipped": "Hide skipped",
"join_beta": "[%key:supervisor::system::supervisor::join_beta_action%]",
"leave_beta": "[%key:supervisor::system::supervisor::leave_beta_action%]",
"skipped": "Skipped"
},
"areas": {
@@ -1195,6 +1238,9 @@
"title": "Remove backup",
"description": "Are you sure you want to remove the backup with the name {name}?",
"confirm": "[%key:ui::common::remove%]"
},
"picker": {
"search": "Search backups"
}
},
"tag": {
@@ -1449,7 +1495,9 @@
"metric_example": "Celsius, kilograms",
"find_currency_value": "Find your value",
"save_button": "Save",
"currency": "Currency"
"currency": "Currency",
"edit_location": "Edit location",
"edit_location_description": "Location can be changed in zone settings"
}
}
}
@@ -1490,7 +1538,7 @@
"copy_menu": "Copy menu",
"copy_raw": "Raw Text",
"copy_github": "For GitHub",
"description": "Version, loaded integration and links to documentation",
"description": "Version, loaded integrations and links to documentation",
"home_assistant_logo": "Home Assistant logo",
"path_configuration": "Path to configuration.yaml: {path}",
"developed_by": "Developed by a bunch of awesome people.",
@@ -1519,7 +1567,7 @@
"search": "Search logs",
"failed_get_logs": "Failed to get {provider} logs, {error}",
"no_issues_search": "No issues found for search term ''{term}''",
"load_full_log": "Load Full Home Assistant Log",
"load_logs": "Load Full Logs",
"loading_log": "Loading error log…",
"no_errors": "No errors have been reported",
"no_issues": "There are no new issues!",
@@ -1536,7 +1584,8 @@
"debug": "DEBUG"
},
"custom_integration": "custom integration",
"error_from_custom_integration": "This error originated from a custom integration."
"error_from_custom_integration": "This error originated from a custom integration.",
"full_logs": "Full logs"
},
"lovelace": {
"caption": "Dashboards",
@@ -1618,65 +1667,6 @@
}
}
},
"server_control": {
"caption": "Server Controls",
"description": "Validate and restart the Home Assistant server",
"section": {
"validation": {
"heading": "Configuration validation",
"introduction": "Validate your configuration if you recently made some changes to your configuration and want to make sure that it is all valid.",
"check_config": "Check configuration",
"valid": "Configuration valid!",
"invalid": "Configuration invalid"
},
"reloading": {
"heading": "YAML configuration reloading",
"introduction": "Some parts of Home Assistant can reload without requiring a restart. Clicking one of the options below will unload their current YAML configuration and load the new one.",
"reload": "{domain}",
"core": "Location & customizations",
"group": "Groups, group entities, and notify services",
"automation": "Automations",
"script": "Scripts",
"scene": "Scenes",
"person": "People",
"zone": "Zones",
"input_boolean": "Input booleans",
"input_button": "Input buttons",
"input_text": "Input texts",
"input_number": "Input numbers",
"input_datetime": "Input date times",
"input_select": "Input selects",
"template": "Template entities",
"universal": "Universal media player entities",
"rest": "Rest entities and notify services",
"command_line": "Command line entities",
"filter": "Filter entities",
"statistics": "Statistics entities",
"generic": "Generic IP camera entities",
"generic_thermostat": "Generic thermostat entities",
"homekit": "HomeKit",
"min_max": "Min/max entities",
"history_stats": "History stats entities",
"trend": "Trend entities",
"ping": "Ping binary sensor entities",
"filesize": "File size entities",
"telegram": "Telegram notify services",
"smtp": "SMTP notify services",
"mqtt": "Manually configured MQTT entities",
"rpi_gpio": "Raspberry Pi GPIO entities",
"timer": "Timers",
"themes": "Themes"
},
"server_management": {
"heading": "Home Assistant",
"introduction": "Restarting Home Assistant will stop your dashboard and automations. After the reboot, each configuration will be reloaded.",
"restart": "Restart",
"confirm_restart": "Are you sure you want to restart Home Assistant?",
"stop": "Stop",
"confirm_stop": "Are you sure you want to stop Home Assistant?"
}
}
},
"automation": {
"caption": "Automations",
"description": "Create custom behavior rules for your home",
@@ -2641,6 +2631,7 @@
"create_zone": "Add Zone",
"add_zone": "Add Zone",
"edit_zone": "Edit Zone",
"edit_home": "Edit Home",
"confirm_delete": "Are you sure you want to delete this zone?",
"can_not_edit": "Unable to edit zone",
"configured_in_yaml": "Zones configured via configuration.yaml cannot be edited via the UI.",
@@ -3167,10 +3158,14 @@
},
"system_health": {
"caption": "System Health",
"cpu_usage": "CPU Usage",
"ram_usage": "RAM Usage",
"cpu_usage": "Process Usage",
"ram_usage": "Memory Usage",
"core_stats": "Core Stats",
"supervisor_stats": "Supervisor Stats"
},
"system_dashboard": {
"confirm_restart": "Are you sure you want to restart Home Assistant?",
"restart_homeassistant": "Restart Home Assistant"
}
},
"lovelace": {
@@ -4150,6 +4145,65 @@
}
},
"adjust_sum": "Adjust sum"
},
"yaml": {
"title": "YAML Configuration",
"section": {
"validation": {
"heading": "Configuration validation",
"introduction": "Validate your configuration if you recently made some changes to it and want to make sure that it is all valid.",
"check_config": "Check configuration",
"valid": "Configuration valid!",
"invalid": "Configuration invalid!"
},
"reloading": {
"heading": "YAML configuration reloading",
"introduction": "Some parts of Home Assistant can reload without requiring a restart. Clicking one of the options below will unload their current YAML configuration and load the new one.",
"reload": "{domain}",
"core": "Location & customizations",
"group": "Groups, group entities, and notify services",
"automation": "Automations",
"script": "Scripts",
"scene": "Scenes",
"person": "People",
"zone": "Zones",
"input_boolean": "Input booleans",
"input_button": "Input buttons",
"input_text": "Input texts",
"input_number": "Input numbers",
"input_datetime": "Input date times",
"input_select": "Input selects",
"template": "Template entities",
"universal": "Universal media player entities",
"rest": "Rest entities and notify services",
"command_line": "Command line entities",
"filter": "Filter entities",
"statistics": "Statistics entities",
"generic": "Generic IP camera entities",
"generic_thermostat": "Generic thermostat entities",
"homekit": "HomeKit",
"min_max": "Min/max entities",
"history_stats": "History stats entities",
"trend": "Trend entities",
"ping": "Ping binary sensor entities",
"filesize": "File size entities",
"telegram": "Telegram notify services",
"smtp": "SMTP notify services",
"mqtt": "Manually configured MQTT entities",
"rpi_gpio": "Raspberry Pi GPIO entities",
"timer": "Timers",
"themes": "Themes"
},
"server_management": {
"heading": "Home Assistant",
"introduction": "Restarting Home Assistant will stop your dashboard and automations. After the reboot, each configuration will be reloaded.",
"restart": "Restart",
"restart_home_assistant": "Restart Home Assistant",
"confirm_restart": "Are you sure you want to restart Home Assistant?",
"stop": "Stop",
"confirm_stop": "Are you sure you want to stop Home Assistant?"
}
}
}
}
},
@@ -4236,7 +4290,8 @@
},
"tips": {
"key_c_hint": "Press 'c' on any page to open the command dialog",
"key_e_hint": "Press 'e' on any page to open the entity search dialog"
"key_e_hint": "Press 'e' on any page to open the entity search dialog",
"key_m_hint": "Press 'm' on any page to get the My Home Assistant link"
}
},
"supervisor": {

View File

@@ -0,0 +1,34 @@
import { assert } from "chai";
import { formatDuration } from "../../../src/common/datetime/duration";
describe("formatDuration", () => {
it("works", () => {
assert.strictEqual(formatDuration("0", "s"), "0");
assert.strictEqual(formatDuration("65", "s"), "1:05");
assert.strictEqual(formatDuration("3665", "s"), "1:01:05");
assert.strictEqual(formatDuration("39665", "s"), "11:01:05");
assert.strictEqual(formatDuration("932093", "s"), "258:54:53");
assert.strictEqual(formatDuration("0", "min"), "0");
assert.strictEqual(formatDuration("65", "min"), "1:05:00");
assert.strictEqual(formatDuration("3665", "min"), "61:05:00");
assert.strictEqual(formatDuration("39665", "min"), "661:05:00");
assert.strictEqual(formatDuration("932093", "min"), "15534:53:00");
assert.strictEqual(formatDuration("12.4", "min"), "12:24");
assert.strictEqual(formatDuration("0", "h"), "0");
assert.strictEqual(formatDuration("65", "h"), "65:00:00");
assert.strictEqual(formatDuration("3665", "h"), "3665:00:00");
assert.strictEqual(formatDuration("39665", "h"), "39665:00:00");
assert.strictEqual(formatDuration("932093", "h"), "932093:00:00");
assert.strictEqual(formatDuration("24.3", "h"), "24:18:00");
assert.strictEqual(formatDuration("24.32423", "h"), "24:19:27");
assert.strictEqual(formatDuration("0", "d"), "0");
assert.strictEqual(formatDuration("65", "d"), "1560:00:00");
assert.strictEqual(formatDuration("3665", "d"), "87960:00:00");
assert.strictEqual(formatDuration("39665", "d"), "951960:00:00");
assert.strictEqual(formatDuration("932093", "d"), "22370232:00:00");
});
});

View File

@@ -8,5 +8,6 @@ describe("secondsToDuration", () => {
assert.strictEqual(secondsToDuration(65), "1:05");
assert.strictEqual(secondsToDuration(3665), "1:01:05");
assert.strictEqual(secondsToDuration(39665), "11:01:05");
assert.strictEqual(secondsToDuration(932093), "258:54:53");
});
});

View File

@@ -1,8 +1,7 @@
import { assert } from "chai";
import { assert, expect } from "chai";
import {
fuzzyFilterSort,
fuzzySequentialMatch,
fuzzySortFilterSort,
ScorableTextItem,
} from "../../../src/common/string/filter/sequence-matching";
@@ -11,45 +10,34 @@ describe("fuzzySequentialMatch", () => {
strings: ["automation.ticker", "Stocks"],
};
const createExpectation: (
pattern,
expected
) => {
pattern: string;
expected: string | number | undefined;
} = (pattern, expected) => ({
pattern,
expected,
});
const shouldMatchEntity = [
createExpectation("automation.ticker", 131),
createExpectation("automation.ticke", 121),
createExpectation("automation.", 82),
createExpectation("au", 10),
createExpectation("automationticker", 85),
createExpectation("tion.tick", 8),
createExpectation("ticker", -4),
createExpectation("automation.r", 73),
createExpectation("tick", -8),
createExpectation("aumatick", 9),
createExpectation("aion.tck", 4),
createExpectation("ioticker", -4),
createExpectation("atmto.ikr", -34),
createExpectation("uoaintce", -39),
createExpectation("au.tce", -3),
createExpectation("tomaontkr", -19),
createExpectation("s", 1),
createExpectation("stocks", 42),
createExpectation("sks", -5),
"",
" ",
"automation.ticker",
"stocks",
"automation.ticke",
"automation. ticke",
"automation.",
"automationticker",
"automation.r",
"aumatick",
"tion.tick",
"aion.tck",
"s",
"au.tce",
"au",
"ticker",
"tick",
"ioticker",
"sks",
"tomaontkr",
"atmto.ikr",
"uoaintce",
];
const shouldNotMatchEntity = [
"",
" ",
"abcdefghijklmnopqrstuvwxyz",
"automation.tickerz",
"automation. ticke",
"1",
"noitamotua",
"autostocks",
@@ -57,23 +45,23 @@ describe("fuzzySequentialMatch", () => {
];
describe(`Entity '${item.strings[0]}'`, () => {
for (const expectation of shouldMatchEntity) {
it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => {
const res = fuzzySequentialMatch(expectation.pattern, item);
assert.equal(res, expectation.expected);
for (const filter of shouldMatchEntity) {
it(`Should matches ${filter}`, () => {
const res = fuzzySortFilterSort(filter, [item]);
assert.lengthOf(res, 1);
});
}
for (const badFilter of shouldNotMatchEntity) {
it(`fails to match with '${badFilter}'`, () => {
const res = fuzzySequentialMatch(badFilter, item);
assert.equal(res, undefined);
const res = fuzzySortFilterSort(badFilter, [item]);
assert.lengthOf(res, 0);
});
}
});
});
describe("fuzzyFilterSort", () => {
describe("fuzzyFilterSort original tests", () => {
const filter = "ticker";
const automationTicker = {
strings: ["automation.ticker", "Stocks"],
@@ -105,14 +93,137 @@ describe("fuzzyFilterSort", () => {
it(`filters and sorts correctly`, () => {
const expectedItemsAfterFilter = [
{ ...ticker, score: 44 },
{ ...sensorTicker, score: 1 },
{ ...automationTicker, score: -4 },
{ ...timerCheckRouter, score: -8 },
{ ...ticker, score: 0 },
{ ...sensorTicker, score: -14 },
{ ...automationTicker, score: -22 },
{ ...timerCheckRouter, score: -32012 },
];
const res = fuzzyFilterSort(filter, itemsBeforeFilter);
const res = fuzzySortFilterSort(filter, itemsBeforeFilter);
assert.deepEqual(res, expectedItemsAfterFilter);
});
});
describe("Fuzzy filter new tests", () => {
const testEntities = [
{
id: "binary_sensor.garage_door_opened",
name: "Garage Door Opened (Sensor, Binary)",
},
{
id: "sensor.garage_door_status",
name: "Garage Door Opened (Sensor)",
},
{
id: "sensor.temperature_living_room",
name: "[Living room] temperature",
},
{
id: "sensor.temperature_parents_bedroom",
name: "[Parents bedroom] temperature",
},
{
id: "sensor.temperature_children_bedroom",
name: "[Children bedroom] temperature",
},
];
function testEntitySearch(
searchInput: string | null,
expectedResults: string[]
) {
const sortableEntities = testEntities.map((entity) => ({
strings: [entity.id, entity.name],
entity: entity,
}));
const sortedEntities = fuzzySortFilterSort(
searchInput || "",
sortableEntities
);
// console.log(sortedEntities);
expect(sortedEntities.map((it) => it.entity.id)).to.have.ordered.members(
expectedResults
);
}
it(`test empty or null query`, () => {
testEntitySearch(
"",
testEntities.map((it) => it.id)
);
testEntitySearch(
null,
testEntities.map((it) => it.id)
);
});
it(`test single word search`, () => {
testEntitySearch("bedroom", [
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test no result`, () => {
testEntitySearch("does not exist", []);
testEntitySearch("betroom", []);
});
it(`test single word search with typo`, () => {
testEntitySearch("bedorom", [
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test multi word search`, () => {
testEntitySearch("bedroom children", [
"sensor.temperature_children_bedroom",
]);
});
it(`test partial word search`, () => {
testEntitySearch("room", [
"sensor.temperature_living_room",
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test mixed cased word search`, () => {
testEntitySearch("garage binary", ["binary_sensor.garage_door_opened"]);
});
it(`test mixed id and name search`, () => {
testEntitySearch("status opened", ["sensor.garage_door_status"]);
});
it(`test special chars in query`, () => {
testEntitySearch("sensor.temperature", [
"sensor.temperature_living_room",
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
testEntitySearch("sensor.temperature parents", [
"sensor.temperature_parents_bedroom",
]);
testEntitySearch("parents_Bedroom", ["sensor.temperature_parents_bedroom"]);
});
it(`test search in name`, () => {
testEntitySearch("Binary)", ["binary_sensor.garage_door_opened"]);
testEntitySearch("Binary)NotExists", []);
});
it(`test regex special chars`, () => {
// Should return an empty result, but no error
testEntitySearch("\\{}()*+?.,[])", []);
testEntitySearch("[Children bedroom]", [
"sensor.temperature_children_bedroom",
]);
});
});

View File

@@ -8433,6 +8433,13 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"fuzzysort@npm:^1.2.1":
version: 1.2.1
resolution: "fuzzysort@npm:1.2.1"
checksum: 74dad902a0aef6c3237d5ae5330aacca23d408f0e07125fcc39b57561b4c29da512fbf3826c3f3918da89f132f5b393cf5d56b3217282ecfb80a90124bdf03d1
languageName: node
linkType: hard
"gauge@npm:~2.7.3":
version: 2.7.4
resolution: "gauge@npm:2.7.4"
@@ -9119,6 +9126,7 @@ fsevents@^1.2.7:
fancy-log: ^1.3.3
fs-extra: ^7.0.1
fuse.js: ^6.0.0
fuzzysort: ^1.2.1
glob: ^7.2.0
google-timezones-json: ^1.0.2
gulp: ^4.0.2