diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 91f8252e8fcd..676e27e6d9b9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -100,6 +100,38 @@ "instanceLimit": 1 } }, + { + "label": "Develop Landing Page", + "type": "gulp", + "task": "develop-landing-page", + "problemMatcher": { + "owner": "ha-build", + "source": "ha-build", + "fileLocation": "absolute", + "severity": "error", + "pattern": [ + { + "regexp": "(SyntaxError): (.+): (.+) \\((\\d+):(\\d+)\\)", + "severity": 1, + "file": 2, + "message": 3, + "line": 4, + "column": 5 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "Changes detected. Starting compilation", + "endsPattern": "Build done @" + } + }, + + "isBackground": true, + "group": "build", + "runOptions": { + "instanceLimit": 1 + } + }, { "label": "Develop Demo", "type": "gulp", diff --git a/build-scripts/bundle.cjs b/build-scripts/bundle.cjs index 6b2f308b3d3a..d105a512e6e9 100644 --- a/build-scripts/bundle.cjs +++ b/build-scripts/bundle.cjs @@ -327,4 +327,17 @@ module.exports.config = { }, }; }, + + landingPage({ isProdBuild, latestBuild }) { + return { + name: "landing-page" + nameSuffix(latestBuild), + entry: { + entrypoint: path.resolve(paths.landingPage_dir, "src/entrypoint.js"), + }, + outputPath: outputPath(paths.landingPage_output_root, latestBuild), + publicPath: publicPath(latestBuild), + isProdBuild, + latestBuild, + }; + }, }; diff --git a/build-scripts/gulp/clean.js b/build-scripts/gulp/clean.js index 9a21f6ccd8c3..b7c570c9d492 100644 --- a/build-scripts/gulp/clean.js +++ b/build-scripts/gulp/clean.js @@ -38,3 +38,14 @@ gulp.task( ]) ) ); + +gulp.task( + "clean-landing-page", + gulp.parallel("clean-translations", async () => + deleteSync([ + paths.landingPage_output_root, + paths.landingPage_build, + paths.build_dir, + ]) + ) +); diff --git a/build-scripts/gulp/entry-html.js b/build-scripts/gulp/entry-html.js index d8bf587f6c01..ec7da3c1c025 100644 --- a/build-scripts/gulp/entry-html.js +++ b/build-scripts/gulp/entry-html.js @@ -257,6 +257,28 @@ gulp.task( ) ); +const LANDING_PAGE_PAGE_ENTRIES = { "index.html": ["entrypoint"] }; + +gulp.task( + "gen-pages-landing-page-dev", + genPagesDevTask( + LANDING_PAGE_PAGE_ENTRIES, + paths.landingPage_dir, + paths.landingPage_output_root + ) +); + +gulp.task( + "gen-pages-landing-page-prod", + genPagesProdTask( + LANDING_PAGE_PAGE_ENTRIES, + paths.landingPage_dir, + paths.landingPage_output_root, + paths.landingPage_output_latest, + paths.landingPage_output_es5 + ) +); + const HASSIO_PAGE_ENTRIES = { "entrypoint.js": ["entrypoint"] }; gulp.task( diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js index 653e863dcde7..48b90cf02974 100644 --- a/build-scripts/gulp/gather-static.js +++ b/build-scripts/gulp/gather-static.js @@ -125,6 +125,11 @@ gulp.task("copy-translations-supervisor", async () => { copyTranslations(staticDir); }); +gulp.task("copy-translations-landing-page", async () => { + const staticDir = paths.landingPage_output_static; + copyTranslations(staticDir); +}); + gulp.task("copy-static-supervisor", async () => { const staticDir = paths.hassio_output_static; copyLocaleData(staticDir); @@ -199,3 +204,14 @@ gulp.task("copy-static-gallery", async () => { copyLocaleData(paths.gallery_output_static); copyMdiIcons(paths.gallery_output_static); }); + +gulp.task("copy-static-landing-page", async () => { + // Copy landing-page static files + fs.copySync( + path.resolve(paths.landingPage_dir, "public"), + paths.landingPage_output_root + ); + + copyFonts(paths.landingPage_output_static); + copyTranslations(paths.landingPage_output_static); +}); diff --git a/build-scripts/gulp/landing-page.js b/build-scripts/gulp/landing-page.js new file mode 100644 index 000000000000..3a8f38749354 --- /dev/null +++ b/build-scripts/gulp/landing-page.js @@ -0,0 +1,41 @@ +import gulp from "gulp"; +import "./clean.js"; +import "./compress.js"; +import "./entry-html.js"; +import "./gather-static.js"; +import "./gen-icons-json.js"; +import "./translations.js"; +import "./webpack.js"; + +gulp.task( + "develop-landing-page", + gulp.series( + async function setEnv() { + process.env.NODE_ENV = "development"; + }, + "clean-landing-page", + "translations-enable-merge-backend", + "build-landing-page-translations", + "copy-translations-landing-page", + "build-locale-data", + "copy-static-landing-page", + "gen-pages-landing-page-dev", + "webpack-watch-landing-page" + ) +); + +gulp.task( + "build-landing-page", + gulp.series( + async function setEnv() { + process.env.NODE_ENV = "production"; + }, + "clean-landing-page", + "build-landing-page-translations", + "copy-translations-landing-page", + "build-locale-data", + "copy-static-landing-page", + "webpack-prod-landing-page", + "gen-pages-landing-page-prod" + ) +); diff --git a/build-scripts/gulp/translations.js b/build-scripts/gulp/translations.js index 1127a5d3f5eb..059babc09215 100755 --- a/build-scripts/gulp/translations.js +++ b/build-scripts/gulp/translations.js @@ -172,12 +172,14 @@ const createMasterTranslation = () => const FRAGMENTS = ["base"]; -const toggleSupervisorFragment = async () => { - FRAGMENTS[0] = "supervisor"; +const setFragment = (fragment) => async () => { + FRAGMENTS[0] = fragment; }; const panelFragment = (fragment) => - fragment !== "base" && fragment !== "supervisor"; + fragment !== "base" && + fragment !== "supervisor" && + fragment !== "landing-page"; const HASHES = new Map(); @@ -224,6 +226,9 @@ const createTranslations = async () => { case "supervisor": // Supervisor key is at the top level return [flatten(data.supervisor), ""]; + case "landing-page": + // landing-page key is at the top level + return [flatten(data["landing-page"]), ""]; default: // Create a fragment with only the given panel return [ @@ -322,5 +327,10 @@ gulp.task( gulp.task( "build-supervisor-translations", - gulp.series(toggleSupervisorFragment, "build-translations") + gulp.series(setFragment("supervisor"), "build-translations") +); + +gulp.task( + "build-landing-page-translations", + gulp.series(setFragment("landing-page"), "build-translations") ); diff --git a/build-scripts/gulp/webpack.js b/build-scripts/gulp/webpack.js index a7eb3be20d8e..f46ce61bfd1a 100644 --- a/build-scripts/gulp/webpack.js +++ b/build-scripts/gulp/webpack.js @@ -14,6 +14,7 @@ import { createDemoConfig, createGalleryConfig, createHassioConfig, + createLandingPageConfig, } from "../webpack.cjs"; const bothBuilds = (createConfigFunc, params) => [ @@ -41,6 +42,7 @@ const runDevServer = async ({ contentBase, port, listenHost = undefined, + proxy = undefined, }) => { if (listenHost === undefined) { // For dev container, we need to listen on all hosts @@ -56,6 +58,7 @@ const runDevServer = async ({ directory: contentBase, watch: true, }, + proxy, }, compiler ); @@ -199,3 +202,30 @@ gulp.task("webpack-prod-gallery", () => }) ) ); + +gulp.task("webpack-watch-landing-page", () => { + // This command will run forever because we don't close compiler + webpack( + process.env.ES5 + ? bothBuilds(createLandingPageConfig, { isProdBuild: false }) + : createLandingPageConfig({ isProdBuild: false, latestBuild: true }) + ).watch({ poll: isWsl }, doneHandler()); + + gulp.watch( + path.join(paths.translations_src, "en.json"), + gulp.series( + "build-landing-page-translations", + "copy-translations-landing-page" + ) + ); +}); + +gulp.task("webpack-prod-landing-page", () => + prodBuild( + bothBuilds(createLandingPageConfig, { + isProdBuild: true, + isStatsBuild: env.isStatsBuild(), + isTestBuild: env.isTestBuild(), + }) + ) +); diff --git a/build-scripts/paths.cjs b/build-scripts/paths.cjs index dff1d4bca491..fc8357d94789 100644 --- a/build-scripts/paths.cjs +++ b/build-scripts/paths.cjs @@ -33,6 +33,22 @@ module.exports = { ), gallery_output_static: path.resolve(__dirname, "../gallery/dist/static"), + landingPage_dir: path.resolve(__dirname, "../landing-page"), + landingPage_build: path.resolve(__dirname, "../landing-page/build"), + landingPage_output_root: path.resolve(__dirname, "../landing-page/dist"), + landingPage_output_latest: path.resolve( + __dirname, + "../landing-page/dist/frontend_latest" + ), + landingPage_output_es5: path.resolve( + __dirname, + "../landing-page/dist/frontend_es5" + ), + landingPage_output_static: path.resolve( + __dirname, + "../landing-page/dist/static" + ), + hassio_dir: path.resolve(__dirname, "../hassio"), hassio_output_root: path.resolve(__dirname, "../hassio/build"), hassio_output_static: path.resolve(__dirname, "../hassio/build/static"), diff --git a/build-scripts/webpack.cjs b/build-scripts/webpack.cjs index 83f54fb58453..1d5ac1bb9482 100644 --- a/build-scripts/webpack.cjs +++ b/build-scripts/webpack.cjs @@ -283,11 +283,15 @@ const createHassioConfig = ({ const createGalleryConfig = ({ isProdBuild, latestBuild }) => createWebpackConfig(bundle.config.gallery({ isProdBuild, latestBuild })); +const createLandingPageConfig = ({ isProdBuild, latestBuild }) => + createWebpackConfig(bundle.config.landingPage({ isProdBuild, latestBuild })); + module.exports = { createAppConfig, createDemoConfig, createCastConfig, createHassioConfig, createGalleryConfig, + createLandingPageConfig, createWebpackConfig, }; diff --git a/landing-page/README.md b/landing-page/README.md new file mode 100644 index 000000000000..005bfa3d8318 --- /dev/null +++ b/landing-page/README.md @@ -0,0 +1,44 @@ +# Home Assistant OS Landingpage + +On initial startup of Home Assistant, HAOS needs to download Home Assistant core before the setup can start. +In this time the [home-assistant/landingpage](https://github.com/home-assistant/landingpage) is serving a "Preparing Home Assistant" page. + +## Functionality + +- Progress bar to show download +- Show / hide supervisor logs +- Links + - Read our Vision + - Join our community + - Download our app +- DNS issue handler + - if the supervisor is not able to connect to the internet + - Show actions to set dns to google or cloudflare to resolve the issue +- Error handler + - if something with the installation goes wrong, we show the logs + +## Develop + +It is similar to the core frontend dev. + +- frontend repo is building stuff +- landingpage repo can set the frontend repo path and serve the dev frontend + +### landingpage dev server + +- clone [home-assistant/landingpage](https://github.com/home-assistant/landingpage) +- Add frontend repo as mount to your devcontainer config + - please do not commit this changes, you can remove it after initial dev container build, because the build will keep the options as long as you don't rebuild it. + - `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]` +- use the dev container +- start the dev server with following optional env vars: + - `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888` + - `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs + - `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend` + - example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go` + - You can also add this into your devcontainer settings, but then it's not so flexible to change if you want to test something else. + +### frontend dev server + +- install all dependencies +- run `landing-page/script/develop` diff --git a/landing-page/eslintrc.config.mjs b/landing-page/eslintrc.config.mjs new file mode 100644 index 000000000000..faf25c873f8e --- /dev/null +++ b/landing-page/eslintrc.config.mjs @@ -0,0 +1,8 @@ +import rootConfig from "../eslint.config.mjs"; + +export default [ + ...rootConfig, + { + rules: {}, + }, +]; diff --git a/landing-page/public/static/icons/favicon-192x192.png b/landing-page/public/static/icons/favicon-192x192.png new file mode 100644 index 000000000000..5f644d2083c3 Binary files /dev/null and b/landing-page/public/static/icons/favicon-192x192.png differ diff --git a/landing-page/public/static/icons/favicon.ico b/landing-page/public/static/icons/favicon.ico new file mode 100644 index 000000000000..bed404857362 Binary files /dev/null and b/landing-page/public/static/icons/favicon.ico differ diff --git a/landing-page/public/static/icons/logo_ohf.svg b/landing-page/public/static/icons/logo_ohf.svg new file mode 100644 index 000000000000..151a7a82733c --- /dev/null +++ b/landing-page/public/static/icons/logo_ohf.svg @@ -0,0 +1,3 @@ + + + diff --git a/landing-page/public/static/icons/ohf.svg b/landing-page/public/static/icons/ohf.svg new file mode 100644 index 000000000000..b6ef1172ca95 --- /dev/null +++ b/landing-page/public/static/icons/ohf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing-page/public/static/images/appstore.svg b/landing-page/public/static/images/appstore.svg new file mode 100644 index 000000000000..da50ed4ce73e --- /dev/null +++ b/landing-page/public/static/images/appstore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing-page/public/static/images/logo_discord.png b/landing-page/public/static/images/logo_discord.png new file mode 100644 index 000000000000..24e567e1eba2 Binary files /dev/null and b/landing-page/public/static/images/logo_discord.png differ diff --git a/landing-page/public/static/images/logo_mastodon.svg b/landing-page/public/static/images/logo_mastodon.svg new file mode 100644 index 000000000000..0f8baebfc9cb --- /dev/null +++ b/landing-page/public/static/images/logo_mastodon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/landing-page/public/static/images/logo_x.svg b/landing-page/public/static/images/logo_x.svg new file mode 100644 index 000000000000..437e2bfddbbc --- /dev/null +++ b/landing-page/public/static/images/logo_x.svg @@ -0,0 +1,3 @@ + + + diff --git a/landing-page/public/static/images/playstore.svg b/landing-page/public/static/images/playstore.svg new file mode 100644 index 000000000000..7528a3d650cb --- /dev/null +++ b/landing-page/public/static/images/playstore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing-page/public/static/images/qr-appstore.svg b/landing-page/public/static/images/qr-appstore.svg new file mode 100644 index 000000000000..3c6605bb8d1d --- /dev/null +++ b/landing-page/public/static/images/qr-appstore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing-page/public/static/images/qr-playstore.svg b/landing-page/public/static/images/qr-playstore.svg new file mode 100644 index 000000000000..af088506ae9f --- /dev/null +++ b/landing-page/public/static/images/qr-playstore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing-page/script/build_landing_page b/landing-page/script/build_landing_page new file mode 100755 index 000000000000..e9e0102df8cd --- /dev/null +++ b/landing-page/script/build_landing_page @@ -0,0 +1,9 @@ +#!/bin/sh +# Run the landing-page + +# Stop on errors +set -e + +cd "$(dirname "$0")/../.." + +./node_modules/.bin/gulp build-landing-page diff --git a/landing-page/script/develop b/landing-page/script/develop new file mode 100755 index 000000000000..4ec2870060ba --- /dev/null +++ b/landing-page/script/develop @@ -0,0 +1,9 @@ +#!/bin/sh +# Run the landing-page + +# Stop on errors +set -e + +cd "$(dirname "$0")/../.." + +./node_modules/.bin/gulp develop-landing-page diff --git a/landing-page/src/components/landing-page-logs.ts b/landing-page/src/components/landing-page-logs.ts new file mode 100644 index 000000000000..80fe1945d260 --- /dev/null +++ b/landing-page/src/components/landing-page-logs.ts @@ -0,0 +1,334 @@ +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js"; +// eslint-disable-next-line import/extensions +import { IntersectionController } from "@lit-labs/observers/intersection-controller.js"; +import { LitElement, type PropertyValues, css, html, nothing } from "lit"; +import { classMap } from "lit/directives/class-map"; +import { customElement, property, query, state } from "lit/decorators"; +import type { + LandingPageKeys, + LocalizeFunc, +} from "../../../src/common/translations/localize"; +import "../../../src/components/ha-button"; +import "../../../src/components/ha-icon-button"; +import "../../../src/components/ha-svg-icon"; +import "../../../src/components/ha-ansi-to-html"; +import "../../../src/components/ha-alert"; +import type { HaAnsiToHtml } from "../../../src/components/ha-ansi-to-html"; +import { + getObserverLogs, + downloadUrl as observerLogsDownloadUrl, +} from "../data/observer"; +import { fireEvent } from "../../../src/common/dom/fire_event"; +import { fileDownload } from "../../../src/util/file_download"; +import { getSupervisorLogs, getSupervisorLogsFollow } from "../data/supervisor"; + +const ERROR_CHECK = /^[\d\s-:]+(ERROR|CRITICAL)(.*)/gm; +declare global { + interface HASSDomEvents { + "landing-page-error": undefined; + } +} + +const SCHEDULE_FETCH_OBSERVER_LOGS = 5; + +@customElement("landing-page-logs") +class LandingPageLogs extends LitElement { + @property({ attribute: false }) + public localize!: LocalizeFunc; + + @query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml; + + @query(".logs") private _logElement?: HTMLElement; + + @query("#scroll-bottom-marker") + private _scrollBottomMarkerElement?: HTMLElement; + + @state() private _show = false; + + @state() private _scrolledToBottomController = + new IntersectionController(this, { + callback(this: IntersectionController, entries) { + return entries[0].isIntersecting; + }, + }); + + @state() private _error = false; + + @state() private _newLogsIndicator?: boolean; + + @state() private _logLinesCount = 0; + + protected render() { + return html` +
+ + ${this.localize(this._show ? "hide_details" : "show_details")} + + ${this._show + ? html`` + : nothing} +
+ ${this._error + ? html` + + + ${this.localize("logs.retry")} + + + ` + : nothing} +
+ +
+
+ + + ${this.localize("logs.scroll_down_button")} + + + `; + } + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + + this._scrolledToBottomController.observe(this._scrollBottomMarkerElement!); + + this._startLogStream(); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + + if (this._newLogsIndicator && this._scrolledToBottomController.value) { + this._newLogsIndicator = false; + } + + if (changedProps.has("_show") && this._show) { + this._scrollToBottom(); + } + } + + private _toggleLogDetails() { + this._show = !this._show; + } + + private _scrollToBottom(): void { + if (this._logElement) { + this._newLogsIndicator = false; + this._logElement!.scrollTo(0, this._logElement!.scrollHeight); + } + } + + private _displayLogs(logs: string, tempLogLine = "", clear = false): string { + if (clear) { + this._ansiToHtmlElement?.clear(); + this._logLinesCount = 0; + } + + const showError = ERROR_CHECK.test(logs); + + const scrolledToBottom = this._scrolledToBottomController.value; + const lines = `${tempLogLine}${logs}` + .split("\n") + .filter((line) => line.trim() !== ""); + + // handle edge case where the last line is not complete + if (logs.endsWith("\n")) { + tempLogLine = ""; + } else { + tempLogLine = lines.splice(-1, 1)[0]; + } + + if (lines.length) { + this._ansiToHtmlElement?.parseLinesToColoredPre(lines); + this._logLinesCount += lines.length; + } + + if (showError) { + fireEvent(this, "landing-page-error"); + this._show = true; + } + + if (showError || (scrolledToBottom && this._logElement)) { + this._scrollToBottom(); + } else { + this._newLogsIndicator = true; + } + + return tempLogLine; + } + + private async _startLogStream() { + this._error = false; + this._newLogsIndicator = false; + this._ansiToHtmlElement?.clear(); + + try { + const response = await getSupervisorLogsFollow(); + + if (!response.ok || !response.body) { + throw new Error("No stream body found"); + } + + let tempLogLine = ""; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + + while (!done) { + // eslint-disable-next-line no-await-in-loop + const { value, done: readerDone } = await reader.read(); + done = readerDone; + + if (value) { + const chunk = decoder.decode(value, { stream: !done }); + tempLogLine = this._displayLogs(chunk, tempLogLine); + } + } + } catch (err: any) { + // eslint-disable-next-line no-console + console.error(err); + + // fallback to observerlogs if there is a problem with supervisor + this._loadObserverLogs(); + } + } + + private _scheduleObserverLogs() { + setTimeout(async () => { + try { + // check if supervisor logs are available + const superVisorLogsResponse = await getSupervisorLogs(1); + if (superVisorLogsResponse.ok) { + this._startLogStream(); + return; + } + } catch (err) { + // ignore and continue with observer logs + } + this._loadObserverLogs(); + }, SCHEDULE_FETCH_OBSERVER_LOGS * 1000); + } + + private async _loadObserverLogs() { + try { + const response = await getObserverLogs(); + + if (!response.ok) { + throw new Error("Error fetching observer logs"); + } + + const logs = await response.text(); + + this._displayLogs(logs, "", true); + + this._scheduleObserverLogs(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + this._error = true; + } + } + + private _downloadLogs() { + const timeString = new Date().toISOString().replace(/:/g, "-"); + + fileDownload(observerLogsDownloadUrl, `observer_${timeString}.log`); + } + + static styles = [ + css` + :host { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + } + + ha-alert { + width: 100%; + } + + .actions { + position: relative; + width: 100%; + text-align: center; + } + + .actions ha-icon-button { + position: absolute; + right: 0; + top: -4px; + --icon-primary-color: var(--primary-color); + } + + .logs { + width: 100%; + max-height: 300px; + overflow: auto; + border: 1px solid var(--divider-color); + border-radius: 4px; + padding: 4px; + } + + .logs.hidden { + display: none; + } + + .new-logs-indicator { + --mdc-theme-primary: var(--text-primary-color); + + overflow: hidden; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 0; + background-color: var(--primary-color); + border-radius: 8px; + + transition: height 0.4s ease-out; + display: flex; + justify-content: space-between; + align-items: center; + } + + .new-logs-indicator.visible { + height: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "landing-page-logs": LandingPageLogs; + } +} diff --git a/landing-page/src/components/landing-page-network.ts b/landing-page/src/components/landing-page-network.ts new file mode 100644 index 000000000000..f4cfadd395e7 --- /dev/null +++ b/landing-page/src/components/landing-page-network.ts @@ -0,0 +1,173 @@ +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { + type CSSResultGroup, + LitElement, + type PropertyValues, + css, + html, + nothing, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import type { + LandingPageKeys, + LocalizeFunc, +} from "../../../src/common/translations/localize"; +import "../../../src/components/ha-button"; +import "../../../src/components/ha-alert"; +import { + ALTERNATIVE_DNS_SERVERS, + getSupervisorNetworkInfo, + setSupervisorNetworkDns, +} from "../data/supervisor"; +import { fireEvent } from "../../../src/common/dom/fire_event"; +import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; + +const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5; + +@customElement("landing-page-network") +class LandingPageNetwork extends LitElement { + @property({ attribute: false }) + public localize!: LocalizeFunc; + + @state() private _networkIssue = false; + + @state() private _getNetworkInfoError = false; + + @state() private _dnsPrimaryInterface?: string; + + protected render() { + if (!this._networkIssue && !this._getNetworkInfoError) { + return nothing; + } + + if (this._getNetworkInfoError) { + return html` + +

${this.localize("network_issue.error_get_network_info")}

+
+ `; + } + + return html` + +

+ ${this.localize("network_issue.description", { + dns: this._dnsPrimaryInterface || "?", + })} +

+

${this.localize("network_issue.resolve_different")}

+ ${!this._dnsPrimaryInterface + ? html` +

+ ${this.localize("network_issue.no_primary_interface")} +

+ ` + : nothing} +
+ ${ALTERNATIVE_DNS_SERVERS.map( + ({ translationKey }, key) => + html`${this.localize(translationKey)}` + )} +
+
+ `; + } + + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this._fetchSupervisorInfo(); + } + + private _scheduleFetchSupervisorInfo() { + setTimeout( + () => this._fetchSupervisorInfo(), + SCHEDULE_FETCH_NETWORK_INFO_SECONDS * 1000 + ); + } + + private async _fetchSupervisorInfo() { + let data; + try { + const response = await getSupervisorNetworkInfo(); + if (!response.ok) { + throw new Error("Failed to fetch network info"); + } + + ({ data } = await response.json()); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + this._getNetworkInfoError = true; + return; + } + + this._getNetworkInfoError = false; + + if (!data.host_internet) { + this._networkIssue = true; + const primaryInterface = data.interfaces.find( + (intf) => intf.primary && intf.enabled + ); + if (primaryInterface) { + this._dnsPrimaryInterface = [ + ...(primaryInterface.ipv4?.nameservers || []), + ...(primaryInterface.ipv6?.nameservers || []), + ].join(", "); + } + } else { + this._networkIssue = false; + } + + fireEvent(this, "value-changed", { + value: this._networkIssue, + }); + this._scheduleFetchSupervisorInfo(); + } + + private async _setDns(ev) { + const index = ev.target?.index; + try { + const response = await setSupervisorNetworkDns(index); + if (!response.ok) { + throw new Error("Failed to set DNS"); + } + this._networkIssue = false; + } catch (err: any) { + // eslint-disable-next-line no-console + console.error(err); + showAlertDialog(this, { + title: this.localize("network_issue.failed"), + warning: true, + text: `${this.localize( + "network_issue.set_dns_failed" + )}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`, + confirmText: this.localize("network_issue.close"), + }); + } + } + + static get styles(): CSSResultGroup { + return [ + css` + .actions { + display: flex; + justify-content: flex-end; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "landing-page-network": LandingPageNetwork; + } +} diff --git a/landing-page/src/data/observer.ts b/landing-page/src/data/observer.ts new file mode 100644 index 000000000000..93216304adb9 --- /dev/null +++ b/landing-page/src/data/observer.ts @@ -0,0 +1,5 @@ +export async function getObserverLogs() { + return fetch("/observer/logs"); +} + +export const downloadUrl = "/observer/logs"; diff --git a/landing-page/src/data/supervisor.ts b/landing-page/src/data/supervisor.ts new file mode 100644 index 000000000000..a2f78d3676ed --- /dev/null +++ b/landing-page/src/data/supervisor.ts @@ -0,0 +1,56 @@ +import type { LandingPageKeys } from "../../../src/common/translations/localize"; + +export const ALTERNATIVE_DNS_SERVERS: { + ipv4: string[]; + ipv6: string[]; + translationKey: LandingPageKeys; +}[] = [ + { + ipv4: ["1.1.1.1", "1.0.0.1"], + ipv6: ["2606:4700:4700::1111", "2606:4700:4700::1001"], + translationKey: "network_issue.use_cloudflare", + }, + { + ipv4: ["8.8.8.8", "8.8.4.4"], + ipv6: ["2001:4860:4860::8888", "2001:4860:4860::8844"], + translationKey: "network_issue.use_google", + }, +]; + +export async function getSupervisorLogs(lines = 100) { + return fetch(`/supervisor/supervisor/logs?lines=${lines}`, { + headers: { + Accept: "text/plain", + }, + }); +} + +export async function getSupervisorLogsFollow(lines = 500) { + return fetch(`/supervisor/supervisor/logs/follow?lines=${lines}`, { + headers: { + Accept: "text/plain", + }, + }); +} + +export async function getSupervisorNetworkInfo() { + return fetch("/supervisor/network/info"); +} + +export const setSupervisorNetworkDns = async (dnsServerIndex: number) => + fetch("/supervisor/network/dns", { + method: "POST", + body: JSON.stringify({ + ipv4: { + method: "auto", + nameservers: ALTERNATIVE_DNS_SERVERS[dnsServerIndex].ipv4, + }, + ipv6: { + method: "auto", + nameservers: ALTERNATIVE_DNS_SERVERS[dnsServerIndex].ipv6, + }, + }), + headers: { + "Content-Type": "application/json", + }, + }); diff --git a/landing-page/src/entrypoint.js b/landing-page/src/entrypoint.js new file mode 100644 index 000000000000..4bf2a33b141a --- /dev/null +++ b/landing-page/src/entrypoint.js @@ -0,0 +1,3 @@ +import "./ha-landing-page"; + +import("../../src/resources/ha-style"); diff --git a/landing-page/src/ha-landing-page.ts b/landing-page/src/ha-landing-page.ts new file mode 100644 index 000000000000..34ac09f43440 --- /dev/null +++ b/landing-page/src/ha-landing-page.ts @@ -0,0 +1,178 @@ +import "@material/mwc-linear-progress"; +import { type PropertyValues, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../src/components/ha-alert"; +import { haStyle } from "../../src/resources/styles"; +import "../../src/onboarding/onboarding-welcome-links"; +import "./components/landing-page-network"; +import "./components/landing-page-logs"; +import { extractSearchParam } from "../../src/common/url/search-params"; +import { onBoardingStyles } from "../../src/onboarding/styles"; +import { makeDialogManager } from "../../src/dialogs/make-dialog-manager"; +import { LandingPageBaseElement } from "./landing-page-base-element"; + +const SCHEDULE_CORE_CHECK_SECONDS = 5; + +@customElement("ha-landing-page") +class HaLandingPage extends LandingPageBaseElement { + @property({ attribute: false }) public translationFragment = "landing-page"; + + @state() private _networkIssue = false; + + @state() private _supervisorError = false; + + private _mobileApp = + extractSearchParam("redirect_uri") === "homeassistant://auth-callback"; + + render() { + return html` + +
+

${this.localize("header")}

+ ${!this._networkIssue && !this._supervisorError + ? html` +

${this.localize("subheader")}

+ + ` + : nothing} + + + ${this._supervisorError + ? html` + + ${this.localize("error_description")} + + ` + : nothing} + +
+
+ + + `; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + + makeDialogManager(this, this.shadowRoot!); + + if (window.innerWidth > 450) { + import("../../src/resources/particles"); + } + import("../../src/components/ha-language-picker"); + + this._scheduleCoreCheck(); + } + + private _scheduleCoreCheck() { + setTimeout( + () => this._checkCoreAvailability(), + SCHEDULE_CORE_CHECK_SECONDS * 1000 + ); + } + + private async _checkCoreAvailability() { + try { + const response = await fetch("/manifest.json"); + if (response.ok) { + location.reload(); + } + } finally { + this._scheduleCoreCheck(); + } + } + + private _showError() { + this._supervisorError = true; + } + + private _networkInfoChanged(ev: CustomEvent) { + this._networkIssue = ev.detail.value; + } + + private _languageChanged(ev: CustomEvent) { + const language = ev.detail.value; + if (language !== this.language && language) { + this.language = language; + try { + localStorage.setItem("selectedLanguage", JSON.stringify(language)); + } catch (err: any) { + // Ignore + } + } + } + + static styles = [ + haStyle, + onBoardingStyles, + css` + .footer { + padding-top: 8px; + display: flex; + justify-content: space-between; + align-items: center; + } + ha-card .card-content { + display: flex; + flex-direction: column; + gap: 16px; + } + ha-alert p { + text-align: unset; + } + ha-language-picker { + display: block; + width: 200px; + border-radius: 4px; + overflow: hidden; + --ha-select-height: 40px; + --mdc-select-fill-color: none; + --mdc-select-label-ink-color: var(--primary-text-color, #212121); + --mdc-select-ink-color: var(--primary-text-color, #212121); + --mdc-select-idle-line-color: transparent; + --mdc-select-hover-line-color: transparent; + --mdc-select-dropdown-icon-color: var(--primary-text-color, #212121); + --mdc-shape-small: 0; + } + a { + text-decoration: none; + color: var(--primary-text-color); + margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-landing-page": HaLandingPage; + } +} diff --git a/landing-page/src/html/index.html.template b/landing-page/src/html/index.html.template new file mode 100644 index 000000000000..2f2211c490da --- /dev/null +++ b/landing-page/src/html/index.html.template @@ -0,0 +1,53 @@ + + + + Home Assistant + <%= renderTemplate("../../../src/html/_header.html.template") %> + <%= renderTemplate("../../../src/html/_style_base.html.template") %> + + + +
+
+ Home Assistant +
+ +
+ <%= renderTemplate("../../../src/html/_js_base.html.template") %> + <%= renderTemplate("../../../src/html/_preload_roboto.html.template") %> + <%= renderTemplate("../../../src/html/_script_loader.html.template") %> + + diff --git a/landing-page/src/landing-page-base-element.ts b/landing-page/src/landing-page-base-element.ts new file mode 100644 index 000000000000..9c7a544ddfc9 --- /dev/null +++ b/landing-page/src/landing-page-base-element.ts @@ -0,0 +1,74 @@ +import type { PropertyValues } from "lit"; +import { LitElement } from "lit"; +import { property, state } from "lit/decorators"; +import { + computeLocalize, + type LandingPageKeys, + type LocalizeFunc, +} from "../../src/common/translations/localize"; +import { ProvideHassLitMixin } from "../../src/mixins/provide-hass-lit-mixin"; +import type { Constructor, Resources } from "../../src/types"; +import { + getLocalLanguage, + getTranslation, +} from "../../src/util/common-translation"; +import { computeDirectionStyles } from "../../src/common/util/compute_rtl"; +import themesMixin from "../../src/state/themes-mixin"; +import { translationMetadata } from "../../src/resources/translations-metadata"; +import type { HassBaseEl } from "../../src/state/hass-base-mixin"; + +export class LandingPageBaseElement extends themesMixin( + ProvideHassLitMixin(LitElement) as unknown as Constructor +) { + // Initialized to empty will prevent undefined errors if called before connected to DOM. + @property({ attribute: false }) + public localize: LocalizeFunc = () => ""; + + // Use browser language setup before login. + @property() public language?: string = getLocalLanguage(); + + @state() private _resources?: Resources; + + public connectedCallback(): void { + super.connectedCallback(); + this._initializeLocalize(); + } + + protected willUpdate(changedProperties: PropertyValues) { + if (changedProperties.get("language")) { + this._resources = undefined; + this._initializeLocalize(); + } + + if ( + this.language && + this._resources && + (changedProperties.has("language") || changedProperties.has("_resources")) + ) { + this._setLocalize(); + } + } + + private async _initializeLocalize() { + if (this._resources || !this.language) { + return; + } + + const { data } = await getTranslation(null, this.language); + this._resources = { + [this.language]: data, + }; + } + + private async _setLocalize() { + this.localize = await computeLocalize( + this.constructor.prototype, + this.language!, + this._resources! + ); + computeDirectionStyles( + translationMetadata.translations[this.language!].isRTL, + this + ); + } +} diff --git a/landing-page/webpack.config.js b/landing-page/webpack.config.js new file mode 100644 index 000000000000..2e3152dcee38 --- /dev/null +++ b/landing-page/webpack.config.js @@ -0,0 +1,8 @@ +import webpack from "../build-scripts/webpack.cjs"; +import env from "../build-scripts/env.cjs"; + +export default webpack.createLandingPageConfig({ + isProdBuild: env.isProdBuild(), + isStatsBuild: env.isStatsBuild(), + latestBuild: true, +}); diff --git a/public/static/icons/logo_ohf.svg b/public/static/icons/logo_ohf.svg new file mode 100644 index 000000000000..151a7a82733c --- /dev/null +++ b/public/static/icons/logo_ohf.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/logo_mastodon.svg b/public/static/images/logo_mastodon.svg new file mode 100644 index 000000000000..0f8baebfc9cb --- /dev/null +++ b/public/static/images/logo_mastodon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index 66da3c4327c9..32323b4b2573 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -33,6 +33,10 @@ export type LocalizeKeys = | `ui.panel.page-authorize.form.${string}` | `component.${string}`; +export type LandingPageKeys = FlattenObjectKeys< + TranslationDict["landing-page"] +>; + // Tweaked from https://www.raygesualdo.com/posts/flattening-object-keys-with-typescript-types export type FlattenObjectKeys< T extends Record, diff --git a/src/onboarding/dialogs/app-dialog.ts b/src/onboarding/dialogs/app-dialog.ts index d5de77408690..5be88e04861b 100644 --- a/src/onboarding/dialogs/app-dialog.ts +++ b/src/onboarding/dialogs/app-dialog.ts @@ -77,18 +77,16 @@ class DialogApp extends LitElement { --mdc-dialog-min-width: min(500px, 90vw); } .app-qr { - margin: 24px auto 0 auto; display: flex; justify-content: space-between; - padding: 0 24px; box-sizing: border-box; - gap: 16px; + gap: 32px; width: 100%; - max-width: 400px; } .app-qr a, .app-qr img { flex: 1; + max-width: 180px; } `; } diff --git a/src/onboarding/dialogs/community-dialog.ts b/src/onboarding/dialogs/community-dialog.ts index 7df13b7318a0..629349676c1b 100644 --- a/src/onboarding/dialogs/community-dialog.ts +++ b/src/onboarding/dialogs/community-dialog.ts @@ -40,7 +40,11 @@ class DialogCommunity extends LitElement { href="https://community.home-assistant.io/" > - + Home Assistant Logo ${this.localize("ui.panel.page-onboarding.welcome.forums")} @@ -51,7 +55,11 @@ class DialogCommunity extends LitElement { href="https://newsletter.openhomefoundation.org/" > - + Open Home Foundation Logo ${this.localize( "ui.panel.page-onboarding.welcome.open_home_newsletter" )} @@ -64,7 +72,11 @@ class DialogCommunity extends LitElement { href="https://www.home-assistant.io/join-chat" > - + Discord Logo ${this.localize("ui.panel.page-onboarding.welcome.discord")} @@ -72,11 +84,15 @@ class DialogCommunity extends LitElement { - - ${this.localize("ui.panel.page-onboarding.welcome.x")} + Mastodon Logo + ${this.localize("ui.panel.page-onboarding.welcome.mastodon")} @@ -96,12 +112,6 @@ class DialogCommunity extends LitElement { a { text-decoration: none; } - - @media (prefers-color-scheme: light) { - img.x { - filter: invert(1) hue-rotate(180deg); - } - } `; } diff --git a/src/onboarding/onboarding-welcome-links.ts b/src/onboarding/onboarding-welcome-links.ts index 026889a0a91f..95183d8839ac 100644 --- a/src/onboarding/onboarding-welcome-links.ts +++ b/src/onboarding/onboarding-welcome-links.ts @@ -13,7 +13,7 @@ import "./onboarding-welcome-link"; class OnboardingWelcomeLinks extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public localize!: LocalizeFunc; + @property({ attribute: false }) public localize!: LocalizeFunc; @property({ type: Boolean }) public mobileApp = false; diff --git a/src/translations/en.json b/src/translations/en.json index ed02b679ac0d..82ef27db64a1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7358,6 +7358,7 @@ "open_home_newsletter": "Building the Open Home newsletter", "discord": "Discord chat", "x": "[%key:ui::panel::config::tips::join_x%]", + "mastodon": "Mastodon", "playstore": "Get it on Google Play", "appstore": "Download on the App Store" }, @@ -7506,6 +7507,51 @@ "key_m_hint": "Press 'm' on any page to get the My Home Assistant link" } }, + "landing-page": { + "header": "Preparing Home Assistant", + "subheader": "This may take 20 minutes or more", + "show_details": "Show details", + "hide_details": "Hide details", + "network_issue": { + "title": "Networking issue detected", + "error_get_network_info": "Cannot get network information", + "description": "Home Assistant OS detected a networking issue in your setup. As part of the initial setup, Home Assistant OS downloads the latest version of Home Assistant Core. This networking issue prevents this download. The network issue might be DNS related. The currently used DNS service is: {dns}.", + "resolve_different": "To resolve this, you can try a different DNS server. Select one of the options below. Alternatively, change your router configuration to use your own custom DNS server.", + "use_cloudflare": "Use Cloudflare DNS", + "use_google": "Use Google DNS", + "no_primary_interface": "Home Assistant OS wasn't able to detect a primary network interface, so you cannot define a DNS server!", + "failed": "Failed", + "set_dns_failed": "An error occurred while setting the DNS server. Please check the logs for more information and try again.", + "error": "Error", + "close": "[%key:ui::common::close%]" + }, + "logs": { + "scroll_down_button": "New logs - Click to scroll", + "fetch_error": "Failed to fetch logs", + "retry": "Retry", + "download_logs": "[%key:ui::panel::config::logs::download_logs%]" + }, + "error_title": "Error installing Home Assistant", + "error_description": "An error occurred while installing Home Assistant. Please check the logs for more information.", + "ui": { + "panel": { + "page-onboarding": { + "welcome": { + "vision": "[%key:ui::panel::page-onboarding::welcome::vision%]", + "community": "[%key:ui::panel::page-onboarding::welcome::community%]", + "download_app": "[%key:ui::panel::page-onboarding::welcome::download_app%]", + "forums": "[%key:ui::panel::page-onboarding::welcome::forums%]", + "open_home_newsletter": "[%key:ui::panel::page-onboarding::welcome::open_home_newsletter%]", + "discord": "[%key:ui::panel::page-onboarding::welcome::discord%]", + "mastodon": "[%key:ui::panel::page-onboarding::welcome::mastodon%]", + "playstore": "[%key:ui::panel::page-onboarding::welcome::playstore%]", + "appstore": "[%key:ui::panel::page-onboarding::welcome::appstore%]" + }, + "help": "[%key:ui::panel::page-onboarding::help%]" + } + } + } + }, "supervisor": { "addon": { "failed_to_reset": "Failed to reset add-on configuration, {error}",