mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-01 03:45:51 +00:00
Compare commits
39 Commits
20260729.2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e6df5fd159 | |||
| 0da3ec1151 | |||
| 46fdf9c91b | |||
| 80a026d9bf | |||
| dc5699fec3 | |||
| 3a1d330a2f | |||
| c8a29ae2f2 | |||
| b1ccb6355d | |||
| 5f2086765a | |||
| 02c51a2789 | |||
| 706421f3c5 | |||
| cbc2e34638 | |||
| 0bc0b08978 | |||
| 48394db843 | |||
| 83dac08059 | |||
| 3c6b70ded8 | |||
| 1e414f31dc | |||
| d013051f81 | |||
| eb2efbb669 | |||
| 7e4d745b21 | |||
| d01f7d98a5 | |||
| 6df6b556bd | |||
| f5c6420fbc | |||
| 71ec76185f | |||
| f08ab2f331 | |||
| 77b0b9b5b8 | |||
| 4fcc1adf3a | |||
| 9699851c26 | |||
| a318593c9f | |||
| 749dd180ae | |||
| 247780e4c8 | |||
| 7991090313 | |||
| 2efa0bd86d | |||
| 0fad4f0097 | |||
| 2b90c9a628 | |||
| f3848df19d | |||
| e2a91c358d | |||
| 8793cb58b9 | |||
| 2442bf662a |
@@ -20,6 +20,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn build # Full production build
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
@@ -28,6 +29,26 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Production Builds
|
||||
|
||||
Production builds support foreground and managed background execution:
|
||||
|
||||
```bash
|
||||
yarn build # Full foreground build
|
||||
yarn build --background # Full managed background build
|
||||
yarn build --modern # Modern frontend_latest bundle only
|
||||
yarn build --modern --background # Modern managed background build
|
||||
yarn build --status
|
||||
yarn build --logs [--follow]
|
||||
yarn build --stop
|
||||
```
|
||||
|
||||
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
|
||||
|
||||
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
|
||||
|
||||
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
@@ -41,7 +62,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
@@ -59,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
|
||||
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `script/build_frontend`
|
||||
- Production build: `yarn build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
## Frontend development
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Manage a Home Assistant frontend production build with an agent-friendly
|
||||
// interface, matching build-scripts/dev-server.mjs.
|
||||
//
|
||||
// node build-scripts/build-manager.mjs [--modern] [mode]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, print the pid, then exit and leave it
|
||||
// running.
|
||||
// --status Report whether a managed build is running.
|
||||
// --stop Stop a managed build.
|
||||
// --logs [--follow] Print (or follow) the background build log.
|
||||
//
|
||||
// --modern Build only the modern frontend_latest bundle.
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
offerToStopProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
runCli,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateDetachedProcess,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
buildCacheDir,
|
||||
describeOutputOwner,
|
||||
workflowLockEnv,
|
||||
workflowLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const stateDir = path.join(buildCacheDir, "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = workflowLockFile;
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
"Usage: node build-scripts/build-manager.mjs [--modern] " +
|
||||
"[--background | --status | --stop | --logs [--follow]]\n"
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
modes: [],
|
||||
follow: false,
|
||||
modern: false,
|
||||
unknown: [],
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else if (arg === "--modern") {
|
||||
args.modern = true;
|
||||
} else if (arg === "--follow") {
|
||||
args.follow = true;
|
||||
} else {
|
||||
args.unknown.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const hints = () =>
|
||||
" Stop: yarn build --stop\n" +
|
||||
" Status: yarn build --status\n" +
|
||||
" Logs: yarn build --logs\n";
|
||||
|
||||
const devCommand = (suite) => {
|
||||
switch (suite) {
|
||||
case "app-serve":
|
||||
return "dev:serve";
|
||||
case "demo":
|
||||
return "dev:demo";
|
||||
case "gallery":
|
||||
return "dev:gallery";
|
||||
case "e2e-app":
|
||||
return "test:e2e:app:dev";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
};
|
||||
|
||||
const readBuild = () => readProcessRecord(lockFile);
|
||||
|
||||
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
|
||||
|
||||
const stopCommandFor = (owner) =>
|
||||
owner?.kind === "build"
|
||||
? "yarn build --stop"
|
||||
: owner?.kind === "dev"
|
||||
? `yarn ${devCommand(owner.suite)} --stop`
|
||||
: undefined;
|
||||
|
||||
const acquireBuild = async (modern, foreground) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
foreground,
|
||||
kind: "build",
|
||||
modern,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(lockFile, record);
|
||||
if (result.acquired) {
|
||||
return { token };
|
||||
}
|
||||
reportExisting(result.existing);
|
||||
return (await offerToStopProcessRecord({
|
||||
file: lockFile,
|
||||
owner: result.existing,
|
||||
ownerDescription: describeOutputOwner(result.existing),
|
||||
stopCommand: stopCommandFor(result.existing),
|
||||
}))
|
||||
? acquireBuild(modern, foreground)
|
||||
: { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateBuild = (token, child, processGroup) => {
|
||||
const existing = readBuild();
|
||||
if (existing?.token !== token) {
|
||||
throw Error("Frontend build lock ownership was lost during startup.");
|
||||
}
|
||||
writeProcessRecord(lockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup,
|
||||
starting: false,
|
||||
});
|
||||
};
|
||||
|
||||
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
|
||||
|
||||
const reportExisting = (existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the build and development workflow` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "dev") {
|
||||
const command = devCommand(existing.suite);
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
` Stop: yarn ${command} --stop\n` +
|
||||
` Status: yarn ${command} --status\n` +
|
||||
` Logs: yarn ${command} --logs\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
|
||||
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
|
||||
);
|
||||
};
|
||||
|
||||
const runForeground = async (modern) => {
|
||||
const lock = await acquireBuild(modern, true);
|
||||
if (!lock.token) {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
} finally {
|
||||
releaseBuild(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackground = async (modern) => {
|
||||
const lock = await acquireBuild(modern, false);
|
||||
if (!lock.token) {
|
||||
return 1;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
process.stdout.write(
|
||||
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
|
||||
hints()
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatus = () => {
|
||||
const existing = readBuild();
|
||||
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
|
||||
);
|
||||
} else {
|
||||
if (existing?.kind === "build") {
|
||||
releaseBuild(existing.token);
|
||||
}
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStop = async () => {
|
||||
let existing = readBuild();
|
||||
if (existing?.kind !== "build") {
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readBuild();
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readBuild();
|
||||
}
|
||||
if (
|
||||
!existing ||
|
||||
existing.kind !== "build" ||
|
||||
!isProcessRecordAlive(existing)
|
||||
) {
|
||||
if (existing) releaseBuild(existing.token);
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
!(await terminateProcess({
|
||||
pid: existing.pid,
|
||||
processGroup: existing.processGroup,
|
||||
isStopped: () => !isProcessRecordAlive(existing),
|
||||
}))
|
||||
) {
|
||||
process.stderr.write(
|
||||
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
releaseBuild(existing.token);
|
||||
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runLogs = (follow) =>
|
||||
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.unknown.length) {
|
||||
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
const handlers = {
|
||||
foreground: () => runForeground(args.modern),
|
||||
background: () => runBackground(args.modern),
|
||||
status: runStatus,
|
||||
stop: runStop,
|
||||
logs: () => runLogs(args.follow),
|
||||
};
|
||||
return handlers[args.mode]();
|
||||
};
|
||||
|
||||
runCli(main);
|
||||
+478
-395
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import env from "../env.cjs";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
@@ -17,6 +18,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
@@ -36,6 +38,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
@@ -51,6 +54,30 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-app-modern",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app-modern"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app-modern",
|
||||
gulp.parallel(
|
||||
"gen-pages-app-prod-modern",
|
||||
"gen-service-worker-app-prod-modern"
|
||||
),
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-app",
|
||||
gulp.series(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -13,6 +14,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-demo"),
|
||||
"clean-demo",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -32,6 +34,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-demo"),
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -12,6 +13,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -31,6 +33,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
|
||||
@@ -58,6 +58,12 @@ const getCommonTemplateVars = () => {
|
||||
return {
|
||||
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
|
||||
hassUrl: process.env.HASS_URL || "",
|
||||
// Single source for the stale-build recovery patterns, shared with the
|
||||
// bundled src/util/recover-stale-build.ts and injected into the inline
|
||||
// boot guard (_bootstrap_recovery.html.template).
|
||||
staleBuildPatterns: fs.readJsonSync(
|
||||
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -107,6 +113,9 @@ const genPagesDevTask =
|
||||
resolve(inputRoot, inputSub, `${page}.template`),
|
||||
{
|
||||
...commonVars,
|
||||
// Dev entries are unhashed, so the stale-index recovery guard has
|
||||
// nothing to key off and rebuild churn could cause spurious reloads.
|
||||
useCacheRecovery: false,
|
||||
latestEntryJS: entries.map(
|
||||
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
|
||||
),
|
||||
@@ -146,10 +155,15 @@ const genPagesProdTask =
|
||||
resolve(inputRoot, inputSub, `${page}.template`),
|
||||
{
|
||||
...commonVars,
|
||||
// Recover from a stale index.html that pins deleted hashed entry
|
||||
// bundles (see _bootstrap_recovery.html.template).
|
||||
useCacheRecovery: true,
|
||||
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
|
||||
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
|
||||
es5EntryJS: outputES5
|
||||
? entries.map((entry) => es5Manifest[`${entry}.js`])
|
||||
: [],
|
||||
latestCustomPanelJS: latestManifest["custom-panel.js"],
|
||||
es5CustomPanelJS: es5Manifest["custom-panel.js"],
|
||||
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
|
||||
}
|
||||
);
|
||||
minifiedHTML.push(
|
||||
@@ -184,6 +198,17 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-app-prod-modern",
|
||||
genPagesProdTask(
|
||||
APP_PAGE_ENTRIES,
|
||||
paths.root_dir,
|
||||
paths.app_output_root,
|
||||
paths.app_output_latest,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
const CAST_PAGE_ENTRIES = {
|
||||
"faq.html": ["launcher"],
|
||||
"index.html": ["launcher"],
|
||||
|
||||
@@ -4,6 +4,7 @@ import gulp from "gulp";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
@@ -164,6 +165,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -195,6 +197,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
|
||||
@@ -108,7 +108,7 @@ const runDevServer = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
const doneHandler = () => (err, stats) => {
|
||||
if (err) {
|
||||
log.error(err.stack || err);
|
||||
if (err.details) {
|
||||
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
|
||||
}
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
const prodBuild = (conf) =>
|
||||
new Promise((resolve) => {
|
||||
new Promise((resolve, reject) => {
|
||||
rspack(
|
||||
conf,
|
||||
// Resolve promise when done. Because we pass a callback, rspack closes itself
|
||||
doneHandler(resolve)
|
||||
(err, stats) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (stats.hasErrors()) {
|
||||
reject(Error(stats.toString("errors-only")));
|
||||
} else {
|
||||
if (stats.hasWarnings()) {
|
||||
console.log(stats.toString("minimal"));
|
||||
}
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +168,17 @@ gulp.task("rspack-prod-app", () =>
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-app-modern", () =>
|
||||
prodBuild(
|
||||
createAppConfig({
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
latestBuild: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-demo", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
|
||||
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
const genServiceWorker = (builds) =>
|
||||
Promise.all(
|
||||
Object.entries(SW_MAP).map(async ([outPath, build]) => {
|
||||
builds.map(async ([outPath, build]) => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(outPath, "manifest.json"), "utf-8")
|
||||
);
|
||||
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
|
||||
await symlink(basename(swDest), swOld);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
genServiceWorker(Object.entries(SW_MAP))
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod-modern", () =>
|
||||
genServiceWorker([[paths.app_output_latest, "modern"]])
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
export const LIFECYCLE_MODE_FLAGS = new Map([
|
||||
["--background", "background"],
|
||||
["--status", "status"],
|
||||
["--stop", "stop"],
|
||||
["--logs", "logs"],
|
||||
]);
|
||||
|
||||
const AGENT_PROVIDERS = [
|
||||
{
|
||||
id: "opencode",
|
||||
env: [
|
||||
"OPENCODE",
|
||||
"OPENCODE_BIN_PATH",
|
||||
"OPENCODE_SERVER",
|
||||
"OPENCODE_APP_INFO",
|
||||
"OPENCODE_MODES",
|
||||
],
|
||||
processes: ["opencode"],
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
env: ["CLAUDECODE"],
|
||||
processes: ["claude"],
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
env: ["CURSOR_TRACE_ID"],
|
||||
processes: [],
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
matchesEnv: (env) =>
|
||||
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
|
||||
processes: [],
|
||||
},
|
||||
{
|
||||
id: "generic",
|
||||
env: ["AGENT", "AI_AGENT"],
|
||||
processes: [],
|
||||
},
|
||||
];
|
||||
const MAX_ANCESTRY_HOPS = 24;
|
||||
|
||||
const readProcessParent = (pid) => {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
const commandStart = stat.indexOf("(");
|
||||
if (commandStart === -1 || commandEnd === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const parentPid = Number.parseInt(
|
||||
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
|
||||
10
|
||||
);
|
||||
return Number.isFinite(parentPid)
|
||||
? {
|
||||
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
|
||||
parentPid,
|
||||
}
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
|
||||
if (env.HA_CODING_AGENT === "0") {
|
||||
return undefined;
|
||||
}
|
||||
const envMatch = AGENT_PROVIDERS.find(
|
||||
(provider) =>
|
||||
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
|
||||
);
|
||||
if (envMatch) {
|
||||
return envMatch.id;
|
||||
}
|
||||
if (env.HA_CODING_AGENT === "1") {
|
||||
return "unknown";
|
||||
}
|
||||
let currentPid = pid;
|
||||
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
|
||||
const processInfo = readProcessParent(currentPid);
|
||||
if (!processInfo) {
|
||||
return undefined;
|
||||
}
|
||||
const processMatch = AGENT_PROVIDERS.find((provider) =>
|
||||
provider.processes.some((name) => processInfo.command.includes(name))
|
||||
);
|
||||
if (processMatch) {
|
||||
return processMatch.id;
|
||||
}
|
||||
if (processInfo.parentPid <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
currentPid = processInfo.parentPid;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const canPromptForConflict = () =>
|
||||
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
|
||||
|
||||
const formatCommand = (command) =>
|
||||
!("NO_COLOR" in process.env)
|
||||
? `\u001b[1;36m${command}\u001b[0m`
|
||||
: `\`${command}\``;
|
||||
|
||||
const confirmStopConflict = async (ownerDescription, stopCommand) => {
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000);
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
|
||||
);
|
||||
const answer = await readline.question("Stop it and continue? [y/N] ", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return ["y", "yes"].includes(answer.trim().toLowerCase());
|
||||
} catch (err) {
|
||||
if (err?.name !== "AbortError") {
|
||||
throw err;
|
||||
}
|
||||
process.stderr.write("\n");
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
readline.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const poll = async () => {
|
||||
if (await predicate()) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
return poll();
|
||||
};
|
||||
return poll();
|
||||
};
|
||||
|
||||
export const isProcessAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
export const processStartTime = (pid) => {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
||||
} catch {
|
||||
try {
|
||||
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isProcessRecordAlive = ({ pid, startTime }) =>
|
||||
Boolean(startTime) && processStartTime(pid) === startTime;
|
||||
|
||||
export const readProcessRecord = (file) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return data && Number.isInteger(data.pid) ? data : undefined;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err instanceof SyntaxError) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, JSON.stringify(data));
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
removeFileIfExists(temporary);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProcessRecord = (file) => {
|
||||
removeFileIfExists(file);
|
||||
};
|
||||
|
||||
export const acquireProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const fd = fs.openSync(file, "wx");
|
||||
try {
|
||||
fs.writeFileSync(fd, JSON.stringify(data));
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return { acquired: true };
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const existing = readProcessRecord(file);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
return { acquired: false, existing };
|
||||
}
|
||||
if (!existing && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const current = readProcessRecord(file);
|
||||
if (
|
||||
current &&
|
||||
(current.token !== existing?.token || isProcessRecordAlive(current))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
removeProcessRecord(file);
|
||||
return true;
|
||||
});
|
||||
if (!removed.acquired || !removed.value) {
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
};
|
||||
|
||||
export const releaseProcessRecord = (file, token, onRelease) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const existing = readProcessRecord(file);
|
||||
if (!existing || existing.token === token) {
|
||||
onRelease?.();
|
||||
if (existing) {
|
||||
removeProcessRecord(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeFileIfExists = (file) => {
|
||||
try {
|
||||
fs.rmSync(file);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRecentFile = (file) => {
|
||||
try {
|
||||
return Date.now() - fs.statSync(file).mtimeMs < 5000;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const withExclusiveFileLockSync = (file, operation) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx");
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const owner = readProcessRecord(file);
|
||||
if (owner && isProcessRecordAlive(owner)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
if (!owner && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
removeFileIfExists(file);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
fd,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
})
|
||||
);
|
||||
return { acquired: true, value: operation() };
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} finally {
|
||||
removeFileIfExists(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false };
|
||||
};
|
||||
|
||||
export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
const forwardSigint = () =>
|
||||
signalProcess(child.pid, "SIGINT", processGroup);
|
||||
const forwardSigterm = () =>
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
const forwardSighup = () =>
|
||||
signalProcess(child.pid, "SIGHUP", processGroup);
|
||||
const removeSignalHandlers = () => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
process.off("SIGHUP", forwardSighup);
|
||||
};
|
||||
if (processGroup) {
|
||||
process.on("SIGINT", forwardSigint);
|
||||
process.on("SIGTERM", forwardSigterm);
|
||||
process.on("SIGHUP", forwardSighup);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
try {
|
||||
onSpawn?.(child);
|
||||
} catch (err) {
|
||||
settled = true;
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
removeSignalHandlers();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve(child);
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const signalProcess = (pid, signal, processGroup) => {
|
||||
if (processGroup) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the process itself.
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
export const terminateProcess = async ({
|
||||
pid,
|
||||
isStopped,
|
||||
processGroup = true,
|
||||
graceMs = 10_000,
|
||||
}) => {
|
||||
signalProcess(pid, "SIGTERM", processGroup);
|
||||
if (await waitFor(isStopped, 300, graceMs)) {
|
||||
return true;
|
||||
}
|
||||
signalProcess(pid, "SIGKILL", processGroup);
|
||||
await sleep(300);
|
||||
return await isStopped();
|
||||
};
|
||||
|
||||
const sameProcessRecord = (current, expected) =>
|
||||
Boolean(expected?.token) && current?.token === expected.token;
|
||||
|
||||
const stopProcessRecord = async (file, owner) => {
|
||||
let current = readProcessRecord(file);
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
if (!sameProcessRecord(current, owner)) {
|
||||
return false;
|
||||
}
|
||||
if (!isProcessRecordAlive(current)) {
|
||||
releaseProcessRecord(file, current.token);
|
||||
return true;
|
||||
}
|
||||
const stopped = await terminateProcess({
|
||||
pid: current.pid,
|
||||
processGroup: current.processGroup ?? false,
|
||||
isStopped: () => {
|
||||
const latest = readProcessRecord(file);
|
||||
return (
|
||||
!sameProcessRecord(latest, owner) ||
|
||||
!latest ||
|
||||
!isProcessRecordAlive(latest)
|
||||
);
|
||||
},
|
||||
});
|
||||
current = readProcessRecord(file);
|
||||
if (current && !sameProcessRecord(current, owner)) {
|
||||
return false;
|
||||
}
|
||||
if (!stopped && current) {
|
||||
return false;
|
||||
}
|
||||
releaseProcessRecord(file, owner.token);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const offerToStopProcessRecord = async ({
|
||||
file,
|
||||
owner,
|
||||
ownerDescription,
|
||||
stopCommand,
|
||||
}) => {
|
||||
if (
|
||||
!owner ||
|
||||
!stopCommand ||
|
||||
!canPromptForConflict() ||
|
||||
!(await confirmStopConflict(ownerDescription, stopCommand))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return stopProcessRecord(file, owner);
|
||||
};
|
||||
|
||||
export const terminateDetachedProcess = (child) =>
|
||||
terminateProcess({
|
||||
pid: child.pid,
|
||||
processGroup: true,
|
||||
isStopped: () => !isProcessAlive(child.pid),
|
||||
});
|
||||
|
||||
export const outputLog = (logFile, follow, missingMessage) => {
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(missingMessage);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
let settled = false;
|
||||
tail.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
tail.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const runCli = (main) => {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
acquireProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
export const buildCacheDir =
|
||||
process.env.HA_BUILD_CACHE_DIR ??
|
||||
path.join(repoRoot, "node_modules", ".cache");
|
||||
|
||||
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
|
||||
const signalCleanups = new Set();
|
||||
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
|
||||
const handleSignal = (signal) => {
|
||||
for (const cleanup of signalCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
for (const cleanupSignal of cleanupSignals) {
|
||||
process.off(cleanupSignal, handleSignal);
|
||||
}
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
|
||||
const registerSignalCleanup = (cleanup) => {
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.on(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
signalCleanups.add(cleanup);
|
||||
};
|
||||
|
||||
const unregisterSignalCleanup = (cleanup) => {
|
||||
signalCleanups.delete(cleanup);
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.off(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
|
||||
|
||||
export const workflowLockEnv = (token) => ({
|
||||
...process.env,
|
||||
[WORKFLOW_LOCK_TOKEN_ENV]: token,
|
||||
});
|
||||
|
||||
export const describeOutputOwner = (owner) => {
|
||||
if (owner?.kind === "build") {
|
||||
return `frontend ${owner.modern ? "modern " : ""}build`;
|
||||
}
|
||||
if (owner?.kind === "dev") {
|
||||
return `dev server (${owner.suite ?? "app"})`;
|
||||
}
|
||||
return owner?.target ? `Gulp task ${owner.target}` : "another process";
|
||||
};
|
||||
|
||||
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
|
||||
let exitToken;
|
||||
|
||||
const cleanup = () => {
|
||||
if (!exitToken) {
|
||||
return;
|
||||
}
|
||||
releaseProcessRecord(file, exitToken);
|
||||
exitToken = undefined;
|
||||
process.off("exit", cleanup);
|
||||
unregisterSignalCleanup(cleanup);
|
||||
};
|
||||
const acquire = async () => {
|
||||
const inheritedToken = process.env[inheritedTokenEnv];
|
||||
if (inheritedToken) {
|
||||
if (readProcessRecord(file)?.token !== inheritedToken) {
|
||||
throw Error(
|
||||
`${label} lock ownership was lost before ${target} started.`
|
||||
);
|
||||
}
|
||||
exitToken = inheritedToken;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
kind,
|
||||
target,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(file, record);
|
||||
if (!result.acquired) {
|
||||
const pid = result.existing?.pid;
|
||||
throw Error(
|
||||
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
|
||||
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
exitToken = token;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
};
|
||||
|
||||
acquire.displayName = `lock-${label}:${target}`;
|
||||
return acquire;
|
||||
};
|
||||
|
||||
export const createWorkflowLockTask = (target) =>
|
||||
createLockTask({
|
||||
file: workflowLockFile,
|
||||
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
|
||||
kind: "output",
|
||||
label: "build and development workflow",
|
||||
target,
|
||||
});
|
||||
@@ -34,16 +34,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -64,12 +56,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
@@ -104,7 +98,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
+7
-7
@@ -7,7 +7,7 @@
|
||||
"name": "home-assistant-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "script/build_frontend",
|
||||
"build": "node build-scripts/build-manager.mjs",
|
||||
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/view": "6.43.7",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
@@ -152,7 +152,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/core": "2.1.6",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,14 +186,14 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jsdom": "30.0.0",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.2.0",
|
||||
@@ -224,12 +224,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,44 @@ fi
|
||||
echo Core is used from ${coreUrl}
|
||||
|
||||
# build the frontend so it connects to the passed core
|
||||
HASS_URL="$coreUrl" ./script/develop &
|
||||
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
|
||||
develop_pid=$!
|
||||
|
||||
# serve the frontend
|
||||
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
|
||||
serve_pid=$!
|
||||
|
||||
# keep the script running while serving
|
||||
wait
|
||||
stop_children() {
|
||||
trap - EXIT INT TERM HUP
|
||||
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
|
||||
wait "$develop_pid" 2>/dev/null || true
|
||||
wait "$serve_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap stop_children EXIT
|
||||
trap 'stop_children; exit 130' INT
|
||||
trap 'stop_children; exit 143' TERM
|
||||
trap 'stop_children; exit 129' HUP
|
||||
|
||||
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
develop_status=
|
||||
serve_status=
|
||||
if ! kill -0 "$develop_pid" 2>/dev/null; then
|
||||
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
|
||||
fi
|
||||
if ! kill -0 "$serve_pid" 2>/dev/null; then
|
||||
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
|
||||
fi
|
||||
|
||||
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
|
||||
status=$develop_status
|
||||
elif [ -n "$serve_status" ]; then
|
||||
status=$serve_status
|
||||
else
|
||||
status=$develop_status
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { EntityIdPart } from "../../data/entity_id_format";
|
||||
import { slugify } from "../string/slugify";
|
||||
|
||||
export const computeEntityIdFormatExample = (
|
||||
format: EntityIdPart[],
|
||||
examples: Record<EntityIdPart, string>
|
||||
): string => {
|
||||
const parts = format
|
||||
.map((item) => examples[item])
|
||||
.filter(Boolean)
|
||||
.map((part) => slugify(part, "_"))
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.join("_") || "unknown";
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
|
||||
|
||||
const HOME_ASSISTANT_SCHEME = "homeassistant://";
|
||||
|
||||
/**
|
||||
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
|
||||
* Only absolute `http:` and `https:` URLs pass, the same check
|
||||
* `ha-attribute-value` already applies to attribute links.
|
||||
*
|
||||
* Use for every URL that reaches the frontend as data — from an integration
|
||||
* manifest, an add-on, a config flow or an entity attribute — so that a
|
||||
* `javascript:` URI can never become a clickable link.
|
||||
*/
|
||||
export const sanitizeHttpUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return protocol === "http:" || protocol === "https:" ? url : undefined;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
|
||||
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
|
||||
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
|
||||
|
||||
/**
|
||||
* Turns a `homeassistant://` deep link into an in-app path, or returns
|
||||
* `undefined` when it does not point inside the frontend. Rewriting the scheme
|
||||
* on its own is not enough: `homeassistant:///example.com` would become
|
||||
* `//example.com`, which resolves to another origin.
|
||||
*/
|
||||
export const homeAssistantUrlToPath = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url)
|
||||
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Sanitizes a URL that may be either an external link or a `homeassistant://`
|
||||
* deep link, returning something safe to bind to an `href`.
|
||||
*/
|
||||
export const sanitizeLinkUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
|
||||
@@ -118,10 +118,15 @@ export class HaProgressButton extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Fade the content out rather than hiding it, so the button keeps its
|
||||
accessible name while the result icon covers it. */
|
||||
ha-button.result::part(start),
|
||||
ha-button.result::part(end),
|
||||
ha-button.result::part(label),
|
||||
ha-button.result::part(caret),
|
||||
ha-button.result::part(caret) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
ha-button.result::part(spinner) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
|
||||
: ""
|
||||
}
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ??
|
||||
column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
) {
|
||||
if (!this._isColumnVisible(key, column)) {
|
||||
return nothing;
|
||||
}
|
||||
const sorted = key === this.sortColumn;
|
||||
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
(narrow && !column.main && !column.showNarrow) ||
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
!this._isColumnVisible(key, column)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
<div class="secondary">
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
([key2, column2]) =>
|
||||
!column2.hidden &&
|
||||
!column2.main &&
|
||||
!column2.showNarrow &&
|
||||
!(this.columnOrder &&
|
||||
this.columnOrder.includes(key2)
|
||||
? (this.hiddenColumns?.includes(key2) ??
|
||||
column2.defaultHidden)
|
||||
: column2.defaultHidden)
|
||||
)
|
||||
.map(
|
||||
([key2, column2], i) =>
|
||||
html`${
|
||||
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
|
||||
}${
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
}`
|
||||
)}
|
||||
${join(
|
||||
Object.entries(columns)
|
||||
.filter(([key2, column2]) =>
|
||||
this._isSecondaryColumnVisible(key2, column2)
|
||||
)
|
||||
.map(([key2, column2]) =>
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
)
|
||||
.filter(this._hasCellValue),
|
||||
STRINGS_SEPARATOR_DOT
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
column.extraTemplate
|
||||
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
|
||||
if (column.hidden) {
|
||||
return false;
|
||||
}
|
||||
if (!this.columnOrder?.includes(key)) {
|
||||
return !column.defaultHidden;
|
||||
}
|
||||
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
|
||||
}
|
||||
|
||||
private _isSecondaryColumnVisible(
|
||||
key: string,
|
||||
column: DataTableColumnData
|
||||
): boolean {
|
||||
if (column.main || column.showNarrow) {
|
||||
return false;
|
||||
}
|
||||
return this._isColumnVisible(key, column);
|
||||
}
|
||||
|
||||
private _hasCellValue = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null && value !== "" && value !== nothing;
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
|
||||
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-dialog";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-button";
|
||||
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
private async _loadConfigEntries(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
configEntryLookup: Record<string, ConfigEntry> | undefined
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
return {
|
||||
deviceId,
|
||||
name: device ? computeDeviceName(device) : deviceId,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
this.hass.areas,
|
||||
this._configEntryLookup
|
||||
).map((item) => {
|
||||
const supportingText = [
|
||||
item.area,
|
||||
item.domainName,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
return html`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
${
|
||||
item.domain
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span slot="headline">${item.name}</span>
|
||||
${
|
||||
item.secondary || item.isPrimary
|
||||
? html`<span slot="supporting-text">
|
||||
${[
|
||||
item.secondary,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ")}
|
||||
</span>`
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "error-message" }) public errorMessage?: string;
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ attribute: "statistic-types" })
|
||||
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.invalid=${this.invalid}
|
||||
.errorMessage=${this.errorMessage}
|
||||
.label=${this.label}
|
||||
use-top-label
|
||||
.placeholder=${placeholder}
|
||||
|
||||
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button-toolbar";
|
||||
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
|
||||
import type {
|
||||
HaIconButtonToolbar,
|
||||
HaIconButtonToolbarItem,
|
||||
} from "./ha-icon-button-toolbar";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "has-test" })
|
||||
public hasTest = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
|
||||
|
||||
@property({ attribute: false }) public testing = false;
|
||||
|
||||
@property({ type: String }) public placeholder?: string;
|
||||
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
changedProps.has("_canCopy") ||
|
||||
changedProps.has("_canUndo") ||
|
||||
changedProps.has("_canRedo") ||
|
||||
changedProps.has("testing")
|
||||
changedProps.has("testing") ||
|
||||
changedProps.has("toolbarItems")
|
||||
) {
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
this._editorToolbar.items = [
|
||||
...(this.toolbarItems ?? []),
|
||||
...(this.hasTest && !this._isFullscreen
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _renderHelper() {
|
||||
const showError = this.invalid && this.errorMessage;
|
||||
const showHelper = !showError && this.helper;
|
||||
|
||||
if (!showError && !showHelper) {
|
||||
if (!showError && !this.helper) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${showError ? this.errorMessage : this.helper}
|
||||
${
|
||||
showError
|
||||
? html`<span class="error">${this.errorMessage}</span> ${
|
||||
this.helper
|
||||
? html`<span class="helper">${this.helper}</span>`
|
||||
: nothing
|
||||
}`
|
||||
: this.helper
|
||||
}
|
||||
</ha-input-helper-text>`;
|
||||
}
|
||||
|
||||
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
:host([invalid]) ha-input-helper-text {
|
||||
color: var(--mdc-theme-error, var(--error-color, #b00020));
|
||||
}
|
||||
ha-input-helper-text .error,
|
||||
ha-input-helper-text .helper {
|
||||
display: block;
|
||||
}
|
||||
ha-input-helper-text .helper {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
|
||||
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
|
||||
)) ||
|
||||
serviceData?.description;
|
||||
|
||||
const documentationLink =
|
||||
this._manifest?.is_built_in && this._value?.action
|
||||
? documentationUrl(this.hass, `/actions/${this._value.action}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const targetSelector =
|
||||
serviceData && "target" in serviceData
|
||||
? this._targetSelector(
|
||||
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : ""}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html` <a
|
||||
href=${
|
||||
this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-split-panel")
|
||||
export class HaSplitPanel extends SplitPanel {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
SplitPanel.styles,
|
||||
css`
|
||||
:host {
|
||||
--divider-width: var(--ha-split-panel-divider-width, 2px);
|
||||
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
|
||||
--min: var(--ha-split-panel-min, 0);
|
||||
--max: var(--ha-split-panel-max, 100%);
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--divider-color);
|
||||
transition: background-color var(--ha-animation-duration-fast)
|
||||
ease-out;
|
||||
}
|
||||
|
||||
/* Grip affordance so the divider reads as draggable. The divider
|
||||
already centers its children via flexbox, so keep this in flow.
|
||||
Consumers slotting their own divider handle can hide it with
|
||||
--ha-split-panel-grip-display: none. */
|
||||
.divider::before {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: var(--ha-space-8);
|
||||
display: var(--ha-split-panel-grip-display, block);
|
||||
border-radius: var(--ha-border-radius-pill, 9999px);
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
/* In vertical orientation the divider is horizontal, so the grip pill
|
||||
lies flat instead of standing upright. */
|
||||
:host([orientation="vertical"]) .divider::before {
|
||||
width: var(--ha-space-8);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:not([disabled])) .divider:hover {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
:host(:not([disabled])) .divider:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:not([disabled])) .divider:focus-visible {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-split-panel": HaSplitPanel;
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -34,15 +34,18 @@ import {
|
||||
browseMediaPlayer,
|
||||
BROWSER_PLAYER,
|
||||
MediaClassBrowserSettings,
|
||||
searchMediaPlayer,
|
||||
} from "../../data/media-player";
|
||||
import {
|
||||
browseLocalMediaPlayer,
|
||||
isManualMediaSourceContentId,
|
||||
isMediaSourceContentId,
|
||||
MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
searchMedia,
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { panelIsReady } from "../../layouts/panel-ready";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -159,6 +162,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _initialReady = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.updateComplete.then(() => this._attachResizeObserver());
|
||||
@@ -272,6 +277,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
ids: navigateIds,
|
||||
current: this._currentItem,
|
||||
});
|
||||
this._signalInitialReady();
|
||||
} else {
|
||||
if (!currentProm) {
|
||||
currentProm = this._fetchData(
|
||||
@@ -287,6 +293,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
ids: navigateIds,
|
||||
current: item,
|
||||
});
|
||||
this._signalInitialReady();
|
||||
},
|
||||
(err) => {
|
||||
// When we change entity ID, we will first try to see if the new entity is
|
||||
@@ -320,8 +327,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
),
|
||||
code: "entity_not_found",
|
||||
});
|
||||
this._signalInitialReady();
|
||||
} else {
|
||||
this._setError(err);
|
||||
this._signalInitialReady();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -869,13 +878,31 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
const mediaFilterClasses = this._mediaClassFilter.length
|
||||
? this._mediaClassFilter
|
||||
: undefined;
|
||||
// A player's tree can embed media sources, which resolve their own searches;
|
||||
// everything else in it uses integration specific ids only the entity knows.
|
||||
const searchEntityId =
|
||||
this.entityId &&
|
||||
this.entityId !== BROWSER_PLAYER &&
|
||||
!isMediaSourceContentId(navigateId.media_content_id ?? "")
|
||||
? this.entityId
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const { result } = await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
const { result } = searchEntityId
|
||||
? await searchMediaPlayer(
|
||||
this.hass,
|
||||
searchEntityId,
|
||||
searchQuery,
|
||||
navigateId.media_content_id,
|
||||
navigateId.media_content_type,
|
||||
mediaFilterClasses
|
||||
)
|
||||
: await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
// Ignore the response if a newer search started or we navigated away
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
@@ -1125,6 +1152,21 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
fireEvent(this, "close-dialog");
|
||||
}
|
||||
|
||||
private _signalInitialReady(): void {
|
||||
if (this._initialReady) {
|
||||
return;
|
||||
}
|
||||
this._initialReady = true;
|
||||
const root = this.getRootNode();
|
||||
panelIsReady(
|
||||
root instanceof ShadowRoot &&
|
||||
root.host instanceof HTMLElement &&
|
||||
root.host.tagName.startsWith("HA-PANEL-")
|
||||
? root.host
|
||||
: this
|
||||
);
|
||||
}
|
||||
|
||||
private _setError(error: any) {
|
||||
if (!this.dialog) {
|
||||
this._error = error;
|
||||
@@ -1189,8 +1231,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
private _animateHeaderHeight() {
|
||||
let start;
|
||||
const animate = (time) => {
|
||||
let start: number | undefined;
|
||||
const animate = (time: number) => {
|
||||
if (start === undefined) {
|
||||
start = time;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public vertical = false;
|
||||
|
||||
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
|
||||
@property({ type: Boolean, attribute: "fixed-info-height" })
|
||||
public fixedInfoHeight = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public interactive = false;
|
||||
|
||||
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = { vertical: this.vertical };
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
padding: 10px var(--ha-space-2);
|
||||
}
|
||||
.vertical.fixed-info-height {
|
||||
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
|
||||
gap: 2px;
|
||||
--ha-tile-info-gap: 2px;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
--ha-tile-info-primary-min-height: var(--ha-space-8);
|
||||
--ha-tile-info-min-height: var(--ha-space-12);
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -15,6 +15,11 @@ import { customElement, property } from "lit/decorators";
|
||||
*
|
||||
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
|
||||
*
|
||||
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
|
||||
*
|
||||
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
|
||||
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
|
||||
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
|
||||
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
|
||||
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
|
||||
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
|
||||
@@ -39,7 +44,7 @@ export class HaTileInfo extends LitElement {
|
||||
return html`
|
||||
<div class="info">
|
||||
<slot name="primary" class="primary">
|
||||
<span>${this.primary}</span>
|
||||
<span part="primary">${this.primary}</span>
|
||||
</slot>
|
||||
${
|
||||
this.secondaryLoading
|
||||
@@ -59,6 +64,8 @@ export class HaTileInfo extends LitElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
--tile-info-gap: var(--ha-tile-info-gap, 0);
|
||||
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
|
||||
--tile-info-primary-font-size: var(
|
||||
--ha-tile-info-primary-font-size,
|
||||
var(--ha-font-size-m)
|
||||
@@ -71,6 +78,10 @@ export class HaTileInfo extends LitElement {
|
||||
--ha-tile-info-primary-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
--tile-info-primary-min-height: var(
|
||||
--ha-tile-info-primary-min-height,
|
||||
auto
|
||||
);
|
||||
--tile-info-primary-letter-spacing: var(
|
||||
--ha-tile-info-primary-letter-spacing,
|
||||
0.1px
|
||||
@@ -106,28 +117,38 @@ export class HaTileInfo extends LitElement {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--tile-info-gap);
|
||||
min-height: var(--tile-info-min-height);
|
||||
}
|
||||
span,
|
||||
::slotted(*) {
|
||||
.primary span,
|
||||
::slotted([slot="primary"]),
|
||||
.secondary span,
|
||||
::slotted([slot="secondary"]) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-primary-font-size);
|
||||
font-weight: var(--tile-info-primary-font-weight);
|
||||
line-height: var(--tile-info-primary-line-height);
|
||||
letter-spacing: var(--tile-info-primary-letter-spacing);
|
||||
color: var(--tile-info-primary-color);
|
||||
min-height: var(--tile-info-primary-min-height);
|
||||
}
|
||||
.secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-secondary-font-size);
|
||||
font-weight: var(--tile-info-secondary-font-weight);
|
||||
line-height: var(--tile-info-secondary-line-height);
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
|
||||
+27
-5
@@ -1,6 +1,7 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
|
||||
domain: string;
|
||||
name: string;
|
||||
config_flow: boolean;
|
||||
documentation: string;
|
||||
documentation?: string;
|
||||
issue_tracker?: string;
|
||||
dependencies?: string[];
|
||||
after_dependencies?: string[];
|
||||
@@ -78,11 +79,27 @@ export enum LogSeverity {
|
||||
|
||||
export type IntegrationLogPersistance = "none" | "once" | "permanent";
|
||||
|
||||
/**
|
||||
* A custom integration supplies its own manifest, so its URLs are untrusted
|
||||
* input. Strip them here, where manifests enter the frontend, so no consumer can
|
||||
* turn one into a link that runs script.
|
||||
*/
|
||||
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
|
||||
manifest: T
|
||||
): T =>
|
||||
manifest
|
||||
? ({
|
||||
...manifest,
|
||||
documentation: sanitizeHttpUrl(manifest.documentation),
|
||||
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
|
||||
} as T)
|
||||
: manifest;
|
||||
|
||||
export const integrationIssuesUrl = (
|
||||
domain: string,
|
||||
manifest: IntegrationManifest
|
||||
) =>
|
||||
manifest.issue_tracker ||
|
||||
sanitizeHttpUrl(manifest.issue_tracker) ||
|
||||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
|
||||
|
||||
export const domainToName = (
|
||||
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
|
||||
if (integrations) {
|
||||
params.integrations = integrations;
|
||||
}
|
||||
return hass.callWS<IntegrationManifest[]>(params);
|
||||
return hass
|
||||
.callWS<IntegrationManifest[]>(params)
|
||||
.then((manifests) => manifests.map(sanitizeManifest));
|
||||
};
|
||||
|
||||
export const fetchIntegrationManifestsCollection = async (
|
||||
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
});
|
||||
const manifests: DomainManifestLookup = {};
|
||||
for (const manifest of fetched) {
|
||||
manifests[manifest.domain] = manifest;
|
||||
manifests[manifest.domain] = sanitizeManifest(manifest);
|
||||
}
|
||||
setValue(manifests);
|
||||
// One-time fetch — nothing to unsubscribe from
|
||||
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
export const fetchIntegrationManifest = (
|
||||
hass: HomeAssistant,
|
||||
integration: string
|
||||
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
|
||||
) =>
|
||||
hass
|
||||
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
|
||||
.then(sanitizeManifest);
|
||||
|
||||
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
|
||||
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
isStrategy: boolean;
|
||||
initialRenderComplete?: Promise<void>;
|
||||
setConfig(config: LovelaceViewConfig): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
|
||||
media_content_type: mediaContentType,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
searchQuery: string,
|
||||
mediaContentId?: string,
|
||||
mediaContentType?: string,
|
||||
mediaFilterClasses?: string[]
|
||||
): Promise<SearchMediaResult> =>
|
||||
hass.callWS<SearchMediaResult>({
|
||||
type: "media_player/search_media",
|
||||
entity_id: entityId,
|
||||
search_query: searchQuery,
|
||||
// the backend requires these two to be passed together, and JSON
|
||||
// serialization drops them both when the current item is the root
|
||||
media_content_id: mediaContentId,
|
||||
media_content_type: mediaContentType,
|
||||
media_filter_classes: mediaFilterClasses,
|
||||
});
|
||||
|
||||
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
|
||||
let progress = stateObj.attributes.media_position!;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem } from "./media-player";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
export interface ResolvedMediaSource {
|
||||
url: string;
|
||||
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
|
||||
media_content_id: mediaContentId,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMedia = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId: string | undefined,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HomeAssistantApi } from "../types";
|
||||
|
||||
export const fetchSlug = (
|
||||
api: HomeAssistantApi,
|
||||
text: string
|
||||
): Promise<{ slug: string }> =>
|
||||
api.callWS<{ slug: string }>({
|
||||
type: "slugify",
|
||||
text,
|
||||
});
|
||||
@@ -314,6 +314,11 @@ export interface ZWaveJSSetConfigParamResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface ZWaveJSDataCollectionStatus {
|
||||
enabled: boolean;
|
||||
opted_in: boolean;
|
||||
@@ -732,6 +737,16 @@ export const fetchZwaveNodeConfigParameters = (
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const subscribeZwaveNodeConfigParameterUpdates = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage(callback, {
|
||||
type: "zwave_js/subscribe_config_parameter_updates",
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const setZwaveNodeConfigParameter = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
this._params.manifest?.is_built_in) ||
|
||||
!!this._params.manifest?.documentation;
|
||||
|
||||
const documentationLink = this._params.manifest?.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest.domain}`
|
||||
)
|
||||
: this._params.manifest?.documentation;
|
||||
|
||||
const dialogTitle = this._getDialogTitle();
|
||||
const dialogSubtitle = this._getDialogSubtitle();
|
||||
|
||||
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDocumentationLink && !this._loading && this._step
|
||||
showDocumentationLink &&
|
||||
documentationLink &&
|
||||
!this._loading &&
|
||||
this._step
|
||||
? html`
|
||||
<a
|
||||
slot="headerActionItems"
|
||||
class="help"
|
||||
href=${
|
||||
this._params.manifest!.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest!.domain}`
|
||||
)
|
||||
: this._params.manifest!.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "external":
|
||||
case "external": {
|
||||
const externalUrl = sanitizeHttpUrl(this._step.url);
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${this._step.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
externalUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
}
|
||||
case "create_entry": {
|
||||
const devices = this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
window.open(this.step.url);
|
||||
// Opened without user interaction, so only ever follow an http(s) URL
|
||||
const url = sanitizeHttpUrl(this.step.url);
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -12,11 +12,13 @@ import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
|
||||
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
|
||||
import type { HaForm } from "../../components/ha-form/ha-form";
|
||||
|
||||
interface StackEntry {
|
||||
params: FormDialogParams;
|
||||
data: FormDialogData;
|
||||
nestedField?: string;
|
||||
error?: Record<string, string>;
|
||||
}
|
||||
|
||||
@customElement("dialog-form")
|
||||
@@ -36,10 +38,15 @@ export class DialogForm
|
||||
|
||||
@state() private _stack: StackEntry[] = [];
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
public async showDialog(params: FormDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._data = params.data || {};
|
||||
this._open = true;
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
}
|
||||
|
||||
@@ -59,11 +66,17 @@ export class DialogForm
|
||||
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
|
||||
this._stack = [
|
||||
...this._stack,
|
||||
{ params: this._params!, data: this._data, nestedField: origin?.name },
|
||||
{
|
||||
params: this._params!,
|
||||
data: this._data,
|
||||
nestedField: origin?.name,
|
||||
error: this._error,
|
||||
},
|
||||
];
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
this._params = nested;
|
||||
this._data = nested?.data || {};
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
};
|
||||
|
||||
@@ -75,6 +88,7 @@ export class DialogForm
|
||||
this._stack = this._stack.slice(0, -1);
|
||||
this._params = prev.params;
|
||||
this._data = prev.data;
|
||||
this._error = prev.error;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
return prev.nestedField;
|
||||
}
|
||||
@@ -88,10 +102,18 @@ export class DialogForm
|
||||
this._params = undefined;
|
||||
this._data = {};
|
||||
this._open = false;
|
||||
this._error = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _submit(): void {
|
||||
if (this._form && !this._form.reportValidity()) {
|
||||
this._error = {
|
||||
base: this.hass!.localize("ui.components.form.validation_failed"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this._closeState = "submitted";
|
||||
const submit = this._params?.submit;
|
||||
const data = this._data;
|
||||
@@ -119,6 +141,7 @@ export class DialogForm
|
||||
: data;
|
||||
|
||||
this._data = deepClone({ ...this._data, [nestedField]: newValue });
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
@@ -136,6 +159,7 @@ export class DialogForm
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._data = ev.detail.value;
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
@@ -158,6 +182,7 @@ export class DialogForm
|
||||
.computeHelper=${this._params.computeHelper}
|
||||
.data=${this._data}
|
||||
.schema=${this._params.schema}
|
||||
.error=${this._error}
|
||||
@value-changed=${this._valueChanged}
|
||||
@show-dialog=${this._handleNestedShowDialog}
|
||||
>
|
||||
|
||||
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
// This dialog must only be dismissed through its own footer buttons
|
||||
// (confirm / revert / close). This flag is flipped right before such a
|
||||
// button closes the dialog, so `closeDialog()` can refuse every other
|
||||
// close request (navigation, back button, `closeAllDialogs`, …).
|
||||
private _resolved = false;
|
||||
|
||||
public showDialog(params: HttpPendingConfigDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._resolved = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
// Refuse programmatic close requests (navigation, back button,
|
||||
// `closeAllDialogs`) so a pending HTTP config is never left silently
|
||||
// unresolved. The dialog only closes once the user picks a footer action.
|
||||
if (!this._resolved) {
|
||||
return false;
|
||||
}
|
||||
this._open = false;
|
||||
this._stopCountdown();
|
||||
return true;
|
||||
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
|
||||
}
|
||||
|
||||
private _notifyResolved(): void {
|
||||
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
|
||||
// it; every footer action calls this before setting `_open = false`.
|
||||
this._resolved = true;
|
||||
this._params?.onResolved?.();
|
||||
// The form on Settings > System > Network may be mounted and showing
|
||||
// stale state; let it know to refetch.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
|
||||
</div>
|
||||
|
||||
${
|
||||
this.stateObj.attributes.release_url
|
||||
releaseUrl
|
||||
? html`<div class="row">
|
||||
<div class="key">
|
||||
<a
|
||||
href=${this.stateObj.attributes.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a href=${releaseUrl} target="_blank" rel="noreferrer">
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.release_announcement"
|
||||
)}
|
||||
|
||||
+12
-1
@@ -37,15 +37,26 @@ declare global {
|
||||
}
|
||||
|
||||
const clearUrlParams = () => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
let changed = false;
|
||||
// Clear auth data from url if we have been able to establish a connection
|
||||
if (location.search.includes("auth_callback=1")) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
// https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/auth.ts
|
||||
// Remove all data from QueryCallbackData type
|
||||
searchParams.delete("auth_callback");
|
||||
searchParams.delete("code");
|
||||
searchParams.delete("state");
|
||||
searchParams.delete("storeToken");
|
||||
changed = true;
|
||||
}
|
||||
// Remove the cache-busting param added by the stale-index recovery guard in
|
||||
// index.html once we have booted successfully, so it doesn't linger in the
|
||||
// URL (and stops acting as the guard's one-shot loop marker).
|
||||
if (searchParams.has("ha_cache_bust")) {
|
||||
searchParams.delete("ha_cache_bust");
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
const search = searchParams.toString();
|
||||
history.replaceState(
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script>
|
||||
/*
|
||||
* Boot-time recovery from a stale index.html.
|
||||
*
|
||||
* index.html pins the content-hashed entry bundles (core.<hash>.js /
|
||||
* app.<hash>.js). After a frontend release those files are deleted from
|
||||
* disk, so a cached (stale) index.html imports URLs that now 404 and the
|
||||
* app never boots - the launch screen stays up forever. No bundled JS can
|
||||
* recover this, because the bundle itself failed to load, so this guard has
|
||||
* to live inline in the HTML document.
|
||||
*
|
||||
* On a failed entry load we navigate once to the same URL with a
|
||||
* cache-busting query param (and, on https, after dropping the service
|
||||
* worker + its caches) so the browser fetches a fresh index.html that points
|
||||
* at the current hashes. The param doubles as a one-shot loop guard: if the
|
||||
* freshly fetched index still fails we stop and show a message instead of
|
||||
* reloading forever. core.ts strips the param again after a successful boot.
|
||||
*/
|
||||
(function () {
|
||||
var BUST_PARAM = "ha_cache_bust";
|
||||
// Only production entry bundles carry a content hash. Requiring the hash
|
||||
// keeps the guard inert in development (unhashed core.js / app.js) and
|
||||
// scoped to the entry chunks whose 404 is fatal to boot - not translations
|
||||
// or lazily loaded panels, which fail non-fatally and are handled elsewhere.
|
||||
// Injected from stale-build-patterns.json (the single source shared with
|
||||
// the bundled util/recover-stale-build.ts) so the two never drift.
|
||||
var HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
|
||||
var MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
|
||||
|
||||
function booted() {
|
||||
// The launch screen is only removed once the app has taken over the
|
||||
// page. If it is gone, any later chunk failure is post-boot (a lazy
|
||||
// panel/dialog) and is not the stale-index boot failure this guard
|
||||
// targets, so we leave it alone.
|
||||
return !document.getElementById("ha-launch-screen");
|
||||
}
|
||||
|
||||
function showFatal() {
|
||||
var box = document.getElementById("ha-launch-screen-info-box");
|
||||
if (box) {
|
||||
box.textContent =
|
||||
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
|
||||
}
|
||||
}
|
||||
|
||||
function recover() {
|
||||
if (location.search.indexOf(BUST_PARAM + "=") !== -1) {
|
||||
// We already reloaded once with a fresh index and it still failed;
|
||||
// stop to avoid a reload loop.
|
||||
showFatal();
|
||||
return;
|
||||
}
|
||||
var target =
|
||||
location.pathname +
|
||||
location.search +
|
||||
(location.search ? "&" : "?") +
|
||||
BUST_PARAM +
|
||||
"=" +
|
||||
Date.now() +
|
||||
location.hash;
|
||||
// On https the service worker "/" route ignores the query string
|
||||
// (ignoreSearch), so a bust param alone would still be answered with the
|
||||
// stale cached index. Drop the worker and its caches first so the reload
|
||||
// is served fresh from the network.
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
|
||||
var go = function () {
|
||||
location.replace(target);
|
||||
};
|
||||
Promise.all([
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then(function (regs) {
|
||||
return Promise.all(
|
||||
regs.map(function (r) {
|
||||
return r.unregister();
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(function () {}),
|
||||
self.caches
|
||||
? caches
|
||||
.keys()
|
||||
.then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.map(function (k) {
|
||||
return caches.delete(k);
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(function () {})
|
||||
: null,
|
||||
]).then(go, go);
|
||||
} else {
|
||||
location.replace(target);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRecover(url, message) {
|
||||
if (booted()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(url && HASHED_ENTRY.test(url)) ||
|
||||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
|
||||
) {
|
||||
recover();
|
||||
}
|
||||
}
|
||||
|
||||
// Resource-load errors (<script>, modulepreload <link>) do not bubble, so
|
||||
// they are only observable in the capture phase at the window.
|
||||
window.addEventListener(
|
||||
"error",
|
||||
function (ev) {
|
||||
var el = ev.target;
|
||||
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
|
||||
maybeRecover(el.href || el.src, null);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// A failed dynamic import() rejects; the inline entry imports do not catch
|
||||
// it, so the rejection surfaces here.
|
||||
window.addEventListener("unhandledrejection", function (ev) {
|
||||
var reason = ev && ev.reason;
|
||||
maybeRecover(null, reason && (reason.message || String(reason)));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Home Assistant</title>
|
||||
<% if (useCacheRecovery) { %><%= renderTemplate("_bootstrap_recovery.html.template") %><% } %>
|
||||
<%= renderTemplate("_header.html.template") %>
|
||||
<link rel="mask-icon" href="/static/icons/mask-icon.svg" color="#18bcf2" />
|
||||
<link
|
||||
@@ -18,38 +19,15 @@
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<meta name="theme-color" content="{{ theme_color }}" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_style_base.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-group(launch-screen) {
|
||||
animation-duration: var(--ha-animation-duration-normal, 250ms);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(launch-screen) {
|
||||
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
height: 100vh;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -61,7 +39,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: launch-screen;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
z-index: 100;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
@@ -98,7 +77,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
|
||||
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`<p>
|
||||
: html`<p class=${classMap({ "loading-text": !this.migration })}>
|
||||
${
|
||||
this.migration
|
||||
? html`<span class="migration-text"
|
||||
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
|
||||
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
|
||||
}</span
|
||||
>`
|
||||
: this.localize?.("ui.init.loading") || "Loading data"
|
||||
: this.localize?.("ui.init.loading") || "Loading..."
|
||||
}
|
||||
</p>`;
|
||||
}
|
||||
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
|
||||
.migration-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.loading-text {
|
||||
opacity: 0.66;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -36,7 +37,9 @@ class HassLoadingScreen extends LitElement {
|
||||
private _renderContent(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${
|
||||
this.message
|
||||
? html`<div id="loading-text">${this.message}</div>`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ContextProvider, createContext } from "@lit/context";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@@ -15,6 +17,61 @@ export const panelIsReady = async (element: HTMLElement) => {
|
||||
fireEvent(element, "hass-panel-ready");
|
||||
};
|
||||
|
||||
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
|
||||
|
||||
export const childPanelReadyContext =
|
||||
createContext<RegisterChildPanelReady>("child-panel-ready");
|
||||
|
||||
export class ChildPanelReady implements ReactiveController {
|
||||
private _promises: Promise<void>[] = [];
|
||||
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
|
||||
private _resolveReady?: () => void;
|
||||
|
||||
private _completing = false;
|
||||
|
||||
public ready = new Promise<void>((resolve) => {
|
||||
this._resolveReady = resolve;
|
||||
});
|
||||
|
||||
public constructor(host: ReactiveControllerHost & HTMLElement) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
new ContextProvider(host, {
|
||||
context: childPanelReadyContext,
|
||||
initialValue: (ready) => this._promises.push(ready),
|
||||
});
|
||||
}
|
||||
|
||||
public hostUpdated() {
|
||||
if (this._completing) {
|
||||
return;
|
||||
}
|
||||
this._completing = true;
|
||||
this._host.removeController(this);
|
||||
void this._complete();
|
||||
}
|
||||
|
||||
private async _complete() {
|
||||
// Children created/updated during this render register after the host's
|
||||
// update commits. Wait for that before snapshotting readiness promises.
|
||||
await this._host.updateComplete;
|
||||
|
||||
await this._waitForPromises();
|
||||
|
||||
this._resolveReady?.();
|
||||
await panelIsReady(this._host);
|
||||
}
|
||||
|
||||
private _waitForPromises(): Promise<void> {
|
||||
const count = this._promises.length;
|
||||
return Promise.allSettled(this._promises).then(() =>
|
||||
this._promises.length > count ? this._waitForPromises() : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelReady {
|
||||
public ready?: Promise<void>;
|
||||
|
||||
|
||||
@@ -19,29 +19,66 @@ import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
|
||||
const PANEL_READY_TIMEOUT = 2000;
|
||||
const DASHBOARD_READY_TIMEOUT = 5000;
|
||||
const COMPONENTS = {
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
custom: () => import("../panels/custom/ha-panel-custom"),
|
||||
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
history: () => import("../panels/history/ha-panel-history"),
|
||||
iframe: () => import("../panels/iframe/ha-panel-iframe"),
|
||||
logbook: () => import("../panels/logbook/ha-panel-logbook"),
|
||||
map: () => import("../panels/map/ha-panel-map"),
|
||||
my: () => import("../panels/my/ha-panel-my"),
|
||||
profile: () => import("../panels/profile/ha-panel-profile"),
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
notfound: () => import("../panels/notfound/ha-panel-notfound"),
|
||||
};
|
||||
app: { load: () => import("../panels/app/ha-panel-app") },
|
||||
energy: {
|
||||
load: () => import("../panels/energy/ha-panel-energy"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
calendar: {
|
||||
load: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
waitForReady: true,
|
||||
},
|
||||
config: { load: () => import("../panels/config/ha-panel-config") },
|
||||
custom: { load: () => import("../panels/custom/ha-panel-custom") },
|
||||
lovelace: {
|
||||
load: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
history: { load: () => import("../panels/history/ha-panel-history") },
|
||||
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
|
||||
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
|
||||
map: { load: () => import("../panels/map/ha-panel-map") },
|
||||
my: { load: () => import("../panels/my/ha-panel-my") },
|
||||
profile: { load: () => import("../panels/profile/ha-panel-profile") },
|
||||
todo: { load: () => import("../panels/todo/ha-panel-todo") },
|
||||
"media-browser": {
|
||||
load: () => import("../panels/media-browser/ha-panel-media-browser"),
|
||||
waitForReady: true,
|
||||
},
|
||||
light: {
|
||||
load: () => import("../panels/light/ha-panel-light"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
security: {
|
||||
load: () => import("../panels/security/ha-panel-security"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
climate: {
|
||||
load: () => import("../panels/climate/ha-panel-climate"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
maintenance: {
|
||||
load: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
home: {
|
||||
load: () => import("../panels/home/ha-panel-home"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
|
||||
} satisfies Record<
|
||||
string,
|
||||
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
|
||||
>;
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
class PartialPanelResolver extends HassRouterPage {
|
||||
@@ -126,13 +163,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
private _getRoutes(panels: Panels): RouterOptions {
|
||||
const routes: RouterOptions["routes"] = {};
|
||||
Object.values(panels).forEach((panel) => {
|
||||
const component = COMPONENTS[panel.component_name];
|
||||
const data: RouteOptions = {
|
||||
tag: `ha-panel-${panel.component_name}`,
|
||||
cache: CACHE_URL_PATHS.includes(panel.url_path),
|
||||
...(component ?? {}),
|
||||
};
|
||||
if (panel.component_name in COMPONENTS) {
|
||||
data.load = COMPONENTS[panel.component_name];
|
||||
}
|
||||
routes[panel.url_path] = data;
|
||||
});
|
||||
|
||||
@@ -221,9 +257,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
)
|
||||
) {
|
||||
await this.rebuild();
|
||||
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
|
||||
() => undefined
|
||||
);
|
||||
const component =
|
||||
COMPONENTS[this.hass.panels[this._currentPage].component_name];
|
||||
await promiseTimeout(
|
||||
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
|
||||
this.pageRendered
|
||||
).catch(() => undefined);
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again. Native apps remove
|
||||
// it instantly because their own splash screen is still visible.
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-switch";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../components/radio/ha-radio-group";
|
||||
import "../../components/radio/ha-radio-option";
|
||||
import { RecurrenceRange } from "../../data/calendar";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ConfirmEventDialogBoxParams } from "./show-confirm-event-dialog-box";
|
||||
import "../../components/ha-button";
|
||||
|
||||
// `RecurrenceRange.THISEVENT` is "", which the radio group treats as unselected
|
||||
// on its value-attribute and form-reset paths, so keep the options on their own
|
||||
// literals and map them when confirming.
|
||||
type RecurrenceScope = "this" | "future";
|
||||
|
||||
@customElement("confirm-event-dialog-box")
|
||||
class ConfirmEventDialogBox extends LitElement {
|
||||
@@ -18,11 +27,14 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state()
|
||||
private _closeState?: "canceled" | "confirmed" | "confirmedFuture";
|
||||
@state() private _closeState?: "canceled" | "confirmed";
|
||||
|
||||
@state() private _scope: RecurrenceScope = "this";
|
||||
|
||||
public async showDialog(params: ConfirmEventDialogBoxParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._scope = "this";
|
||||
this._closeState = undefined;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -39,20 +51,27 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { destructive, recurring } = this._params;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
width="small"
|
||||
type="alert"
|
||||
aria-describedby=${recurring ? nothing : "description"}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div>
|
||||
<p>${this._params.text}</p>
|
||||
</div>
|
||||
${
|
||||
recurring
|
||||
? this._renderRecurrenceRange()
|
||||
: html`<p id="description">${this._params.text}</p>`
|
||||
}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._dismiss}
|
||||
?autofocus=${!recurring && destructive}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
@@ -60,29 +79,41 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._confirm}
|
||||
autofocus
|
||||
variant="danger"
|
||||
?autofocus=${!recurring && !destructive}
|
||||
variant=${destructive ? "danger" : "brand"}
|
||||
>
|
||||
${this._params.confirmText}
|
||||
</ha-button>
|
||||
${
|
||||
this._params.confirmFutureText
|
||||
? html`
|
||||
<ha-button
|
||||
@click=${this._confirmFuture}
|
||||
slot="primaryAction"
|
||||
variant="danger"
|
||||
>
|
||||
${this._params.confirmFutureText}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRecurrenceRange() {
|
||||
const thisEvent = this.hass.localize(
|
||||
"ui.components.calendar.event.recurrence_range.this_event"
|
||||
);
|
||||
const thisAndFuture = this.hass.localize(
|
||||
"ui.components.calendar.event.recurrence_range.this_and_future"
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-radio-group
|
||||
name="recurrence_range"
|
||||
.label=${this._params!.text ?? this._params!.title}
|
||||
.value=${this._scope}
|
||||
@change=${this._scopeChanged}
|
||||
>
|
||||
<ha-radio-option value="this" autofocus>${thisEvent}</ha-radio-option>
|
||||
<ha-radio-option value="future">${thisAndFuture}</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
`;
|
||||
}
|
||||
|
||||
private _scopeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>): void {
|
||||
this._scope = ev.currentTarget.value as RecurrenceScope;
|
||||
}
|
||||
|
||||
private _dismiss(): void {
|
||||
this._closeState = "canceled";
|
||||
this.closeDialog();
|
||||
@@ -90,17 +121,11 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
|
||||
private _confirm(): void {
|
||||
this._closeState = "confirmed";
|
||||
if (this._params!.confirm) {
|
||||
this._params!.confirm(RecurrenceRange.THISEVENT);
|
||||
}
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _confirmFuture(): void {
|
||||
this._closeState = "confirmedFuture";
|
||||
if (this._params!.confirm) {
|
||||
this._params!.confirm(RecurrenceRange.THISANDFUTURE);
|
||||
}
|
||||
this._params!.confirm?.(
|
||||
this._scope === "future"
|
||||
? RecurrenceRange.THISANDFUTURE
|
||||
: RecurrenceRange.THISEVENT
|
||||
);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
@@ -108,10 +133,7 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
if (!this._params) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this._closeState !== "confirmed" &&
|
||||
this._closeState !== "confirmedFuture"
|
||||
) {
|
||||
if (this._closeState !== "confirmed") {
|
||||
this._params.cancel?.();
|
||||
}
|
||||
this._params = undefined;
|
||||
@@ -125,18 +147,12 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
pointer-events: initial !important;
|
||||
cursor: initial !important;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.no-bottom-padding {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
ha-radio-group::part(form-control-label) {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
ha-dialog {
|
||||
/* Place above other dialogs */
|
||||
|
||||
@@ -224,18 +224,9 @@ class DialogCalendarEventDetail extends LitElement {
|
||||
: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.prompt"
|
||||
),
|
||||
confirmText: entry.recurrence_id
|
||||
? this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete_this"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete"
|
||||
),
|
||||
confirmFutureText: entry.recurrence_id
|
||||
? this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete_future"
|
||||
)
|
||||
: undefined,
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
recurring: !!entry.recurrence_id,
|
||||
destructive: true,
|
||||
});
|
||||
if (range === undefined) {
|
||||
// Cancel
|
||||
|
||||
@@ -569,12 +569,8 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
|
||||
text: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_update.recurring_prompt"
|
||||
),
|
||||
confirmText: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_update.update_this"
|
||||
),
|
||||
confirmFutureText: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_update.update_future"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.update"),
|
||||
recurring: true,
|
||||
});
|
||||
}
|
||||
if (range === undefined) {
|
||||
@@ -625,18 +621,9 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
|
||||
: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.prompt"
|
||||
),
|
||||
confirmText: entry.recurrence_id
|
||||
? this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete_this"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete"
|
||||
),
|
||||
confirmFutureText: entry.recurrence_id
|
||||
? this.hass.localize(
|
||||
"ui.components.calendar.event.confirm_delete.delete_future"
|
||||
)
|
||||
: undefined,
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
recurring: !!entry.recurrence_id,
|
||||
destructive: true,
|
||||
});
|
||||
if (range === undefined) {
|
||||
// Cancel
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
|
||||
import { fetchIntegrationManifest } from "../../data/integration";
|
||||
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { panelIsReady } from "../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { CalendarViewChanged, HomeAssistant } from "../../types";
|
||||
@@ -77,6 +78,8 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _mql?: MediaQueryList;
|
||||
|
||||
private _initialReady = false;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._mql = window.matchMedia(
|
||||
@@ -103,6 +106,10 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
this._entityRegistry = entities;
|
||||
// Refresh calendars when entity registry updates (includes color changes)
|
||||
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
|
||||
if (!this._initialReady) {
|
||||
this._initialReady = true;
|
||||
panelIsReady(this);
|
||||
}
|
||||
// Resubscribe events if view dates are available (handles both initial load and color updates)
|
||||
if (this._start && this._end) {
|
||||
this._unsubscribeAll().then(() => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { RecurrenceRange } from "../../data/calendar";
|
||||
|
||||
export interface ConfirmEventDialogBoxParams {
|
||||
title: string;
|
||||
// Labels the recurrence range options when `recurring` is set
|
||||
text?: string;
|
||||
confirmText?: string;
|
||||
confirmFutureText?: string; // Prompt for future recurring events
|
||||
// Let the user pick which occurrences of a recurring event are affected
|
||||
recurring?: boolean;
|
||||
destructive?: boolean;
|
||||
confirm?: (recurrenceRange: RecurrenceRange) => void;
|
||||
cancel?: () => void;
|
||||
text?: string | TemplateResult;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const loadGenericDialog = () => import("./confirm-event-dialog-box");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelClimate extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
const selectedDomainName = this._params.selectedDomain
|
||||
? domainToName(this.hass.localize, this._domain!)
|
||||
: "";
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._domain}`)
|
||||
: this._manifest?.documentation;
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
}
|
||||
)}
|
||||
${
|
||||
this._manifest?.is_built_in ||
|
||||
this._manifest?.documentation
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/chips/ha-assist-chip";
|
||||
import "../../../../../components/chips/ha-chip-set";
|
||||
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
name: html`<a
|
||||
href=${this._currentAddon.url!}
|
||||
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${getAppDisplayName(
|
||||
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private get _pathWebui(): string | null {
|
||||
const addon = this._currentAddon as HassioAddonDetails;
|
||||
return addon.webui!.replace("[HOST]", document.location.hostname);
|
||||
return (
|
||||
sanitizeHttpUrl(
|
||||
addon.webui!.replace("[HOST]", document.location.hostname)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private get _computeShowWebUI(): boolean | "" | null {
|
||||
|
||||
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -203,12 +203,10 @@ export function getModifiedAtTableColumn<T>(
|
||||
}
|
||||
|
||||
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
|
||||
html`${
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing
|
||||
}`;
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing;
|
||||
|
||||
@@ -3,9 +3,10 @@ import { mdiRestore } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -23,11 +24,13 @@ import {
|
||||
type EntityIdFormat,
|
||||
type EntityIdPart,
|
||||
} from "../../../data/entity_id_format";
|
||||
import { fetchSlug } from "../../../data/ws-slugify";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "./ha-entity-id-format-editor";
|
||||
|
||||
const EXAMPLE_DOMAIN = "sensor";
|
||||
const PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
@customElement("ha-config-entity-id-format")
|
||||
export class HaConfigEntityIdFormat extends LitElement {
|
||||
@@ -47,6 +50,10 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _preview?: string;
|
||||
|
||||
@state() private _previewError = false;
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
try {
|
||||
@@ -57,12 +64,47 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _examples: Record<EntityIdPart, string> = {
|
||||
area: "Living room",
|
||||
device: "Thermostat",
|
||||
entity: "Temperature",
|
||||
floor: "Ground floor",
|
||||
};
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
(changedProps.has("_format") || changedProps.has("_localize")) &&
|
||||
this._format
|
||||
) {
|
||||
if (changedProps.get("_format") === undefined) {
|
||||
this._updatePreview();
|
||||
} else {
|
||||
this._debouncedUpdatePreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _examples = memoizeOne(
|
||||
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
|
||||
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
|
||||
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
|
||||
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
|
||||
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
|
||||
})
|
||||
);
|
||||
|
||||
private _debouncedUpdatePreview = debounce(
|
||||
() => this._updatePreview(),
|
||||
PREVIEW_DEBOUNCE_MS
|
||||
);
|
||||
|
||||
private async _updatePreview() {
|
||||
const examples = this._examples(this._localize);
|
||||
const fullName = this._format!.map((part) => examples[part])
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
try {
|
||||
const { slug } = await fetchSlug(this._api, fullName);
|
||||
this._preview = slug;
|
||||
this._previewError = false;
|
||||
} catch (_err: any) {
|
||||
this._previewError = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
@@ -131,13 +173,20 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
}
|
||||
|
||||
private _renderPreview() {
|
||||
const example = computeEntityIdFormatExample(this._format!, this._examples);
|
||||
return html`
|
||||
<div class="preview">
|
||||
<span class="preview-label">
|
||||
${this._localize("ui.panel.config.entity_id_format.card.preview")}
|
||||
</span>
|
||||
<code>${EXAMPLE_DOMAIN}.${example}</code>
|
||||
${
|
||||
this._previewError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.preview_error"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import { ChildPanelReady } from "../../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -157,6 +158,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
private _pages = memoizeOne(
|
||||
(
|
||||
cloudStatus,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-next";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { getConfigEntries } from "../../../data/config_entries";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-config-navigation-list";
|
||||
|
||||
@@ -18,21 +24,53 @@ class HaConfigNavigation extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public pages!: PageNavigation[];
|
||||
|
||||
@state() private _hasBluetoothConfigEntries = false;
|
||||
@state() private _visiblePages?: PageNavigation[];
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((bluetoothEntries) => {
|
||||
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
|
||||
});
|
||||
private _hasBluetoothConfigEntries = false;
|
||||
|
||||
private _bluetoothEntriesLoaded?: Promise<void>;
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
private _filterNavigationPages = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
pages: PageNavigation[],
|
||||
hasBluetoothConfigEntries: boolean
|
||||
) => filterNavigationPages(hass, pages, { hasBluetoothConfigEntries })
|
||||
);
|
||||
|
||||
@consume({ context: childPanelReadyContext, subscribe: true })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
protected override updated(changedProps: Map<PropertyKey, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
const pagesOrHassChanged =
|
||||
changedProps.has("pages") || changedProps.has("hass");
|
||||
|
||||
if (pagesOrHassChanged) {
|
||||
const ready = this._resolveVisiblePages();
|
||||
if (!this._childReadyRegistered && this._registerChildPanelReady) {
|
||||
this._registerChildPanelReady(ready);
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._childReadyRegistered &&
|
||||
this._registerChildPanelReady &&
|
||||
this.pages &&
|
||||
this.hass
|
||||
) {
|
||||
this._registerChildPanelReady(this._resolveVisiblePages());
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
}).map((page) => ({
|
||||
const pages = (this._visiblePages ?? []).map((page) => ({
|
||||
...page,
|
||||
name:
|
||||
page.name ||
|
||||
@@ -75,6 +113,42 @@ class HaConfigNavigation extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _loadBluetoothEntries(): Promise<void> {
|
||||
if (!this._bluetoothEntriesLoaded) {
|
||||
this._bluetoothEntriesLoaded = getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
})
|
||||
.then((entries) => {
|
||||
this._hasBluetoothConfigEntries = entries.length > 0;
|
||||
})
|
||||
.catch(() => {
|
||||
this._hasBluetoothConfigEntries = false;
|
||||
});
|
||||
}
|
||||
return this._bluetoothEntriesLoaded;
|
||||
}
|
||||
|
||||
private async _resolveVisiblePages(): Promise<void> {
|
||||
if (this.pages.some((page) => page.component === "bluetooth")) {
|
||||
await this._loadBluetoothEntries();
|
||||
}
|
||||
|
||||
const visiblePages = this._filterNavigationPages(
|
||||
this.hass,
|
||||
this.pages,
|
||||
this._hasBluetoothConfigEntries
|
||||
);
|
||||
const currentVisiblePages = this._visiblePages;
|
||||
if (
|
||||
!currentVisiblePages ||
|
||||
visiblePages.length !== currentVisiblePages.length ||
|
||||
visiblePages.some((page, index) => page !== currentVisiblePages[index])
|
||||
) {
|
||||
this._visiblePages = visiblePages;
|
||||
}
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
|
||||
@@ -96,6 +96,10 @@ import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
@@ -1263,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
const deviceActions: DeviceAction[] = [];
|
||||
|
||||
const configurationUrlIsHomeAssistant =
|
||||
device.configuration_url?.startsWith("homeassistant://") || false;
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
device.configuration_url
|
||||
);
|
||||
|
||||
const configurationUrl = configurationUrlIsHomeAssistant
|
||||
? device.configuration_url?.replace("homeassistant://", "/")
|
||||
: device.configuration_url;
|
||||
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
|
||||
|
||||
if (configurationUrl) {
|
||||
deviceActions.push({
|
||||
|
||||
@@ -61,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _requiredError(): string {
|
||||
return this.hass.localize("ui.common.error_required");
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<p class="power-section-label">
|
||||
@@ -119,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
|
||||
{ unit: this._powerUnits?.join(", ") || "" }
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
@@ -138,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.helper=${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate_inverted}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
// These two exclude each other, so they keep their clear button
|
||||
// (and therefore no required marker) to stay swappable.
|
||||
this.powerType === "two_sensors"
|
||||
? html`
|
||||
<ha-statistic-picker
|
||||
@@ -157,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_to,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._fromPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_from}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -170,6 +184,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_from,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._toPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_to}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -89,6 +89,7 @@ class HaPanelConfig extends HassRouterPage {
|
||||
dashboard: {
|
||||
tag: "ha-config-dashboard",
|
||||
load: () => import("./dashboard/ha-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
entities: {
|
||||
tag: "ha-config-entities",
|
||||
|
||||
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { round } from "../../../common/number/round";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/chart/ha-chart-base";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
|
||||
) as ConfigEntry[];
|
||||
boardId = boardData.board!.hassio_board_id;
|
||||
boardName = boardData.name;
|
||||
documentationURL = boardData.url;
|
||||
documentationURL = sanitizeHttpUrl(boardData.url);
|
||||
imageURL = hardwareBrandsUrl(
|
||||
{
|
||||
category: "boards",
|
||||
|
||||
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
|
||||
const newValue = { ...this._item };
|
||||
|
||||
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = {
|
||||
...newValue[day][index],
|
||||
from: value.from,
|
||||
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
|
||||
};
|
||||
|
||||
if (newDay === day) {
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = event;
|
||||
} else {
|
||||
newValue[day].splice(index, 1);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const configurationUrl = sanitizeLinkUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const documentationLink = this.manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
|
||||
: this.manifest?.documentation;
|
||||
return html`
|
||||
<ha-integration-action-card
|
||||
class=${classMap({
|
||||
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
this.flow.context.configuration_url || this.manifest || attention
|
||||
configurationUrl || documentationLink || attention
|
||||
? html`<ha-dropdown
|
||||
slot="header-button"
|
||||
placement="bottom-end"
|
||||
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.flow.context.configuration_url
|
||||
configurationUrl
|
||||
? html`<a
|
||||
href=${this.flow.context.configuration_url.replace(
|
||||
/^homeassistant:\/\//,
|
||||
"/"
|
||||
)}
|
||||
href=${configurationUrl}
|
||||
rel="noreferrer"
|
||||
target=${
|
||||
this.flow.context.configuration_url.startsWith(
|
||||
"homeassistant://"
|
||||
)
|
||||
? "_self"
|
||||
: "_blank"
|
||||
configurationUrlIsHomeAssistant ? "_self" : "_blank"
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item>
|
||||
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this.manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this.manifest.domain}`
|
||||
)
|
||||
: this.manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this.domain
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`
|
||||
<a
|
||||
slot="toolbar-icon"
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -1398,7 +1395,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 40px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: none;
|
||||
margin: 0;
|
||||
|
||||
+119
-8
@@ -4,6 +4,7 @@ import {
|
||||
mdiCloseCircle,
|
||||
mdiProgressClock,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -11,6 +12,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import { groupBy } from "../../../../../common/util/group-by";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
@@ -25,10 +27,10 @@ import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type {
|
||||
ZWaveJSNodeCapabilities,
|
||||
ZWaveJSNodeConfigParam,
|
||||
ZWaveJSNodeConfigParams,
|
||||
ZWaveJSSetConfigParamResult,
|
||||
ZwaveJSNodeConfigParameterUpdate,
|
||||
ZwaveJSNodeMetadata,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
fetchZwaveNodeMetadata,
|
||||
invokeZWaveCCApi,
|
||||
setZwaveNodeConfigParameter,
|
||||
subscribeZwaveNodeConfigParameterUpdates,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-error-screen";
|
||||
@@ -59,7 +62,7 @@ const icons = {
|
||||
|
||||
@customElement("zwave_js-node-config")
|
||||
class ZWaveJSNodeConfig extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@@ -83,13 +86,41 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
|
||||
@state() private _resetDialogProgress = false;
|
||||
|
||||
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _resultTimeouts: Record<string, number> = {};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.deviceId = this.route.path.substr(1);
|
||||
this._subscribeConfigParameterUpdates();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
this._clearAllResultTimeouts();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!changedProps.has("route") || !this.route) {
|
||||
return;
|
||||
}
|
||||
const deviceId = this.route.path.slice(1);
|
||||
if (deviceId !== this.deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
this._config = undefined;
|
||||
this._clearAllResultTimeouts();
|
||||
this._results = {};
|
||||
this._error = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (!this._config || changedProps.has("deviceId")) {
|
||||
if (changedProps.has("deviceId")) {
|
||||
this._fetchData();
|
||||
this._subscribeConfigParameterUpdates();
|
||||
} else if (!this._config) {
|
||||
this._fetchData();
|
||||
}
|
||||
}
|
||||
@@ -154,8 +185,9 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
device_database: html`<a
|
||||
rel="noreferrer noopener"
|
||||
href=${
|
||||
this._nodeMetadata?.device_database_url ||
|
||||
"https://devices.zwave-js.io"
|
||||
sanitizeHttpUrl(
|
||||
this._nodeMetadata?.device_database_url
|
||||
) || "https://devices.zwave-js.io"
|
||||
}
|
||||
target="_blank"
|
||||
>${this.hass.localize(
|
||||
@@ -443,6 +475,58 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
<p>${item.value}</p>`;
|
||||
}
|
||||
|
||||
private _subscribeConfigParameterUpdates(): void {
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
if (!this.isConnected || !this.hass || !this.deviceId) {
|
||||
return;
|
||||
}
|
||||
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
this._handleConfigParameterUpdate
|
||||
);
|
||||
this._unsubConfigParamUpdates.catch(() => {
|
||||
// The backend doesn't support the subscription; the page still works,
|
||||
// it just won't receive live updates
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribeConfigParameterUpdates(): void {
|
||||
if (this._unsubConfigParamUpdates) {
|
||||
this._unsubConfigParamUpdates
|
||||
.then((unsub) => unsub())
|
||||
.catch(() => {
|
||||
// The subscription never succeeded, so there is nothing to clean up
|
||||
});
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleConfigParameterUpdate = (
|
||||
update: ZwaveJSNodeConfigParameterUpdate
|
||||
): void => {
|
||||
const param = this._config?.[update.id];
|
||||
if (!param) {
|
||||
return;
|
||||
}
|
||||
const status = this._results[update.id]?.status;
|
||||
if (status === "queued") {
|
||||
// The device applied the queued change; the accepted result is cleared
|
||||
// after a short delay by _setResult so the success message shows.
|
||||
// The value was already set optimistically when the change was queued,
|
||||
// so this runs even if the reported value matches the stored one.
|
||||
this._setResult(update.id, "accepted");
|
||||
} else if (status === "error" && param.value !== update.value) {
|
||||
// The parameter changed, so the previous error no longer applies
|
||||
this._setResult(update.id, undefined);
|
||||
}
|
||||
if (param.value !== update.value) {
|
||||
param.value = update.value;
|
||||
this._config = { ...this._config };
|
||||
}
|
||||
};
|
||||
|
||||
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
|
||||
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
|
||||
// We want those to be considered boolean and show a toggle switch
|
||||
@@ -609,16 +693,38 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _clearResultTimeout(key: string): void {
|
||||
if (key in this._resultTimeouts) {
|
||||
clearTimeout(this._resultTimeouts[key]);
|
||||
delete this._resultTimeouts[key];
|
||||
}
|
||||
}
|
||||
|
||||
private _clearAllResultTimeouts(): void {
|
||||
Object.values(this._resultTimeouts).forEach((timeout) =>
|
||||
clearTimeout(timeout)
|
||||
);
|
||||
this._resultTimeouts = {};
|
||||
}
|
||||
|
||||
private _setResult(key: string, value: string | undefined) {
|
||||
this._clearResultTimeout(key);
|
||||
if (value === undefined) {
|
||||
delete this._results[key];
|
||||
this.requestUpdate();
|
||||
} else {
|
||||
this._results = { ...this._results, [key]: { status: value } };
|
||||
if (value === "accepted") {
|
||||
// Show the success message briefly, then clear it
|
||||
this._resultTimeouts[key] = window.setTimeout(() => {
|
||||
this._setResult(key, undefined);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _setError(key: string, message: string) {
|
||||
this._clearResultTimeout(key);
|
||||
const errorParam = { status: "error", error: message };
|
||||
this._results = { ...this._results, [key]: errorParam };
|
||||
}
|
||||
@@ -634,12 +740,17 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let capabilities: ZWaveJSNodeCapabilities | undefined;
|
||||
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
|
||||
const [nodeMetadata, config, capabilities] = await Promise.all([
|
||||
fetchZwaveNodeMetadata(this.hass, device.id),
|
||||
fetchZwaveNodeConfigParameters(this.hass, device.id),
|
||||
fetchZwaveNodeCapabilities(this.hass, device.id),
|
||||
]);
|
||||
if (device.id !== this.deviceId) {
|
||||
// The user navigated to another node while the data was loading
|
||||
return;
|
||||
}
|
||||
this._nodeMetadata = nodeMetadata;
|
||||
this._config = config;
|
||||
this._canResetAll =
|
||||
capabilities &&
|
||||
Object.values(capabilities).some((endpoint) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
|
||||
|
||||
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
|
||||
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
|
||||
|
||||
// Build description with learn more link if available
|
||||
const descriptionWithLink = preview_feature.learn_more_url
|
||||
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
|
||||
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
<div class="card-actions">
|
||||
<div>
|
||||
${
|
||||
preview_feature.feedback_url
|
||||
feedbackUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.feedback_url}
|
||||
href=${feedbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
preview_feature.report_issue_url
|
||||
reportIssueUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.report_issue_url}
|
||||
href=${reportIssueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
|
||||
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
|
||||
import { formatSystemLogTime } from "./util";
|
||||
|
||||
/** Compares the host, so a URL that merely contains ours does not pass. */
|
||||
const isOfficialDocumentationUrl = (url: string): boolean => {
|
||||
try {
|
||||
return new URL(url).hostname === "www.home-assistant.io";
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("dialog-system-log-detail")
|
||||
class DialogSystemLogDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
this._manifest &&
|
||||
(this._manifest.is_built_in ||
|
||||
// Custom components with our official docs should not link to our docs
|
||||
!this._manifest.documentation.includes("://www.home-assistant.io"));
|
||||
(!!this._manifest.documentation &&
|
||||
!isOfficialDocumentationUrl(this._manifest.documentation)));
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const title = this.hass.localize("ui.panel.config.logs.details", {
|
||||
level: html`<span class=${item.level}
|
||||
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
${
|
||||
!this._manifest ||
|
||||
// Can happen with custom integrations
|
||||
!showDocumentation
|
||||
!showDocumentation ||
|
||||
!documentationLink
|
||||
? ""
|
||||
: html`
|
||||
(<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
@@ -24,7 +25,10 @@ import type {
|
||||
HttpConfig,
|
||||
HttpConfigWithMeta,
|
||||
} from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@@ -167,6 +171,10 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
@state() private _activeConfigType?: ActiveConfigType;
|
||||
|
||||
// The built-in default config as reported by core; used to show the default
|
||||
// port in the helper text instead of a hard-coded value.
|
||||
@state() private _default?: HttpConfigWithMeta;
|
||||
|
||||
// A pending config that was reverted/failed and kept only for display.
|
||||
@state() private _revertedPending?: HttpConfigWithMeta;
|
||||
|
||||
@@ -201,6 +209,8 @@ class HaConfigHttpForm extends LitElement {
|
||||
const portChanged =
|
||||
!!this._stable && this._config?.server_port !== this._stable.server_port;
|
||||
|
||||
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -255,6 +265,17 @@ class HaConfigHttpForm extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
hasListenAddresses
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.server_host_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -309,12 +330,16 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
const { stable, pending, active_config_type } = await fetchHttpConfig(
|
||||
this.hass
|
||||
);
|
||||
const {
|
||||
stable,
|
||||
pending,
|
||||
active_config_type,
|
||||
default: defaultConfig,
|
||||
} = await fetchHttpConfig(this.hass);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
this._activeConfigType = active_config_type;
|
||||
this._default = defaultConfig;
|
||||
// An active trial pending (no error) is handled by the global
|
||||
// confirm/revert dialog. A pending carrying an error was reverted or
|
||||
// failed to apply and is kept only so we can surface it here.
|
||||
@@ -351,6 +376,12 @@ class HaConfigHttpForm extends LitElement {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
return "";
|
||||
}
|
||||
if (schema.name === "server_port") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.network.http.helpers.server_port",
|
||||
{ port: this._default?.server_port ?? 8123 }
|
||||
);
|
||||
}
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.helpers.${schema.name}` as any
|
||||
@@ -365,6 +396,63 @@ class HaConfigHttpForm extends LitElement {
|
||||
this._showNoChanges = false;
|
||||
}
|
||||
|
||||
// Build a link to the new address for an address-changing restart, so the
|
||||
// user (still on the old address) can jump to it once Home Assistant is back.
|
||||
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
|
||||
// the current page is not on the old port — that usually means a reverse
|
||||
// proxy, where swapping the port would point at the wrong place. Even when
|
||||
// shown, the new address may not be reachable (e.g. a firewall).
|
||||
private _newAddressUrl(): string | undefined {
|
||||
if (!this._stable || !this._config) {
|
||||
return undefined;
|
||||
}
|
||||
const loc = mainWindow.location;
|
||||
if (loc.hostname.endsWith("nabu.casa")) {
|
||||
return undefined;
|
||||
}
|
||||
const oldHttps = !!this._stable.ssl_certificate;
|
||||
const newHttps = !!this._config.ssl_certificate;
|
||||
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
|
||||
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
|
||||
// The reachable address only changes when the scheme or the port changes.
|
||||
if (oldHttps === newHttps && oldPort === newPort) {
|
||||
return undefined;
|
||||
}
|
||||
const currentPort = loc.port
|
||||
? Number(loc.port)
|
||||
: loc.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
if (currentPort !== oldPort) {
|
||||
return undefined;
|
||||
}
|
||||
const url = new URL(loc.origin);
|
||||
url.protocol = newHttps ? "https:" : "http:";
|
||||
url.port = String(newPort);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private _showNewAddress(url: string): void {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.title"
|
||||
),
|
||||
text: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.text"
|
||||
)}
|
||||
</p>
|
||||
<a href=${url} rel="noreferrer noopener">${url}</a>
|
||||
<p class="dialog-note">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.note"
|
||||
)}
|
||||
</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._config || !this._stable) {
|
||||
return;
|
||||
@@ -393,24 +481,44 @@ class HaConfigHttpForm extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the new address before the restart drops the connection.
|
||||
const newAddressUrl = this._newAddressUrl();
|
||||
|
||||
this._saving = true;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
// Drop empty entries from multi-value fields, and omit the field entirely
|
||||
// once it is empty so the backend applies its default. Otherwise a cleared
|
||||
// "IP address to bind to" would submit [""] / [], which binds to nothing.
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(this._config).map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const filtered = value.filter(Boolean);
|
||||
return [key, filtered.length ? filtered : undefined];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
) as HttpConfig;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
const result = await saveHttpConfig(this.hass, config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
} else if (newAddressUrl) {
|
||||
// restart === true: a restart is in flight. The reply usually races
|
||||
// with the connection drop; if we do reach this branch, offer the new
|
||||
// address so the user can follow along.
|
||||
this._showNewAddress(newAddressUrl);
|
||||
}
|
||||
// restart === true: a restart is in flight. The reply usually races with
|
||||
// the connection drop; if we do reach this branch, the disconnected
|
||||
// overlay will appear in moments. Leave the form as is.
|
||||
} catch (err: any) {
|
||||
// The restart kills the WS connection before the ack — that's expected.
|
||||
if (
|
||||
err?.error?.code === ERR_CONNECTION_LOST ||
|
||||
err === ERR_CONNECTION_LOST
|
||||
) {
|
||||
if (newAddressUrl) {
|
||||
this._showNewAddress(newAddressUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._handleSaveError(err);
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { fetchCloudStatus } from "../../../data/cloud";
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import type { HttpConfigWithMeta } from "../../../data/http";
|
||||
import { fetchHttpConfig } from "../../../data/http";
|
||||
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _internal_url = "";
|
||||
|
||||
// Stable HTTP config from core; used to build a realistic internal URL
|
||||
// placeholder (scheme from SSL, port from the configured/default port).
|
||||
@state() private _httpConfig?: HttpConfigWithMeta;
|
||||
|
||||
@state() private _httpDefault?: HttpConfigWithMeta;
|
||||
|
||||
@state() private _cloudStatus?: CloudStatus | null;
|
||||
|
||||
@state() private _showCustomExternalUrl = false;
|
||||
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build realistic URL placeholders: use https when an SSL certificate is
|
||||
// configured, and only show the port when it differs from the scheme
|
||||
// default (80 for http, 443 for https).
|
||||
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
|
||||
const placeholderPort =
|
||||
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
|
||||
const placeholderParts = {
|
||||
protocol: useHttps ? "https" : "http",
|
||||
port:
|
||||
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
|
||||
? `:${placeholderPort}`
|
||||
: "",
|
||||
};
|
||||
const internalUrlPlaceholder = this.hass.localize(
|
||||
"ui.panel.config.url.internal_url_placeholder",
|
||||
placeholderParts
|
||||
);
|
||||
const externalUrlPlaceholder = this.hass.localize(
|
||||
"ui.panel.config.url.external_url_placeholder",
|
||||
placeholderParts
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
data-name="external_url"
|
||||
type="url"
|
||||
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
|
||||
placeholder="https://example.duckdns.org:8123"
|
||||
.placeholder=${externalUrlPlaceholder}
|
||||
.value=${externalUrl}
|
||||
.maskedValue=${
|
||||
this._showCustomExternalUrl && canEdit
|
||||
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
data-name="internal_url"
|
||||
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
|
||||
type="url"
|
||||
placeholder=${this.hass.localize(
|
||||
"ui.panel.config.url.internal_url_placeholder"
|
||||
)}
|
||||
.placeholder=${internalUrlPlaceholder}
|
||||
.value=${internalUrl}
|
||||
.maskedValue=${
|
||||
this._showCustomInternalUrl && canEdit
|
||||
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
this._cloudStatus = null;
|
||||
}
|
||||
this._fetchUrls();
|
||||
// Best-effort: the placeholder still works without it, just without a port.
|
||||
fetchHttpConfig(this.hass).then(
|
||||
({ stable, default: defaultConfig }) => {
|
||||
this._httpConfig = stable;
|
||||
this._httpDefault = defaultConfig;
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleCloud(ev: Event) {
|
||||
|
||||
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-button";
|
||||
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const learnMoreUrlIsHomeAssistant =
|
||||
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
|
||||
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this._issue.learn_more_url
|
||||
);
|
||||
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
|
||||
|
||||
const dialogTitle =
|
||||
this.hass.localize(
|
||||
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
|
||||
}
|
||||
</ha-button>
|
||||
${
|
||||
this._issue.learn_more_url
|
||||
learnMoreUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
appearance="filled"
|
||||
rel="noopener noreferrer"
|
||||
href=${
|
||||
learnMoreUrlIsHomeAssistant
|
||||
? this._issue.learn_more_url.replace(
|
||||
"homeassistant://",
|
||||
"/"
|
||||
)
|
||||
: this._issue.learn_more_url
|
||||
}
|
||||
href=${learnMoreUrl}
|
||||
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
|
||||
@click=${
|
||||
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
|
||||
|
||||
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { formatDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
|
||||
if (info.type === "pending") {
|
||||
value = html` <ha-spinner size="small"></ha-spinner> `;
|
||||
} else if (info.type === "failed") {
|
||||
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
|
||||
value = html`
|
||||
<span class="error">${info.error}</span>${
|
||||
!info.more_info
|
||||
!moreInfoUrl
|
||||
? ""
|
||||
: html`
|
||||
–
|
||||
<a
|
||||
href=${info.more_info}
|
||||
href=${moreInfoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
|
||||
`);
|
||||
}
|
||||
if (domain !== "homeassistant") {
|
||||
// No target, so an in-app path is also a valid destination here
|
||||
const manageUrl =
|
||||
sanitizeHttpUrl(domainInfo.manage_url) ??
|
||||
sanitizeNavigationPath(domainInfo.manage_url);
|
||||
sections.push(html`
|
||||
<div class="card-header">
|
||||
<h3>${domainToName(this.hass.localize, domain)}</h3>
|
||||
${
|
||||
!domainInfo.manage_url
|
||||
!manageUrl
|
||||
? ""
|
||||
: html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
class="manage"
|
||||
href=${domainInfo.manage_url}
|
||||
href=${manageUrl}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.info.system_health.manage"
|
||||
|
||||
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
|
||||
<ha-md-list>
|
||||
${this._setups?.map((setup) => {
|
||||
const manifest = this._manifests && this._manifests[setup.domain];
|
||||
const docLink = manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
|
||||
: manifest.documentation
|
||||
: "";
|
||||
const docLink =
|
||||
(manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${manifest.domain}`
|
||||
)
|
||||
: manifest.documentation
|
||||
: "") || "";
|
||||
|
||||
const setupSeconds = setup.seconds?.toFixed(2);
|
||||
return html`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
isTemplate,
|
||||
} from "../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
|
||||
import { extractSearchParam } from "../../../../common/url/search-params";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
|
||||
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<a
|
||||
href=${resolved.url}
|
||||
href=${ifDefined(
|
||||
sanitizeHttpUrl(resolved.url) ??
|
||||
sanitizeNavigationPath(resolved.url)
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
><ha-button>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
mdiRestore,
|
||||
mdiTrashCanOutline,
|
||||
mdiViewSplitHorizontal,
|
||||
mdiViewSplitVertical,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-code-editor";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
|
||||
import "../../../../components/ha-label";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tip";
|
||||
import type { RenderTemplateResult } from "../../../../data/ws-templates";
|
||||
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
|
||||
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
|
||||
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
|
||||
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
|
||||
const DEFAULT_SPLIT_POSITION = 50;
|
||||
|
||||
type SplitOrientation = "horizontal" | "vertical";
|
||||
|
||||
@customElement("tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
|
||||
|
||||
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@state() private _descriptionExpanded = false;
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("ha-tip") private _editorTip?: HTMLElement;
|
||||
@state() private _splitOrientation: SplitOrientation = "horizontal";
|
||||
|
||||
private _template = "";
|
||||
|
||||
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
|
||||
// its late-arriving results discarded.
|
||||
private _subscribeRequestId = 0;
|
||||
|
||||
private _tipResizeObserver?: ResizeObserver;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._template && !this._unsubRenderTemplate) {
|
||||
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeTemplate();
|
||||
this._tipResizeObserver?.disconnect();
|
||||
this._tipResizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (localStorage && localStorage["panel-dev-template-template"]) {
|
||||
this._template = localStorage["panel-dev-template-template"];
|
||||
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
|
||||
this._template = localStorage[STORAGE_KEY_TEMPLATE];
|
||||
} else {
|
||||
this._template = DEMO_TEMPLATE;
|
||||
}
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
|
||||
this._splitOrientation = "vertical";
|
||||
}
|
||||
this._subscribeTemplate();
|
||||
this._observeTipHeight();
|
||||
this._inited = true;
|
||||
}
|
||||
|
||||
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
|
||||
: "dict"
|
||||
: type;
|
||||
|
||||
const editorCard = this._renderEditorCard();
|
||||
const resultCard = this._renderResultCard(type, resultType);
|
||||
|
||||
// On narrow viewports side-by-side is too cramped, so force the (still
|
||||
// resizable) stacked layout and hide the orientation toggle.
|
||||
const orientation = this.narrow ? "vertical" : this._splitOrientation;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="about">
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.about"
|
||||
)}
|
||||
outlined
|
||||
.expanded=${this._descriptionExpanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div class="description">
|
||||
<p>
|
||||
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
layout: !this.narrow,
|
||||
horizontal: !this.narrow,
|
||||
})}"
|
||||
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
|
||||
>
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._restoreDemo}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.reset"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._clear}>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._templateResult
|
||||
? html`<pre
|
||||
class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>
|
||||
<ha-split-panel
|
||||
class="panes ${orientation === "vertical" ? "vertical" : ""}"
|
||||
.position=${this._splitPosition}
|
||||
.orientation=${orientation}
|
||||
snap="50%"
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
>
|
||||
<div slot="start" class="pane">${editorCard}</div>
|
||||
<div slot="end" class="pane">${resultCard}</div>
|
||||
${this.narrow ? nothing : this._renderOrientationToggle()}
|
||||
</ha-split-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOrientationToggle() {
|
||||
const label = this.hass.localize(
|
||||
this._splitOrientation === "vertical"
|
||||
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
|
||||
: "ui.panel.config.tools.tabs.templates.layout_stacked"
|
||||
);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
slot="divider"
|
||||
class="divider-toggle"
|
||||
.title=${label}
|
||||
aria-label=${label}
|
||||
@mousedown=${stopPropagation}
|
||||
@touchstart=${stopPropagation}
|
||||
@click=${this._toggleOrientation}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this._splitOrientation === "vertical"
|
||||
? mdiViewSplitVertical
|
||||
: mdiViewSplitHorizontal
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
|
||||
// copy, search and fullscreen buttons; the trailing divider separates them.
|
||||
private _editorToolbarItems = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"]
|
||||
): (HaIconButtonToolbarItem | string)[] => [
|
||||
{
|
||||
id: "restore-demo",
|
||||
label: localize("ui.panel.config.tools.tabs.templates.reset"),
|
||||
path: mdiRestore,
|
||||
action: () => this._restoreDemo(),
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: localize("ui.common.clear"),
|
||||
path: mdiTrashCanOutline,
|
||||
action: () => this._clear(),
|
||||
},
|
||||
"divider",
|
||||
]
|
||||
);
|
||||
|
||||
private _renderEditorCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
${
|
||||
this.narrow
|
||||
? nothing
|
||||
: html`
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
`
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderResultCard(type: string, resultType: string) {
|
||||
const showEmptyState =
|
||||
!this._error && !this._rendering && !this._template?.trim();
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showEmptyState
|
||||
? html`<div class="empty">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_placeholder"
|
||||
)}
|
||||
</div>`
|
||||
: this._templateResult
|
||||
? html`
|
||||
<ha-label dense>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</ha-label>
|
||||
<pre class="rendered">
|
||||
${
|
||||
type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result
|
||||
}</pre>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</p>
|
||||
${
|
||||
this._templateResult.listeners.time
|
||||
? html`
|
||||
@@ -316,109 +410,179 @@ ${
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _observeTipHeight() {
|
||||
if (!this._editorTip || this._tipResizeObserver) {
|
||||
return;
|
||||
}
|
||||
this._tipResizeObserver = new ResizeObserver((entries) => {
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ??
|
||||
entries[0]?.contentRect.height;
|
||||
if (height) {
|
||||
this.style.setProperty("--tip-height", `${height}px`);
|
||||
}
|
||||
});
|
||||
this._tipResizeObserver.observe(this._editorTip);
|
||||
private _splitRepositioned(ev: Event) {
|
||||
this._splitPosition = (ev.target as HaSplitPanel).position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _expandedChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
|
||||
) {
|
||||
this._descriptionExpanded = ev.detail.expanded;
|
||||
private _toggleOrientation() {
|
||||
this._splitOrientation =
|
||||
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
|
||||
if (this._inited) {
|
||||
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-4);
|
||||
.about {
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.content:has(ha-expansion-panel) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content.horizontal {
|
||||
--panel-header-height: calc(
|
||||
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
|
||||
var(--ha-font-size-m) + 1px + 2px
|
||||
);
|
||||
--description-pane-height: calc(
|
||||
var(--ha-space-4) + 48px +
|
||||
(
|
||||
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
|
||||
var(--ha-space-1) * 2
|
||||
) *
|
||||
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
|
||||
);
|
||||
--card-header-height: calc(
|
||||
var(--ha-space-3) + var(--ha-space-4) +
|
||||
var(--ha-line-height-expanded) *
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
|
||||
);
|
||||
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
|
||||
--tip-height-minimal: calc(
|
||||
var(--mdc-icon-size, 24px) + var(--ha-space-4)
|
||||
);
|
||||
--edit-pane-height: calc(
|
||||
100vh - var(--panel-header-height) - var(
|
||||
--description-pane-height
|
||||
) - var(--ha-space-4) *
|
||||
2
|
||||
);
|
||||
--code-mirror-max-height: calc(
|
||||
var(--edit-pane-height) - var(--card-header-height) +
|
||||
var(--ha-space-2) - var(--card-actions-height) - var(
|
||||
--tip-height,
|
||||
var(--tip-height-minimal)
|
||||
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
|
||||
2
|
||||
);
|
||||
.about a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-4);
|
||||
--ha-split-panel-min: 20%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* On wide viewports we slot our own handle (the orientation toggle)
|
||||
into the divider, so hide the default grip. On narrow there is no
|
||||
toggle, so keep the default grip as the resize affordance. */
|
||||
:host(:not([narrow])) .panes {
|
||||
--ha-split-panel-grip-display: none;
|
||||
}
|
||||
|
||||
/* Orientation toggle that lives on the divider. Clicking it toggles
|
||||
orientation; resizing is done by dragging the divider elsewhere. */
|
||||
.divider-toggle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 16px;
|
||||
transition:
|
||||
color var(--ha-animation-duration-fast) ease-out,
|
||||
border-color var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.divider-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.divider-toggle:focus-visible {
|
||||
outline: none;
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.pane[slot="start"] {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane[slot="end"] {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="start"] {
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="end"] {
|
||||
padding-inline-start: 0;
|
||||
padding-block-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane ha-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-pane ha-code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
--code-mirror-height: 100%;
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.edit-pane {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.edit-pane a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content.horizontal > * {
|
||||
width: 50%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.render-spinner {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
@@ -428,10 +592,24 @@ ${
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.render-pane ha-label {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.rendered {
|
||||
font-family: var(--ha-font-family-code);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
@@ -439,6 +617,7 @@ ${
|
||||
clear: both;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -447,7 +626,7 @@ ${
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin-block-end: 0;
|
||||
margin-block: 0;
|
||||
}
|
||||
.description > p {
|
||||
margin-block-start: 0;
|
||||
@@ -468,26 +647,6 @@ ${
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane .card-content {
|
||||
overflow: auto;
|
||||
max-height: calc(
|
||||
var(--code-mirror-max-height) +
|
||||
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
|
||||
);
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
}
|
||||
|
||||
.all_listeners {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
@@ -507,19 +666,6 @@ ${
|
||||
background-color: var(--secondary-background-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
.content ha-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.card-actions > ha-button:last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -615,7 +761,7 @@ ${
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage["panel-dev-template-template"] = this._template;
|
||||
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
|
||||
}
|
||||
|
||||
private async _restoreDemo() {
|
||||
@@ -631,7 +777,7 @@ ${
|
||||
}
|
||||
this._template = DEMO_TEMPLATE;
|
||||
this._subscribeTemplate();
|
||||
delete localStorage["panel-dev-template-template"];
|
||||
delete localStorage[STORAGE_KEY_TEMPLATE];
|
||||
}
|
||||
|
||||
private async _clear() {
|
||||
@@ -647,12 +793,8 @@ ${
|
||||
}
|
||||
this._unsubscribeTemplate();
|
||||
this._template = "";
|
||||
// Reset to empty result. Setting to 'undefined' results in a different visual
|
||||
// behaviour compared to manually emptying the template input box.
|
||||
this._templateResult = {
|
||||
result: "",
|
||||
listeners: { all: false, entities: [], domains: [], time: false },
|
||||
};
|
||||
// An empty template shows the placeholder empty state.
|
||||
this._templateResult = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,19 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
|
||||
() => undefined
|
||||
);
|
||||
void import("../lovelace/strategies/home/home-overview-view-strategy")
|
||||
.then(({ preloadHomeEnergyPreferences }) => {
|
||||
const path = this.route?.path?.split("/")[1];
|
||||
return !path || path === "overview"
|
||||
? preloadHomeEnergyPreferences(this.hass)
|
||||
: undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
|
||||
|
||||
this._updateExtraActionItems();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
|
||||
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
|
||||
|
||||
render() {
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
|
||||
return html`
|
||||
<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
error="Unable to load iframes with this URL."
|
||||
rootnav
|
||||
></hass-error-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
if (
|
||||
location.protocol === "https:" &&
|
||||
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelLight extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelLight extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
"entity_id",
|
||||
"state",
|
||||
"event_type",
|
||||
"name",
|
||||
"message",
|
||||
"source",
|
||||
"context_id",
|
||||
"context_user_id",
|
||||
"context_event_type",
|
||||
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
d.entity_id || "",
|
||||
csvSafeString(d.state),
|
||||
csvSafeString(d.attributes?.event_type),
|
||||
csvSafeString(d.name),
|
||||
csvSafeString(d.message),
|
||||
csvSafeString(d.source),
|
||||
d.context_id || "",
|
||||
d.context_user_id || "",
|
||||
csvSafeString(d.context_event_type),
|
||||
|
||||
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
${
|
||||
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${Boolean(this._hasCardAction)}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info
|
||||
slot="info"
|
||||
class=${ifDefined(
|
||||
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
|
||||
)}
|
||||
.primary=${primary}
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
@@ -818,6 +825,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
ha-tile-info.twoline::part(primary) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
|
||||
throw new Error("URL required");
|
||||
}
|
||||
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(config.url) === "about:blank") {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: ShortcutCardConfig;
|
||||
|
||||
private _navInfo = new NavigationPathInfoController(this);
|
||||
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const style = color ? { "--tile-color": color } : {};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/animation/ha-fade-in";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -38,7 +39,9 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: TileCardConfig;
|
||||
|
||||
@state() private _featureContext: LovelaceCardFeatureContext = {};
|
||||
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { migrateStateColorConfig } from "../common/entity-color-config";
|
||||
import { migrateTimeFormatConfig } from "../common/entity-time-format-config";
|
||||
import { migrateSecondaryInfoConfig } from "../common/entity-secondary-info-config";
|
||||
import type {
|
||||
ConditionalRowConfig,
|
||||
LovelaceRowConfig,
|
||||
@@ -20,11 +21,13 @@ const migrateEntitiesRowConfig = (
|
||||
let newConf: LovelaceRowConfig = rowConf;
|
||||
newConf = migrateTimeFormatConfig(newConf as EntitiesCardEntityConfig);
|
||||
newConf = migrateStateColorConfig(newConf as EntitiesCardEntityConfig);
|
||||
newConf = migrateSecondaryInfoConfig(newConf as EntitiesCardEntityConfig);
|
||||
if (newConf.type === "conditional") {
|
||||
const row = (newConf as ConditionalRowConfig).row;
|
||||
if (row && typeof row === "object") {
|
||||
let newRow = migrateTimeFormatConfig(row as EntitiesCardEntityConfig);
|
||||
newRow = migrateStateColorConfig(newRow);
|
||||
newRow = migrateSecondaryInfoConfig(newRow);
|
||||
if (newRow !== row) {
|
||||
newConf = { ...newConf, row: newRow } as ConditionalRowConfig;
|
||||
}
|
||||
|
||||
@@ -91,16 +91,7 @@ export interface EntityCardConfig extends LovelaceCardConfig {
|
||||
|
||||
export interface EntitiesCardEntityConfig extends EntityConfig {
|
||||
type?: string;
|
||||
secondary_info?:
|
||||
| "entity-id"
|
||||
| "last-changed"
|
||||
| "last-triggered"
|
||||
| "last-updated"
|
||||
| "area"
|
||||
| "position"
|
||||
| "state"
|
||||
| "tilt-position"
|
||||
| "brightness";
|
||||
secondary_info?: string | string[];
|
||||
action_name?: string;
|
||||
action?: string;
|
||||
/** @deprecated use "action" instead */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isCustomType } from "../../../data/lovelace_custom_cards";
|
||||
|
||||
interface LegacySecondaryInfoConfig {
|
||||
type?: string;
|
||||
secondary_info?: string | string[];
|
||||
}
|
||||
|
||||
const SUBSTITUTIONS = {
|
||||
"last-changed": "last_changed",
|
||||
"last-updated": "last_updated",
|
||||
"last-triggered": "last_triggered",
|
||||
position: "current_position",
|
||||
"tilt-position": "current_tilt_position",
|
||||
area: "area_name",
|
||||
none: undefined,
|
||||
};
|
||||
|
||||
export const migrateSecondaryInfoConfig = <T extends LegacySecondaryInfoConfig>(
|
||||
config: T
|
||||
): T => {
|
||||
// Custom elements own their config schema and may use the same option with
|
||||
// a different meaning, leave them untouched
|
||||
if (config.type !== undefined && isCustomType(config.type)) {
|
||||
return config;
|
||||
}
|
||||
if (
|
||||
config.secondary_info === undefined ||
|
||||
typeof config.secondary_info !== "string" ||
|
||||
!(config.secondary_info in SUBSTITUTIONS)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
const { secondary_info, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
...(SUBSTITUTIONS[secondary_info] && {
|
||||
secondary_info: SUBSTITUTIONS[secondary_info],
|
||||
}),
|
||||
} as T;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
@@ -133,7 +134,7 @@ export const handleAction = async (
|
||||
break;
|
||||
case "url": {
|
||||
if (actionConfig.url_path) {
|
||||
window.open(actionConfig.url_path);
|
||||
window.open(sanitizeUrl(actionConfig.url_path));
|
||||
} else {
|
||||
showToast(node, {
|
||||
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
|
||||
|
||||
@@ -4,13 +4,9 @@ import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { DOMAINS_INPUT_ROW } from "../../../common/const";
|
||||
import { uid } from "../../../common/util/uid";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { toggleAttribute } from "../../../common/dom/toggle_attribute";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { getEntityContext } from "../../../common/entity/context/get_entity_context";
|
||||
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-relative-time";
|
||||
import "../../../components/ha-tooltip";
|
||||
@@ -21,6 +17,7 @@ import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
import { createEntityNotFoundWarning } from "./hui-warning";
|
||||
import "../../../state-display/state-display";
|
||||
|
||||
@customElement("hui-generic-entity-row")
|
||||
export class HuiGenericEntityRow extends LitElement {
|
||||
@@ -40,8 +37,6 @@ export class HuiGenericEntityRow extends LitElement {
|
||||
@property({ attribute: "catch-interaction", type: Boolean })
|
||||
public catchInteraction?;
|
||||
|
||||
private _secondaryInfoElementId = "-" + uid();
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this.config) {
|
||||
return nothing;
|
||||
@@ -101,118 +96,15 @@ export class HuiGenericEntityRow extends LitElement {
|
||||
<div class="secondary">
|
||||
${
|
||||
this.secondaryText ||
|
||||
(this.config.secondary_info === "entity-id"
|
||||
? stateObj.entity_id
|
||||
: this.config.secondary_info === "last-changed"
|
||||
? html`
|
||||
<ha-tooltip
|
||||
for="last-changed${
|
||||
this._secondaryInfoElementId
|
||||
}"
|
||||
placement="right"
|
||||
>
|
||||
${formatDateTimeWithSeconds(
|
||||
new Date(stateObj.last_changed),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-relative-time
|
||||
id="last-changed${this._secondaryInfoElementId}"
|
||||
.datetime=${stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
`
|
||||
: this.config.secondary_info === "last-updated"
|
||||
? html`
|
||||
<ha-tooltip
|
||||
for="last-updated${
|
||||
this._secondaryInfoElementId
|
||||
}"
|
||||
placement="right"
|
||||
>
|
||||
${formatDateTimeWithSeconds(
|
||||
new Date(stateObj.last_updated),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-relative-time
|
||||
id="last-updated${
|
||||
this._secondaryInfoElementId
|
||||
}"
|
||||
.datetime=${stateObj.last_updated}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
`
|
||||
: this.config.secondary_info ===
|
||||
"last-triggered"
|
||||
? stateObj.attributes.last_triggered
|
||||
? html`
|
||||
<ha-tooltip
|
||||
for="last-triggered${
|
||||
this._secondaryInfoElementId
|
||||
}"
|
||||
placement="right"
|
||||
>
|
||||
${formatDateTimeWithSeconds(
|
||||
new Date(
|
||||
stateObj.attributes
|
||||
.last_triggered
|
||||
),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-relative-time
|
||||
id="last-triggered${
|
||||
this._secondaryInfoElementId
|
||||
}"
|
||||
.datetime=${
|
||||
stateObj.attributes.last_triggered
|
||||
}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.entities.never_triggered"
|
||||
)
|
||||
: this.config.secondary_info ===
|
||||
"position" &&
|
||||
stateObj.attributes.current_position !==
|
||||
undefined
|
||||
? `${this.hass.localize(
|
||||
"ui.card.cover.position"
|
||||
)}: ${stateObj.attributes.current_position}`
|
||||
: this.config.secondary_info ===
|
||||
"tilt-position" &&
|
||||
stateObj.attributes
|
||||
.current_tilt_position !== undefined
|
||||
? `${this.hass.localize(
|
||||
"ui.card.cover.tilt_position"
|
||||
)}: ${
|
||||
stateObj.attributes
|
||||
.current_tilt_position
|
||||
}`
|
||||
: this.config.secondary_info ===
|
||||
"brightness" &&
|
||||
stateObj.attributes.brightness
|
||||
? html`${Math.round(
|
||||
(stateObj.attributes.brightness /
|
||||
255) *
|
||||
100
|
||||
)}
|
||||
%`
|
||||
: this.config.secondary_info ===
|
||||
"state"
|
||||
? html`${this.hass.formatEntityState(
|
||||
stateObj
|
||||
)}`
|
||||
: this.config.secondary_info ===
|
||||
"area"
|
||||
? (this._getArea(stateObj) ??
|
||||
nothing)
|
||||
: nothing)
|
||||
html`<state-display
|
||||
.stateObj=${stateObj}
|
||||
.hass=${this.hass}
|
||||
.content=${this.config.secondary_info}
|
||||
.timeFormat=${this.config.time_format}
|
||||
.name=${name}
|
||||
timestamp-tooltip
|
||||
>
|
||||
</state-display>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -253,17 +145,6 @@ export class HuiGenericEntityRow extends LitElement {
|
||||
handleAction(this, this.hass!, this.config!, ev.detail.action!);
|
||||
}
|
||||
|
||||
private _getArea(stateObj) {
|
||||
const context = getEntityContext(
|
||||
stateObj,
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
return context.area ? computeAreaName(context.area) : undefined;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDateTimeNumeric,
|
||||
formatDateTimeWithSeconds,
|
||||
} from "../../../common/datetime/format_date_time";
|
||||
import {
|
||||
formatTime,
|
||||
@@ -29,6 +30,8 @@ import type {
|
||||
} from "../../../types";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type { TimestampRenderingFormat } from "./types";
|
||||
import { uid } from "../../../common/util/uid";
|
||||
import "../../../components/ha-tooltip";
|
||||
|
||||
const FORMATS: Record<
|
||||
string,
|
||||
@@ -60,6 +63,8 @@ class HuiTimestampDisplay extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public capitalize = false;
|
||||
|
||||
@property({ type: Boolean }) public tooltip = false;
|
||||
|
||||
@state() private _relative?: string;
|
||||
|
||||
@state()
|
||||
@@ -87,6 +92,8 @@ class HuiTimestampDisplay extends LitElement {
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _uid = "timestamp-display-" + uid();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._connected = true;
|
||||
@@ -114,6 +121,14 @@ class HuiTimestampDisplay extends LitElement {
|
||||
const formatType = this._formatType;
|
||||
|
||||
if (INTERVAL_FORMAT.includes(formatType)) {
|
||||
if (this.tooltip) {
|
||||
return html`
|
||||
<ha-tooltip for=${this._uid} placement="right">
|
||||
${formatDateTimeWithSeconds(this.ts, this._locale, this._config)}
|
||||
</ha-tooltip>
|
||||
<span id=${this._uid}> ${this._relative} </span>
|
||||
`;
|
||||
}
|
||||
return html` ${this._relative} `;
|
||||
}
|
||||
if (formatType in FORMATS) {
|
||||
@@ -123,9 +138,20 @@ class HuiTimestampDisplay extends LitElement {
|
||||
: format.style && format.style in FORMATS[formatType]
|
||||
? format.style
|
||||
: "default";
|
||||
return html`
|
||||
${FORMATS[formatType][style](this.ts, this._locale, this._config)}
|
||||
`;
|
||||
const formatted = FORMATS[formatType][style](
|
||||
this.ts,
|
||||
this._locale,
|
||||
this._config
|
||||
);
|
||||
if (this.tooltip) {
|
||||
return html`
|
||||
<ha-tooltip for=${this._uid} placement="right">
|
||||
${formatDateTimeWithSeconds(this.ts, this._locale, this._config)}
|
||||
</ha-tooltip>
|
||||
<span id=${this._uid}> ${formatted} </span>
|
||||
`;
|
||||
}
|
||||
return html` ${formatted} `;
|
||||
}
|
||||
return html`${this._localize(
|
||||
"ui.panel.lovelace.components.timestamp-display.invalid_format"
|
||||
|
||||
@@ -4,12 +4,8 @@ import memoizeOne from "memoize-one";
|
||||
import { assert } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../../../data/sensor";
|
||||
import type { EntitiesCardEntityConfig } from "../../cards/types";
|
||||
@@ -17,19 +13,7 @@ import type { LovelaceRowEditor } from "../../types";
|
||||
import { entitiesConfigStruct } from "../structs/entities-struct";
|
||||
import { DOMAIN_TO_ELEMENT_TYPE } from "../../create-element/create-row-element";
|
||||
import { TIMESTAMP_STATE_DOMAINS } from "../../../../common/const";
|
||||
|
||||
const SECONDARY_INFO_VALUES = {
|
||||
none: {},
|
||||
"entity-id": {},
|
||||
"last-changed": {},
|
||||
"last-updated": {},
|
||||
"last-triggered": { domains: ["automation", "script"] },
|
||||
area: {},
|
||||
position: { domains: ["cover"] },
|
||||
state: {},
|
||||
"tilt-position": { domains: ["cover"] },
|
||||
brightness: { domains: ["light"] },
|
||||
};
|
||||
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
|
||||
|
||||
@customElement("hui-generic-entity-row-editor")
|
||||
export class HuiGenericEntityRowEditor
|
||||
@@ -47,96 +31,87 @@ export class HuiGenericEntityRowEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(entity: string, localize: LocalizeFunc, showTimeFormat?: boolean) => {
|
||||
const domain = computeDomain(entity);
|
||||
|
||||
return [
|
||||
{ name: "entity", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: {
|
||||
icon_entity: "entity",
|
||||
},
|
||||
private _schema = memoizeOne((showTimeFormat?: boolean) => {
|
||||
return [
|
||||
{ name: "entity", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_state: true,
|
||||
include_none: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secondary_info",
|
||||
selector: {
|
||||
select: {
|
||||
options: (
|
||||
Object.keys(SECONDARY_INFO_VALUES).filter(
|
||||
(info) =>
|
||||
!("domains" in SECONDARY_INFO_VALUES[info]) ||
|
||||
("domains" in SECONDARY_INFO_VALUES[info] &&
|
||||
SECONDARY_INFO_VALUES[info].domains!.includes(domain))
|
||||
) as (keyof typeof SECONDARY_INFO_VALUES)[]
|
||||
).map((info) => ({
|
||||
value: info,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.entities.secondary_info_values.${info}`
|
||||
),
|
||||
})),
|
||||
context: {
|
||||
icon_entity: "entity",
|
||||
},
|
||||
},
|
||||
},
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_state: true,
|
||||
include_none: true,
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
] as const;
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secondary_info",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const entity = this._config.entity;
|
||||
const domain = entity ? computeDomain(entity) : "";
|
||||
const simpleEntity =
|
||||
(DOMAIN_TO_ELEMENT_TYPE[domain] ||
|
||||
DOMAIN_TO_ELEMENT_TYPE["_domain_not_found"]) === "simple";
|
||||
const showTimeFormat = simpleEntity
|
||||
? TIMESTAMP_STATE_DOMAINS.has(domain)
|
||||
: domain === "event" ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
|
||||
this.hass.states[entity]?.attributes.device_class
|
||||
));
|
||||
|
||||
const schema =
|
||||
this.schema ||
|
||||
this._schema(this._config.entity, this.hass.localize, showTimeFormat);
|
||||
let schema = this.schema;
|
||||
if (!schema) {
|
||||
const entity = this._config.entity;
|
||||
const domain = entity ? computeDomain(entity) : "";
|
||||
const simpleEntity =
|
||||
(DOMAIN_TO_ELEMENT_TYPE[domain] ||
|
||||
DOMAIN_TO_ELEMENT_TYPE["_domain_not_found"]) === "simple";
|
||||
const showTimeFormat =
|
||||
(this._config.secondary_info &&
|
||||
stateContentHasTimestamp(
|
||||
entity,
|
||||
this.hass.states[entity],
|
||||
this._config.secondary_info
|
||||
)) ||
|
||||
(simpleEntity
|
||||
? TIMESTAMP_STATE_DOMAINS.has(domain)
|
||||
: domain === "event" ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
|
||||
this.hass.states[entity]?.attributes.device_class
|
||||
)));
|
||||
schema = this._schema(showTimeFormat);
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
|
||||
}
|
||||
|
||||
const data: NumericInputCardFeatureConfig = {
|
||||
style: "buttons",
|
||||
style: "slider",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import {
|
||||
getCustomBadgeEntry,
|
||||
getCustomCardEntry,
|
||||
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
|
||||
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user