mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-26 10:14:54 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a43d0d1e | |||
| 179b4cf77c | |||
| 542f07606a | |||
| cf2c440e7b | |||
| 27fbabb71b | |||
| 389af6e00c | |||
| 7ff4cf58e8 | |||
| f849302876 | |||
| 1db707937b | |||
| 70f0d12e43 | |||
| 12bb09dad2 | |||
| f08ffefe28 | |||
| 9de89278cd | |||
| 207d997a3a | |||
| 0bb32aa1b4 | |||
| ba0310ee58 |
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks that a pull request follows the contribution standards: it must use the
|
||||
// PR template, tick exactly one "Type of change" option, and describe the change.
|
||||
// Labels and comments the PR when it does not, and fails the check so it blocks
|
||||
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
core.info(
|
||||
`${pr.user.login} is not an organization member, checking standards`
|
||||
);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
let body = pr.body || "";
|
||||
let previous;
|
||||
do {
|
||||
previous = body;
|
||||
body = body.replace(/<!--[\s\S]*?-->/g, "");
|
||||
} while (body !== previous);
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push('Select exactly one option under "Type of change".');
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// Restricts Task issues to organization members: closes and labels the issue with
|
||||
// an explanatory comment when the author is not an org member. Invoked from the
|
||||
// `check-authorization` job in .github/workflows/restrict-task-creation.yml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
});
|
||||
core.info(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (_error) {
|
||||
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body:
|
||||
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
|
||||
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
|
||||
`If you would like to:\n` +
|
||||
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
|
||||
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
});
|
||||
}
|
||||
@@ -20,31 +20,16 @@ jobs:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map(
|
||||
(l) => l.name
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
|
||||
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
|
||||
.write();
|
||||
}
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
|
||||
@@ -105,6 +105,8 @@ jobs:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
if-no-files-found: error
|
||||
- name: Check entrypoint bundle size budget
|
||||
run: yarn run check-bundlesize
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -229,16 +229,12 @@ jobs:
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report link to PR
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
const { default: postReportComment } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Pull request standards
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
@@ -23,168 +23,16 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check pull request standards
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.info(`${pr.user.login} is not an organization member, checking standards`);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push(
|
||||
'Select exactly one option under "Type of change".'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number, per_page: 100 }
|
||||
);
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number, name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number, body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(
|
||||
`Pull request standards not met:\n- ${problems.join("\n- ")}`
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
|
||||
@@ -36,52 +36,21 @@ jobs:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: 'home-assistant',
|
||||
username: issueAuthor
|
||||
});
|
||||
console.log(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (error) {
|
||||
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
|
||||
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
|
||||
`If you would like to:\n` +
|
||||
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
|
||||
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['auto-closed']
|
||||
});
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* global require, process, __dirname */
|
||||
// Enforce a strict size budget on the initial JS of the most critical
|
||||
// entrypoints (`app` and `core`). These two are downloaded on every cold load
|
||||
// before anything interactive can happen, so unintended growth here hurts
|
||||
// first-load performance directly.
|
||||
//
|
||||
// In production rspack does not split initial chunks (splitChunks only operates
|
||||
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
|
||||
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
|
||||
// the entrypoint's initial JS size against a committed budget.
|
||||
//
|
||||
// Usage:
|
||||
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
|
||||
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
|
||||
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const paths = require("./paths.cjs");
|
||||
|
||||
// Entrypoints whose initial JS we hold to a strict budget. These are all
|
||||
// downloaded on a user-facing cold load before anything interactive can happen:
|
||||
// `app`/`core` for the main app, plus the standalone `authorize` and
|
||||
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
|
||||
// when a custom panel is opened).
|
||||
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
|
||||
|
||||
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
|
||||
const BUILDS = ["frontend-modern", "frontend-legacy"];
|
||||
|
||||
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
|
||||
const STATS_DIR = path.join(paths.build_dir, "stats");
|
||||
|
||||
const readStats = (build) => {
|
||||
const file = path.join(STATS_DIR, `${build}.json`);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(
|
||||
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
|
||||
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
|
||||
);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
};
|
||||
|
||||
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
|
||||
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
|
||||
const entrypointInitialJS = (stats, entrypoint) => {
|
||||
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const chunk of stats.chunks) {
|
||||
if (!chunk.entry || !chunk.initial) {
|
||||
continue;
|
||||
}
|
||||
if (!(chunk.names || []).includes(entrypoint)) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
for (const file of chunk.files || []) {
|
||||
if (file.endsWith(".js") && assetSize.has(file)) {
|
||||
total += assetSize.get(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
|
||||
const main = () => {
|
||||
const update = process.argv.includes("--update");
|
||||
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
|
||||
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
|
||||
|
||||
const current = {};
|
||||
for (const build of BUILDS) {
|
||||
const stats = readStats(build);
|
||||
current[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
const budget = { _comment: BUDGET_COMMENT };
|
||||
for (const build of BUILDS) {
|
||||
budget[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
budget[build][entrypoint] = Math.ceil(
|
||||
current[build][entrypoint] * (1 + headroom / 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
|
||||
console.log(
|
||||
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
|
||||
(headroom ? ` (+${headroom}% headroom).` : ".")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BUDGET_FILE)) {
|
||||
throw new Error(
|
||||
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
|
||||
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
|
||||
);
|
||||
}
|
||||
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
|
||||
|
||||
let failed = false;
|
||||
console.log("Initial JS budget (entry chunks, raw bytes):\n");
|
||||
for (const build of BUILDS) {
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
const actual = current[build][entrypoint];
|
||||
const limit = budget[build] && budget[build][entrypoint];
|
||||
if (typeof limit !== "number") {
|
||||
failed = true;
|
||||
console.log(
|
||||
` ✗ ${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ok = actual <= limit;
|
||||
const delta = (((actual - limit) / limit) * 100).toFixed(1);
|
||||
console.log(
|
||||
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
|
||||
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
|
||||
);
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
|
||||
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
|
||||
"If the growth is intentional, re-seed the budget:\n" +
|
||||
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll tracked entrypoints within budget.");
|
||||
};
|
||||
|
||||
const BUDGET_COMMENT =
|
||||
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
|
||||
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
|
||||
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
|
||||
|
||||
main();
|
||||
+3
-1
@@ -66,7 +66,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
// `false` for contexts: HomeAssistantAppEl already provides them via
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -240,6 +240,12 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { mdiCog, mdiMenu } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -19,6 +20,22 @@ import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/ha-top-app-bar-fixed";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../src/data/context";
|
||||
import { updateHassGroups } from "../../src/data/context/updateContext";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../src/types";
|
||||
import { PAGES, SIDEBAR } from "../build/import-pages";
|
||||
import {
|
||||
@@ -113,6 +130,65 @@ class HaGallery extends LitElement {
|
||||
|
||||
@state() private _drawerOpen = !this._narrow;
|
||||
|
||||
// Fallback Lit context providers for the whole gallery. The real app's root
|
||||
// element provides these via `contextMixin`; here we mirror that so demos
|
||||
// which render context-consuming components without setting up their own hass
|
||||
// (e.g. bare component demos) still resolve `localize`, formatters, config,
|
||||
// etc. instead of throwing during init. Demos that call `provideHass`
|
||||
// register their own providers closer in the tree, which take precedence.
|
||||
private _contextProviders = {
|
||||
registries: new ContextProvider(this, { context: registriesContext }),
|
||||
internationalization: new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
}),
|
||||
api: new ContextProvider(this, { context: apiContext }),
|
||||
connection: new ContextProvider(this, { context: connectionContext }),
|
||||
ui: new ContextProvider(this, { context: uiContext }),
|
||||
config: new ContextProvider(this, { context: configContext }),
|
||||
formatters: new ContextProvider(this, { context: formattersContext }),
|
||||
};
|
||||
|
||||
// The individual (non-grouped) contexts contextMixin also provides. Components
|
||||
// such as ha-area-picker / ha-entity-picker consume these directly, so the
|
||||
// fallback must cover them too.
|
||||
private _singleContextProviders = {
|
||||
states: new ContextProvider(this, { context: statesContext }),
|
||||
services: new ContextProvider(this, { context: servicesContext }),
|
||||
entities: new ContextProvider(this, { context: entitiesContext }),
|
||||
devices: new ContextProvider(this, { context: devicesContext }),
|
||||
areas: new ContextProvider(this, { context: areasContext }),
|
||||
floors: new ContextProvider(this, { context: floorsContext }),
|
||||
};
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Refresh the fallback contexts before each render so theme/page changes in
|
||||
// the gallery hass propagate to consuming components.
|
||||
const hass = this._galleryHass;
|
||||
(
|
||||
Object.keys(
|
||||
this._contextProviders
|
||||
) as (keyof typeof this._contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = this._contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
hass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
(
|
||||
Object.keys(
|
||||
this._singleContextProviders
|
||||
) as (keyof typeof this._singleContextProviders)[]
|
||||
).forEach((key) => {
|
||||
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
|
||||
hass[key]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const isSettingsPage = this._page === SETTINGS_PAGE;
|
||||
const page = isSettingsPage ? undefined : PAGES[this._page];
|
||||
@@ -576,6 +652,21 @@ class HaGallery extends LitElement {
|
||||
callWS: async () => undefined,
|
||||
fetchWithAuth: async () => new Response(),
|
||||
sendWS: () => undefined,
|
||||
formatEntityState: (stateObj, stateValue) =>
|
||||
(stateValue != null ? stateValue : stateObj.state) ?? "",
|
||||
formatEntityStateToParts: (stateObj, stateValue) => [
|
||||
{
|
||||
type: "value",
|
||||
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
|
||||
},
|
||||
],
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
value != null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
@@ -15,11 +14,6 @@ import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../src/data/context";
|
||||
import { updateHassGroups } from "../../../../src/data/context/updateContext";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
@@ -528,17 +522,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
|
||||
private data = SCHEMAS.map(() => ({}));
|
||||
|
||||
// The date/datetime selectors and the date-picker dialog consume these
|
||||
// contexts (provided by the root element in the real app). Provide them here
|
||||
// so they work in the gallery.
|
||||
private _i18nProvider = new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
});
|
||||
|
||||
private _configProvider = new ContextProvider(this, {
|
||||
context: configContext,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
@@ -560,16 +543,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
this._i18nProvider.setValue(
|
||||
updateHassGroups.internationalization(this.hass)
|
||||
);
|
||||
this._configProvider.setValue(updateHassGroups.config(this.hass));
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
|
||||
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("lovelace", "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
|
||||
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
+3
-2
@@ -23,6 +23,7 @@
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
|
||||
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
@@ -144,10 +145,10 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@playwright/test": "1.61.0",
|
||||
"@rsdoctor/rspack-plugin": "1.5.15",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.1"
|
||||
version = "20260624.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
+16
-6
@@ -27,6 +27,7 @@ export interface LogbookEntry {
|
||||
source?: string; // The trigger source (English phrase, parsed for the cause)
|
||||
domain?: string;
|
||||
state?: string; // The state of the entity
|
||||
attributes?: { event_type?: string }; // Selected attributes the backend surfaces
|
||||
// Context data
|
||||
context_id?: string;
|
||||
context_user_id?: string;
|
||||
@@ -241,13 +242,13 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
|
||||
};
|
||||
|
||||
// Short label shown instead of the bare timestamp for each timestamp-state
|
||||
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
|
||||
// new timestamp domain won't compile until it gets a label here.
|
||||
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value) and
|
||||
// event (handled separately via its event type), so a new timestamp domain
|
||||
// won't compile until it gets a label here.
|
||||
type LogbookActionMessage =
|
||||
| "pressed"
|
||||
| "activated"
|
||||
| "scanned"
|
||||
| "detected_event_no_type"
|
||||
| "updated"
|
||||
| "sent"
|
||||
| "detected"
|
||||
@@ -258,14 +259,13 @@ type LogbookActionMessage =
|
||||
| "command_sent";
|
||||
|
||||
const STATE_ACTION_MESSAGES: Record<
|
||||
Exclude<TimestampStateDomain, "datetime">,
|
||||
Exclude<TimestampStateDomain, "datetime" | "event">,
|
||||
LogbookActionMessage
|
||||
> = {
|
||||
button: "pressed",
|
||||
input_button: "pressed",
|
||||
scene: "activated",
|
||||
tag: "scanned",
|
||||
event: "detected_event_no_type",
|
||||
image: "updated",
|
||||
notify: "sent",
|
||||
wake_word: "detected",
|
||||
@@ -281,8 +281,18 @@ export const localizeStateMessage = (
|
||||
hass: HomeAssistant,
|
||||
state: string,
|
||||
stateObj: HassEntity,
|
||||
domain: string
|
||||
domain: string,
|
||||
attributes?: LogbookEntry["attributes"]
|
||||
): string => {
|
||||
// Events show the triggered event type, falling back to a generic label when
|
||||
// the type is unknown (the timestamp state is meaningless on its own).
|
||||
if (domain === "event") {
|
||||
const eventType = attributes?.event_type;
|
||||
if (eventType != null) {
|
||||
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
|
||||
}
|
||||
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
|
||||
}
|
||||
const actionKey: LogbookActionMessage | undefined =
|
||||
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
|
||||
if (actionKey) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import { configSections } from "../panels/config/ha-panel-config";
|
||||
import { configSections } from "../panels/config/config-sections";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
|
||||
@@ -159,7 +159,7 @@ class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
|
||||
value: panel.url_path,
|
||||
label:
|
||||
(getPanelTitle(this.hass, panel) || panel.url_path) +
|
||||
`${defaultPanel === panel.url_path ? ` (${this.hass.localize("ui.sidebar.default")})` : ""}`,
|
||||
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
|
||||
icon: getPanelIcon(panel),
|
||||
iconPath: getPanelIconPath(panel),
|
||||
disableHiding: panel.url_path === defaultPanel,
|
||||
|
||||
@@ -9,11 +9,17 @@ import { computeFormatFunctions } from "../common/translations/entity-state";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import { updateHassGroups } from "../data/context/updateContext";
|
||||
@@ -97,11 +103,15 @@ export const provideHass = (
|
||||
elements,
|
||||
overrideData: Partial<HomeAssistant> = {},
|
||||
setHassProperty = false,
|
||||
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
|
||||
// that the real app's root element provides via `contextMixin`. Needed for
|
||||
// gallery demos that render context-consuming components (e.g. the climate
|
||||
// temperature control) without the full app shell.
|
||||
provideContexts = false
|
||||
// Provide the grouped Lit contexts (registries, internationalization, api,
|
||||
// connection, ui, config, formatters) that the real app's root element
|
||||
// provides via `contextMixin`. On by default so that any standalone hass root
|
||||
// (e.g. a gallery demo) automatically feeds context-consuming components the
|
||||
// same way the real app does, instead of each demo wiring up a partial set by
|
||||
// hand. Pass `false` for hosts that already provide these contexts themselves
|
||||
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
|
||||
// registering duplicate providers on the same element.
|
||||
provideContexts = true
|
||||
): MockHomeAssistant => {
|
||||
elements = ensureArray(elements);
|
||||
// Can happen because we store sidebar, more info etc on hass.
|
||||
@@ -128,21 +138,46 @@ export const provideHass = (
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// The individual (non-grouped) contexts that contextMixin also provides.
|
||||
// Components such as ha-area-picker / ha-entity-picker consume these directly
|
||||
// (e.g. `Object.values(areas)`), so they must be provided alongside the
|
||||
// grouped contexts or those components throw once they render.
|
||||
const singleContextProviders = provideContexts
|
||||
? {
|
||||
states: new ContextProvider(baseEl(), { context: statesContext }),
|
||||
services: new ContextProvider(baseEl(), { context: servicesContext }),
|
||||
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
|
||||
devices: new ContextProvider(baseEl(), { context: devicesContext }),
|
||||
areas: new ContextProvider(baseEl(), { context: areasContext }),
|
||||
floors: new ContextProvider(baseEl(), { context: floorsContext }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const updateContextProviders = (newHass: HomeAssistant) => {
|
||||
if (!contextProviders) {
|
||||
return;
|
||||
if (contextProviders) {
|
||||
(
|
||||
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
newHass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (singleContextProviders) {
|
||||
(
|
||||
Object.keys(
|
||||
singleContextProviders
|
||||
) as (keyof typeof singleContextProviders)[]
|
||||
).forEach((key) => {
|
||||
(singleContextProviders[key] as ContextProvider<any>).setValue(
|
||||
newHass[key]
|
||||
);
|
||||
});
|
||||
}
|
||||
(
|
||||
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
newHass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const wsCommands = {};
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
|
||||
|
||||
@customElement("ha-config-application-credentials")
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import {
|
||||
loadAreaRegistryDetailDialog,
|
||||
showAreaRegistryDetailDialog,
|
||||
|
||||
@@ -120,7 +120,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
getTriggeredAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import {
|
||||
getAssistantsSortableKey,
|
||||
|
||||
@@ -52,7 +52,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
|
||||
|
||||
type BlueprintMetaDataPath = BlueprintMetaData & {
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiBackupRestore,
|
||||
mdiBadgeAccountHorizontal,
|
||||
mdiBluetooth,
|
||||
mdiCellphoneCog,
|
||||
mdiCog,
|
||||
mdiDatabase,
|
||||
mdiDevices,
|
||||
mdiFlask,
|
||||
mdiHammer,
|
||||
mdiInformationOutline,
|
||||
mdiLabel,
|
||||
mdiLightningBolt,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMemory,
|
||||
mdiMicrophone,
|
||||
mdiNetwork,
|
||||
mdiNfcVariant,
|
||||
mdiPalette,
|
||||
mdiPaletteSwatch,
|
||||
mdiPuzzle,
|
||||
mdiRadioTower,
|
||||
mdiRemote,
|
||||
mdiRobot,
|
||||
mdiScrewdriver,
|
||||
mdiScriptText,
|
||||
mdiShape,
|
||||
mdiSofa,
|
||||
mdiStarFourPoints,
|
||||
mdiTextBoxOutline,
|
||||
mdiTools,
|
||||
mdiUpdate,
|
||||
mdiViewDashboard,
|
||||
mdiZigbee,
|
||||
mdiZWave,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
const getHasDomainCheck = (domain: string) => {
|
||||
const prefix = `${domain}.`;
|
||||
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
|
||||
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
|
||||
);
|
||||
return (hass: HomeAssistant) => checkRegistry(hass.entities);
|
||||
};
|
||||
|
||||
export const configSections: Record<string, PageNavigation[]> = {
|
||||
dashboard: [
|
||||
{
|
||||
path: "/config/integrations",
|
||||
translationKey: "devices",
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#0D47A1",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/automation",
|
||||
translationKey: "automations",
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/areas",
|
||||
translationKey: "areas",
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#E48629",
|
||||
component: "zone",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/apps",
|
||||
translationKey: "apps",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#F1C447",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/lovelace/dashboards",
|
||||
translationKey: "dashboards",
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
component: "lovelace",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/voice-assistants",
|
||||
translationKey: "voice_assistants",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_external_settings: [
|
||||
{
|
||||
path: "#external-app-configuration",
|
||||
translationKey: "companion",
|
||||
iconPath: mdiCellphoneCog,
|
||||
iconColor: "#8E24AA",
|
||||
},
|
||||
],
|
||||
dashboard_2: [
|
||||
{
|
||||
path: "/config/matter",
|
||||
iconPath:
|
||||
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
|
||||
iconViewBox: "0 1 24 24",
|
||||
iconColor: "#2458B3",
|
||||
component: "matter",
|
||||
translationKey: "matter",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zha",
|
||||
iconPath: mdiZigbee,
|
||||
iconColor: "#E74011",
|
||||
component: "zha",
|
||||
translationKey: "zha",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zwave_js",
|
||||
iconPath: mdiZWave,
|
||||
iconColor: "#153163",
|
||||
component: "zwave_js",
|
||||
translationKey: "zwave_js",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/knx",
|
||||
iconPath:
|
||||
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
|
||||
iconColor: "#4EAA66",
|
||||
component: "knx",
|
||||
translationKey: "knx",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/thread",
|
||||
iconPath:
|
||||
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
|
||||
iconColor: "#ED7744",
|
||||
component: "thread",
|
||||
translationKey: "thread",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/bluetooth",
|
||||
iconPath: mdiBluetooth,
|
||||
iconColor: "#0082FC",
|
||||
component: "bluetooth",
|
||||
translationKey: "bluetooth",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/infrared",
|
||||
iconPath: mdiRemote,
|
||||
iconColor: "#9C27B0",
|
||||
translationKey: "infrared",
|
||||
adminOnly: true,
|
||||
filter: getHasDomainCheck("infrared"),
|
||||
},
|
||||
{
|
||||
path: "/config/radio-frequency",
|
||||
iconPath: mdiRadioTower,
|
||||
iconColor: "#E74011",
|
||||
component: "radio_frequency",
|
||||
translationKey: "radio_frequency",
|
||||
adminOnly: true,
|
||||
filter: getHasDomainCheck("radio_frequency"),
|
||||
},
|
||||
{
|
||||
path: "/insteon",
|
||||
iconPath:
|
||||
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
|
||||
iconColor: "#E4002C",
|
||||
component: "insteon",
|
||||
translationKey: "insteon",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/tags",
|
||||
translationKey: "tags",
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
component: "tag",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_3: [
|
||||
{
|
||||
path: "/config/person",
|
||||
translationKey: "people",
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
component: ["person", "users"],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/system",
|
||||
translationKey: "system",
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#301ABE",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "developer_tools",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/info",
|
||||
translationKey: "about",
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
backup: [
|
||||
{
|
||||
path: "/config/backup",
|
||||
translationKey: "ui.panel.config.backup.caption",
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#4084CD",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
{
|
||||
component: "integrations",
|
||||
path: "/config/integrations",
|
||||
translationKey: "ui.panel.config.integrations.caption",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "devices",
|
||||
path: "/config/devices",
|
||||
translationKey: "ui.panel.config.devices.caption",
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "entities",
|
||||
path: "/config/entities",
|
||||
translationKey: "ui.panel.config.entities.caption",
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "helpers",
|
||||
path: "/config/helpers",
|
||||
translationKey: "ui.panel.config.helpers.caption",
|
||||
iconPath: mdiTools,
|
||||
iconColor: "#4D2EA4",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
automations: [
|
||||
{
|
||||
component: "automation",
|
||||
path: "/config/automation",
|
||||
translationKey: "ui.panel.config.automation.caption",
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "scene",
|
||||
path: "/config/scene",
|
||||
translationKey: "ui.panel.config.scene.caption",
|
||||
iconPath: mdiPalette,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "script",
|
||||
path: "/config/script",
|
||||
translationKey: "ui.panel.config.script.caption",
|
||||
iconPath: mdiScriptText,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "blueprint",
|
||||
path: "/config/blueprint",
|
||||
translationKey: "ui.panel.config.blueprint.caption",
|
||||
iconPath: mdiPaletteSwatch,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
component: "tag",
|
||||
path: "/config/tags",
|
||||
translationKey: "ui.panel.config.tag.caption",
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
voice_assistants: [
|
||||
{
|
||||
path: "/config/voice-assistants",
|
||||
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
developer_tools: [
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "ui.panel.config.dashboard.developer_tools.main",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
energy: [
|
||||
{
|
||||
component: "energy",
|
||||
path: "/config/energy",
|
||||
translationKey: "ui.panel.config.energy.caption",
|
||||
iconPath: mdiLightningBolt,
|
||||
iconColor: "#F1C447",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
network_discovery: [
|
||||
{
|
||||
component: "dhcp",
|
||||
path: "/config/dhcp",
|
||||
translationKey: "ui.panel.config.network.discovery.dhcp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "ssdp",
|
||||
path: "/config/ssdp",
|
||||
translationKey: "ui.panel.config.network.discovery.ssdp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zeroconf",
|
||||
path: "/config/zeroconf",
|
||||
translationKey: "ui.panel.config.network.discovery.zeroconf",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
integration_credentials: [
|
||||
{
|
||||
path: "/config/application_credentials",
|
||||
translationKey: "ui.panel.config.application_credentials.caption",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
integration_mqtt: [
|
||||
{
|
||||
component: "mqtt",
|
||||
path: "/config/mqtt",
|
||||
translationKey: "ui.panel.config.mqtt.title",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
lovelace: [
|
||||
{
|
||||
component: "lovelace",
|
||||
path: "/config/lovelace/dashboards",
|
||||
translationKey: "ui.panel.config.lovelace.caption",
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
persons: [
|
||||
{
|
||||
component: "person",
|
||||
path: "/config/person",
|
||||
translationKey: "ui.panel.config.person.caption",
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "users",
|
||||
path: "/config/users",
|
||||
translationKey: "ui.panel.config.users.caption",
|
||||
iconPath: mdiBadgeAccountHorizontal,
|
||||
iconColor: "#5A87FA",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
areas: [
|
||||
{
|
||||
component: "areas",
|
||||
path: "/config/areas",
|
||||
translationKey: "ui.panel.config.areas.caption",
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "labels",
|
||||
path: "/config/labels",
|
||||
translationKey: "ui.panel.config.labels.caption",
|
||||
iconPath: mdiLabel,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zone",
|
||||
path: "/config/zone",
|
||||
translationKey: "ui.panel.config.zone.caption",
|
||||
iconPath: mdiMapMarkerRadius,
|
||||
iconColor: "#E48629",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
general: [
|
||||
{
|
||||
path: "/config/general",
|
||||
translationKey: "core",
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#653249",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/updates",
|
||||
translationKey: "updates",
|
||||
iconPath: mdiUpdate,
|
||||
iconColor: "#3B808E",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/repairs",
|
||||
translationKey: "repairs",
|
||||
iconPath: mdiScrewdriver,
|
||||
iconColor: "#5c995c",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "logs",
|
||||
path: "/config/logs",
|
||||
translationKey: "logs",
|
||||
iconPath: mdiTextBoxOutline,
|
||||
iconColor: "#C65326",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/backup",
|
||||
translationKey: "backup",
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#0D47A1",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/analytics",
|
||||
translationKey: "analytics",
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#f1c447",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/ai-tasks",
|
||||
translationKey: "ai_tasks",
|
||||
iconPath: mdiStarFourPoints,
|
||||
iconColor: "#8B69E3",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/labs",
|
||||
translationKey: "labs",
|
||||
iconPath: mdiFlask,
|
||||
iconColor: "#b1b134",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/network",
|
||||
translationKey: "network",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/storage",
|
||||
translationKey: "storage",
|
||||
iconPath: mdiDatabase,
|
||||
iconColor: "#518C43",
|
||||
component: "hassio",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/hardware",
|
||||
translationKey: "hardware",
|
||||
iconPath: mdiMemory,
|
||||
iconColor: "#301A8E",
|
||||
component: ["hassio", "hardware"],
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
about: [
|
||||
{
|
||||
component: "info",
|
||||
path: "/config/info",
|
||||
translationKey: "ui.panel.config.info.caption",
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-config-navigation-list";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
|
||||
@customElement("ha-config-system-navigation")
|
||||
class HaConfigSystemNavigation extends LitElement {
|
||||
|
||||
@@ -43,7 +43,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { isMac } from "../../../util/is_mac";
|
||||
import { isMobileClient } from "../../../util/is_mobile";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import "../repairs/ha-config-repairs";
|
||||
import "./ha-config-navigation";
|
||||
import "./ha-config-updates";
|
||||
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
getModifiedAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import "../integrations/ha-integration-overflow-menu";
|
||||
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
getModifiedAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import type { Helper } from "../helpers/const";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import "../integrations/ha-integration-overflow-menu";
|
||||
|
||||
@@ -1,43 +1,5 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiBackupRestore,
|
||||
mdiBadgeAccountHorizontal,
|
||||
mdiBluetooth,
|
||||
mdiCellphoneCog,
|
||||
mdiCog,
|
||||
mdiDatabase,
|
||||
mdiDevices,
|
||||
mdiFlask,
|
||||
mdiHammer,
|
||||
mdiInformationOutline,
|
||||
mdiLabel,
|
||||
mdiLightningBolt,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMemory,
|
||||
mdiMicrophone,
|
||||
mdiNetwork,
|
||||
mdiNfcVariant,
|
||||
mdiPalette,
|
||||
mdiPaletteSwatch,
|
||||
mdiPuzzle,
|
||||
mdiRadioTower,
|
||||
mdiRemote,
|
||||
mdiRobot,
|
||||
mdiScrewdriver,
|
||||
mdiScriptText,
|
||||
mdiShape,
|
||||
mdiSofa,
|
||||
mdiStarFourPoints,
|
||||
mdiTextBoxOutline,
|
||||
mdiTools,
|
||||
mdiUpdate,
|
||||
mdiViewDashboard,
|
||||
mdiZigbee,
|
||||
mdiZWave,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { CloudStatus } from "../../data/cloud";
|
||||
@@ -48,7 +10,6 @@ import {
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { RouterOptions } from "../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../layouts/hass-router-page";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
|
||||
declare global {
|
||||
@@ -58,521 +19,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const getHasDomainCheck = (domain: string) => {
|
||||
const prefix = `${domain}.`;
|
||||
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
|
||||
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
|
||||
);
|
||||
return (hass: HomeAssistant) => checkRegistry(hass.entities);
|
||||
};
|
||||
|
||||
export const configSections: Record<string, PageNavigation[]> = {
|
||||
dashboard: [
|
||||
{
|
||||
path: "/config/integrations",
|
||||
translationKey: "devices",
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#0D47A1",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/automation",
|
||||
translationKey: "automations",
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/areas",
|
||||
translationKey: "areas",
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#E48629",
|
||||
component: "zone",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/apps",
|
||||
translationKey: "apps",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#F1C447",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/lovelace/dashboards",
|
||||
translationKey: "dashboards",
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
component: "lovelace",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/voice-assistants",
|
||||
translationKey: "voice_assistants",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_external_settings: [
|
||||
{
|
||||
path: "#external-app-configuration",
|
||||
translationKey: "companion",
|
||||
iconPath: mdiCellphoneCog,
|
||||
iconColor: "#8E24AA",
|
||||
},
|
||||
],
|
||||
dashboard_2: [
|
||||
{
|
||||
path: "/config/matter",
|
||||
iconPath:
|
||||
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
|
||||
iconViewBox: "0 1 24 24",
|
||||
iconColor: "#2458B3",
|
||||
component: "matter",
|
||||
translationKey: "matter",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zha",
|
||||
iconPath: mdiZigbee,
|
||||
iconColor: "#E74011",
|
||||
component: "zha",
|
||||
translationKey: "zha",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zwave_js",
|
||||
iconPath: mdiZWave,
|
||||
iconColor: "#153163",
|
||||
component: "zwave_js",
|
||||
translationKey: "zwave_js",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/knx",
|
||||
iconPath:
|
||||
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
|
||||
iconColor: "#4EAA66",
|
||||
component: "knx",
|
||||
translationKey: "knx",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/thread",
|
||||
iconPath:
|
||||
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
|
||||
iconColor: "#ED7744",
|
||||
component: "thread",
|
||||
translationKey: "thread",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/bluetooth",
|
||||
iconPath: mdiBluetooth,
|
||||
iconColor: "#0082FC",
|
||||
component: "bluetooth",
|
||||
translationKey: "bluetooth",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/infrared",
|
||||
iconPath: mdiRemote,
|
||||
iconColor: "#9C27B0",
|
||||
translationKey: "infrared",
|
||||
adminOnly: true,
|
||||
filter: getHasDomainCheck("infrared"),
|
||||
},
|
||||
{
|
||||
path: "/config/radio-frequency",
|
||||
iconPath: mdiRadioTower,
|
||||
iconColor: "#E74011",
|
||||
component: "radio_frequency",
|
||||
translationKey: "radio_frequency",
|
||||
adminOnly: true,
|
||||
filter: getHasDomainCheck("radio_frequency"),
|
||||
},
|
||||
{
|
||||
path: "/insteon",
|
||||
iconPath:
|
||||
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
|
||||
iconColor: "#E4002C",
|
||||
component: "insteon",
|
||||
translationKey: "insteon",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/tags",
|
||||
translationKey: "tags",
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
component: "tag",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_3: [
|
||||
{
|
||||
path: "/config/person",
|
||||
translationKey: "people",
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
component: ["person", "users"],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/system",
|
||||
translationKey: "system",
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#301ABE",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "developer_tools",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/info",
|
||||
translationKey: "about",
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
backup: [
|
||||
{
|
||||
path: "/config/backup",
|
||||
translationKey: "ui.panel.config.backup.caption",
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#4084CD",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
{
|
||||
component: "integrations",
|
||||
path: "/config/integrations",
|
||||
translationKey: "ui.panel.config.integrations.caption",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "devices",
|
||||
path: "/config/devices",
|
||||
translationKey: "ui.panel.config.devices.caption",
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "entities",
|
||||
path: "/config/entities",
|
||||
translationKey: "ui.panel.config.entities.caption",
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "helpers",
|
||||
path: "/config/helpers",
|
||||
translationKey: "ui.panel.config.helpers.caption",
|
||||
iconPath: mdiTools,
|
||||
iconColor: "#4D2EA4",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
automations: [
|
||||
{
|
||||
component: "automation",
|
||||
path: "/config/automation",
|
||||
translationKey: "ui.panel.config.automation.caption",
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "scene",
|
||||
path: "/config/scene",
|
||||
translationKey: "ui.panel.config.scene.caption",
|
||||
iconPath: mdiPalette,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "script",
|
||||
path: "/config/script",
|
||||
translationKey: "ui.panel.config.script.caption",
|
||||
iconPath: mdiScriptText,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "blueprint",
|
||||
path: "/config/blueprint",
|
||||
translationKey: "ui.panel.config.blueprint.caption",
|
||||
iconPath: mdiPaletteSwatch,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
component: "tag",
|
||||
path: "/config/tags",
|
||||
translationKey: "ui.panel.config.tag.caption",
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
voice_assistants: [
|
||||
{
|
||||
path: "/config/voice-assistants",
|
||||
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
developer_tools: [
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "ui.panel.config.dashboard.developer_tools.main",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
energy: [
|
||||
{
|
||||
component: "energy",
|
||||
path: "/config/energy",
|
||||
translationKey: "ui.panel.config.energy.caption",
|
||||
iconPath: mdiLightningBolt,
|
||||
iconColor: "#F1C447",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
network_discovery: [
|
||||
{
|
||||
component: "dhcp",
|
||||
path: "/config/dhcp",
|
||||
translationKey: "ui.panel.config.network.discovery.dhcp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "ssdp",
|
||||
path: "/config/ssdp",
|
||||
translationKey: "ui.panel.config.network.discovery.ssdp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zeroconf",
|
||||
path: "/config/zeroconf",
|
||||
translationKey: "ui.panel.config.network.discovery.zeroconf",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
integration_credentials: [
|
||||
{
|
||||
path: "/config/application_credentials",
|
||||
translationKey: "ui.panel.config.application_credentials.caption",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
integration_mqtt: [
|
||||
{
|
||||
component: "mqtt",
|
||||
path: "/config/mqtt",
|
||||
translationKey: "ui.panel.config.mqtt.title",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
lovelace: [
|
||||
{
|
||||
component: "lovelace",
|
||||
path: "/config/lovelace/dashboards",
|
||||
translationKey: "ui.panel.config.lovelace.caption",
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
persons: [
|
||||
{
|
||||
component: "person",
|
||||
path: "/config/person",
|
||||
translationKey: "ui.panel.config.person.caption",
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "users",
|
||||
path: "/config/users",
|
||||
translationKey: "ui.panel.config.users.caption",
|
||||
iconPath: mdiBadgeAccountHorizontal,
|
||||
iconColor: "#5A87FA",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
areas: [
|
||||
{
|
||||
component: "areas",
|
||||
path: "/config/areas",
|
||||
translationKey: "ui.panel.config.areas.caption",
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "labels",
|
||||
path: "/config/labels",
|
||||
translationKey: "ui.panel.config.labels.caption",
|
||||
iconPath: mdiLabel,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zone",
|
||||
path: "/config/zone",
|
||||
translationKey: "ui.panel.config.zone.caption",
|
||||
iconPath: mdiMapMarkerRadius,
|
||||
iconColor: "#E48629",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
general: [
|
||||
{
|
||||
path: "/config/general",
|
||||
translationKey: "core",
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#653249",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/updates",
|
||||
translationKey: "updates",
|
||||
iconPath: mdiUpdate,
|
||||
iconColor: "#3B808E",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/repairs",
|
||||
translationKey: "repairs",
|
||||
iconPath: mdiScrewdriver,
|
||||
iconColor: "#5c995c",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "logs",
|
||||
path: "/config/logs",
|
||||
translationKey: "logs",
|
||||
iconPath: mdiTextBoxOutline,
|
||||
iconColor: "#C65326",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/backup",
|
||||
translationKey: "backup",
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#0D47A1",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/analytics",
|
||||
translationKey: "analytics",
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#f1c447",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/ai-tasks",
|
||||
translationKey: "ai_tasks",
|
||||
iconPath: mdiStarFourPoints,
|
||||
iconColor: "#8B69E3",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/labs",
|
||||
translationKey: "labs",
|
||||
iconPath: mdiFlask,
|
||||
iconColor: "#b1b134",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/network",
|
||||
translationKey: "network",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/storage",
|
||||
translationKey: "storage",
|
||||
iconPath: mdiDatabase,
|
||||
iconColor: "#518C43",
|
||||
component: "hassio",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/hardware",
|
||||
translationKey: "hardware",
|
||||
iconPath: mdiMemory,
|
||||
iconColor: "#301A8E",
|
||||
component: ["hassio", "hardware"],
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
about: [
|
||||
{
|
||||
component: "info",
|
||||
path: "/config/info",
|
||||
translationKey: "ui.panel.config.info.caption",
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@customElement("ha-panel-config")
|
||||
class HaPanelConfig extends HassRouterPage {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -122,7 +122,7 @@ import {
|
||||
getEntityIdTableColumn,
|
||||
getLabelsTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
|
||||
import "../integrations/ha-integration-overflow-menu";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
|
||||
@@ -58,7 +58,7 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import "./ha-config-flow-card";
|
||||
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
getCreatedAtTableColumn,
|
||||
getModifiedAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showLabelDetailDialog } from "./show-dialog-label-detail";
|
||||
|
||||
type ConfigTranslationKey = FlattenObjectKeys<
|
||||
|
||||
@@ -27,7 +27,7 @@ import "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import {
|
||||
loadPersonDetailDialog,
|
||||
showPersonDetailDialog,
|
||||
|
||||
@@ -108,7 +108,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
renderRelativeTimeColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import {
|
||||
getAssistantsSortableKey,
|
||||
|
||||
@@ -113,7 +113,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
getTriggeredAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import {
|
||||
getAssistantsSortableKey,
|
||||
|
||||
@@ -37,7 +37,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showTagDetailDialog } from "./show-dialog-tag-detail";
|
||||
import "./tag-image";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddUserDialog } from "./show-dialog-add-user";
|
||||
import { showUserDetailDialog } from "./show-dialog-user-detail";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
|
||||
@@ -43,7 +43,7 @@ import "../../../layouts/hass-tabs-subpage";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
|
||||
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
|
||||
|
||||
|
||||
@@ -266,7 +266,13 @@ const computeLogbookValue = (
|
||||
if (item.entity_id && item.state) {
|
||||
return {
|
||||
text: stateObj
|
||||
? localizeStateMessage(hass, item.state, stateObj, domain!)
|
||||
? localizeStateMessage(
|
||||
hass,
|
||||
item.state,
|
||||
stateObj,
|
||||
domain!,
|
||||
item.attributes
|
||||
)
|
||||
: item.state,
|
||||
type: "state",
|
||||
};
|
||||
|
||||
@@ -331,28 +331,6 @@ export function computeBarycenter(
|
||||
return totalWeight > 0 ? weightedSum / totalWeight : fallback;
|
||||
}
|
||||
|
||||
// Index of the single highest-weight neighbor present in the reference
|
||||
// section (ties on weight broken by the earliest edge). Used only as a
|
||||
// barycenter tie-break so a node stays beside its dominant neighbor's group
|
||||
// instead of falling back to a stale seed index. Falls back to the node's own
|
||||
// index when it has no resolvable neighbor, matching computeBarycenter.
|
||||
export function dominantNeighborIndex(
|
||||
neighbors: WeightedNeighbor[],
|
||||
referenceIdIndexMap: Map<string, number>,
|
||||
fallback: number
|
||||
): number {
|
||||
let bestIdx = fallback;
|
||||
let bestWeight = -Infinity;
|
||||
neighbors.forEach(({ id, weight }) => {
|
||||
const idx = referenceIdIndexMap.get(id);
|
||||
if (idx !== undefined && weight > bestWeight) {
|
||||
bestWeight = weight;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
function buildIdIndexMap(section: Node[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
section.forEach((node, index) => map.set(node.id, index));
|
||||
@@ -364,20 +342,12 @@ function sortSectionByBarycenter(
|
||||
referenceMap: Map<string, number>,
|
||||
getNeighbors: (node: Node) => WeightedNeighbor[]
|
||||
): { sorted: Node[]; changed: boolean } {
|
||||
const decorated = section.map((node, index) => {
|
||||
const neighbors = getNeighbors(node);
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
barycenter: computeBarycenter(neighbors, referenceMap, index),
|
||||
// Tie-break that keeps a node next to its dominant neighbor's group.
|
||||
anchor: dominantNeighborIndex(neighbors, referenceMap, index),
|
||||
};
|
||||
});
|
||||
decorated.sort(
|
||||
(a, b) =>
|
||||
a.barycenter - b.barycenter || a.anchor - b.anchor || a.index - b.index
|
||||
);
|
||||
const decorated = section.map((node, index) => ({
|
||||
node,
|
||||
index,
|
||||
barycenter: computeBarycenter(getNeighbors(node), referenceMap, index),
|
||||
}));
|
||||
decorated.sort((a, b) => a.barycenter - b.barycenter || a.index - b.index);
|
||||
const sorted = decorated.map((d) => d.node);
|
||||
const changed = sorted.some((n, idx) => n !== section[idx]);
|
||||
return { sorted, changed };
|
||||
@@ -482,55 +452,6 @@ function crossingsAdjacentTo(
|
||||
return total;
|
||||
}
|
||||
|
||||
function countAllCrossings(
|
||||
sections: Node[][],
|
||||
sectionMaps: Map<string, number>[],
|
||||
depths: number[],
|
||||
edges: GraphEdge[]
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < sections.length - 1; i++) {
|
||||
total += countCrossings(
|
||||
getEdgeSegmentsBetween(
|
||||
depths[i],
|
||||
depths[i + 1],
|
||||
depths,
|
||||
edges,
|
||||
sectionMaps[i],
|
||||
sectionMaps[i + 1]
|
||||
)
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// A section is "multi-parent" when at least one of its real nodes draws from
|
||||
// two or more distinct parents in the previous section. In the energy/power/
|
||||
// water cards only the source/home layers do this (grid/solar/battery feed
|
||||
// home + battery_in + grid_return); every later section (floors, areas,
|
||||
// devices) is a pure single-parent tree level. Pass-throughs are always
|
||||
// single-parent (one source/target chain) and never make a section multi-parent.
|
||||
function sectionHasMultipleParents(
|
||||
section: Node[],
|
||||
prevDepth: number,
|
||||
depths: number[]
|
||||
): boolean {
|
||||
return section.some((node) => {
|
||||
if (isPassThroughNode(node)) {
|
||||
return false;
|
||||
}
|
||||
const parentIds = new Set(
|
||||
getNeighborIds(node, "source", prevDepth, depths).map((n) => n.id)
|
||||
);
|
||||
return parentIds.size > 1;
|
||||
});
|
||||
}
|
||||
|
||||
// The head barycenter sweep (STEP 1) only touches the multi-parent head
|
||||
// sections (the single-parent tree below is placed deterministically in
|
||||
// STEP 2), so a single forward+backward pass converges in practice and the loop
|
||||
// early-exits on the first no-change sweep. The cap is just headroom for unusual
|
||||
// topologies with several interacting head sections.
|
||||
const MAX_SORT_ITERATIONS = 4;
|
||||
|
||||
export function sortNodesInSections(
|
||||
@@ -539,30 +460,13 @@ export function sortNodesInSections(
|
||||
edges: GraphEdge[]
|
||||
): Record<number, Node[]> {
|
||||
const sections: Node[][] = depths.map((d) => [...(nodesPerSection[d] || [])]);
|
||||
// Id→index lookup per section, kept in sync with sections.
|
||||
// Id→index lookup per section, kept in sync with sections. Rebuilt only when
|
||||
// a section's order actually changes (inside tryReplace).
|
||||
const sectionMaps: Map<string, number>[] = sections.map(buildIdIndexMap);
|
||||
|
||||
// Classify each section past the root. Multi-parent sections (the
|
||||
// intentionally-ordered source/home layers) are minimized by barycenter in
|
||||
// PASS 2; the rest are single-parent tree levels placed deterministically in
|
||||
// PASS 1. Classification reads only the graph, so it is stable across passes.
|
||||
const multiParent = depths.map(
|
||||
(_d, i) =>
|
||||
i >= 1 && sectionHasMultipleParents(sections[i], depths[i - 1], depths)
|
||||
);
|
||||
|
||||
// Best (fewest-crossing) head ordering seen so far, seeded from the original
|
||||
// input so the head is provably never worse than the seed.
|
||||
const snapshot = (): Node[][] => sections.map((s) => s.slice());
|
||||
let liveCrossings = countAllCrossings(sections, sectionMaps, depths, edges);
|
||||
let bestCrossings = liveCrossings;
|
||||
let bestSections = snapshot();
|
||||
|
||||
// Replace a multi-parent section with a candidate ordering when crossings on
|
||||
// its adjacent boundaries do not increase. Accepting equal-crossing
|
||||
// ("plateau") moves lets the sweep escape local optima; the best snapshot
|
||||
// (captured only on a strict global decrease) is what seeds the head order,
|
||||
// so the result is deterministic and never worse than the seed.
|
||||
// Replace a section with a candidate ordering only when crossings strictly
|
||||
// drop on either side. This keeps user-intended ordering intact when
|
||||
// barycenter would shuffle nodes without improving the layout.
|
||||
const tryReplace = (i: number, candidate: Node[]): boolean => {
|
||||
const before = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
|
||||
const sectionSnapshot = sections[i];
|
||||
@@ -570,12 +474,7 @@ export function sortNodesInSections(
|
||||
sections[i] = candidate;
|
||||
sectionMaps[i] = buildIdIndexMap(candidate);
|
||||
const after = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
|
||||
if (after <= before) {
|
||||
liveCrossings += after - before;
|
||||
if (liveCrossings < bestCrossings) {
|
||||
bestCrossings = liveCrossings;
|
||||
bestSections = snapshot();
|
||||
}
|
||||
if (after < before) {
|
||||
return true;
|
||||
}
|
||||
sections[i] = sectionSnapshot;
|
||||
@@ -583,97 +482,39 @@ export function sortNodesInSections(
|
||||
return false;
|
||||
};
|
||||
|
||||
// STEP 1 — settle the multi-parent head sections (sources → home/battery_in/
|
||||
// grid_return) by barycenter. These layers have nodes with several parents and
|
||||
// so no single parent position to inherit; we minimize their crossings by the
|
||||
// weighted average of neighbour positions, iterating forward then backward
|
||||
// until the order is stable. The backward sweep is restricted to multi-parent
|
||||
// neighbours, so the head order never depends on its single-parent children —
|
||||
// that is what keeps the whole result idempotent, because STEP 2 re-derives
|
||||
// those children purely from the settled head. The root section (index 0) is
|
||||
// never reordered.
|
||||
if (multiParent.some(Boolean)) {
|
||||
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
if (!multiParent[i]) {
|
||||
continue;
|
||||
}
|
||||
const prevDepth = depths[i - 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
const prevDepth = depths[i - 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = sections.length - 2; i >= 1; i--) {
|
||||
if (!multiParent[i] || !multiParent[i + 1]) {
|
||||
continue;
|
||||
}
|
||||
const nextDepth = depths[i + 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i + 1],
|
||||
(node) => getNeighborIds(node, "target", nextDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
for (let i = sections.length - 2; i >= 0; i--) {
|
||||
const nextDepth = depths[i + 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i + 1],
|
||||
(node) => getNeighborIds(node, "target", nextDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed || bestCrossings === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2 — deterministic hierarchy placement. Starting from the best head
|
||||
// ordering, walk left to right and order every single-parent section by the
|
||||
// position of each node's single parent in the already-final previous section
|
||||
// (sortSectionByBarycenter with one parent reduces to a stable sort by that
|
||||
// parent's index). This is the classic layered-tree drawing: each parent's
|
||||
// children stay contiguous, every single-parent boundary is crossing-free,
|
||||
// pass-throughs travel along their chain, and the user-configured floor/area
|
||||
// order is preserved because same-parent siblings keep their seed index.
|
||||
// It runs unconditionally — grouping a parent's children under it must win
|
||||
// even on a crossing-neutral plateau (the #52852 fix) — and because it is a
|
||||
// pure function of the settled head, re-running yields the identical layout.
|
||||
const finalSections = bestSections.map((s) => s.slice());
|
||||
const finalMaps = finalSections.map(buildIdIndexMap);
|
||||
for (let i = 1; i < finalSections.length; i++) {
|
||||
if (multiParent[i]) {
|
||||
continue;
|
||||
}
|
||||
const prevDepth = depths[i - 1];
|
||||
const { sorted } = sortSectionByBarycenter(
|
||||
finalSections[i],
|
||||
finalMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
finalSections[i] = sorted;
|
||||
finalMaps[i] = buildIdIndexMap(sorted);
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
// Hierarchy placement makes every single-parent boundary crossing-free, so on
|
||||
// the energy/water cards (multi-parent only at the head) it can only lower the
|
||||
// total. Guard the general graph: if regrouping somehow raised crossings (only
|
||||
// possible for a multi-parent section sitting *below* single-parent ones,
|
||||
// which the cards never produce), fall back to the gated best head so the
|
||||
// never-worse-than-seed guarantee always holds. Ties keep the grouped layout.
|
||||
const finalCrossings = countAllCrossings(
|
||||
finalSections,
|
||||
finalMaps,
|
||||
depths,
|
||||
edges
|
||||
);
|
||||
const chosen = finalCrossings <= bestCrossings ? finalSections : bestSections;
|
||||
|
||||
const sortedSections: Record<number, Node[]> = {};
|
||||
depths.forEach((depth, i) => {
|
||||
sortedSections[depth] = chosen[i];
|
||||
sortedSections[depth] = sections[i];
|
||||
});
|
||||
return sortedSections;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, unsafeCSS } from "lit";
|
||||
export const fontStyles = css`
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Thin"),
|
||||
local("Roboto-Thin"),
|
||||
@@ -13,6 +14,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Thin Italic"),
|
||||
local("Roboto-ThinItalic"),
|
||||
@@ -23,6 +25,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Light"),
|
||||
local("Roboto-Light"),
|
||||
@@ -33,6 +36,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Light Italic"),
|
||||
local("Roboto-LightItalic"),
|
||||
@@ -43,6 +47,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Regular"),
|
||||
local("Roboto-Regular"),
|
||||
@@ -53,6 +58,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Italic"),
|
||||
local("Roboto-Italic"),
|
||||
@@ -63,6 +69,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Medium"),
|
||||
local("Roboto-Medium"),
|
||||
@@ -73,6 +80,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Medium Italic"),
|
||||
local("Roboto-MediumItalic"),
|
||||
@@ -83,6 +91,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Bold"),
|
||||
local("Roboto-Bold"),
|
||||
@@ -93,6 +102,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Bold Italic"),
|
||||
local("Roboto-BoldItalic"),
|
||||
@@ -103,6 +113,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Black"),
|
||||
local("Roboto-Black"),
|
||||
@@ -113,6 +124,7 @@ export const fontStyles = css`
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Roboto Black Italic"),
|
||||
local("Roboto-BlackItalic"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { getConfigSubpageTitle, getPanelTitleFromUrlPath } from "../data/panel";
|
||||
import { configSections } from "../panels/config/ha-panel-config";
|
||||
import { configSections } from "../panels/config/config-sections";
|
||||
import type { Constructor, HomeAssistant } from "../types";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
|
||||
|
||||
@@ -2503,7 +2503,6 @@
|
||||
"edit_sidebar": "Edit sidebar",
|
||||
"edit_subtitle": "Synced on all devices",
|
||||
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
|
||||
"default": "default",
|
||||
"reset_to_defaults": "Reset to defaults",
|
||||
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
|
||||
},
|
||||
|
||||
@@ -66,7 +66,9 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
// `false` for contexts: HomeAssistantAppEl already provides them via
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -54,29 +54,16 @@ async function assertPageLoads(page: Page, hash: string, selector: string) {
|
||||
}
|
||||
|
||||
// Errors that are gallery-harness artifacts rather than bugs in the component
|
||||
// under test. The gallery feeds demos a synchronous mock `hass`, but migrated
|
||||
// components now read `localize`/formatters from Lit context, which resolves
|
||||
// asynchronously — so a demo can render one frame before the context lands and
|
||||
// throw a transient "undefined" error during init. These don't prevent the
|
||||
// demo from rendering (the toBeAttached check above still has to pass), and
|
||||
// they're timing-dependent, so we filter the whole init-error family here.
|
||||
// under test. The Lit-context init-error family that used to live here is gone:
|
||||
// ha-gallery now provides fallback contexts for every demo (mirroring the real
|
||||
// app's root), so context-consuming components resolve `localize`, formatters,
|
||||
// config, etc. synchronously instead of throwing during init.
|
||||
const IGNORED_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// localize consumed from context before it resolves (`this.localize` /
|
||||
// `this._localize is not a function`, or `hass.localize` read as undefined).
|
||||
/_?localize is not a function/,
|
||||
/Cannot read properties of undefined \(reading 'localize'\)/,
|
||||
// Formatters consumed from context before it resolves.
|
||||
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
|
||||
// hass API methods consumed from context before it resolves (e.g. the
|
||||
// update demo fetches release notes via callWS during init).
|
||||
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
|
||||
// locale fields read before the mock locale is wired up.
|
||||
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data — pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds and posts a PR comment summarising Playwright E2E failures from the
|
||||
// merged JSON report. Invoked from the `report` job in .github/workflows/e2e.yaml
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: postReportComment } =
|
||||
// await import("${{ github.workspace }}/test/e2e/post-report-comment.mjs");
|
||||
// await postReportComment({ github, context, core });
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const REPORT_PATH = "test/e2e/reports/combined/results.json";
|
||||
|
||||
// GitHub comment bodies cap at 65536 chars; leave headroom.
|
||||
const MAX_BODY = 60000;
|
||||
|
||||
// Strip ANSI colour codes that Playwright bakes into error messages.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
|
||||
// Walk the JSON report tree and collect every failing spec with its error
|
||||
// output, so the comment shows the actual test failures.
|
||||
const collectFailures = (report) => {
|
||||
const failures = [];
|
||||
|
||||
const walk = (suite, titlePath) => {
|
||||
const here = suite.title ? [...titlePath, suite.title] : titlePath;
|
||||
for (const spec of suite.specs ?? []) {
|
||||
if (spec.ok) continue;
|
||||
const errors = [];
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const result of test.results ?? []) {
|
||||
for (const err of result.errors ?? []) {
|
||||
if (err.message) errors.push(stripAnsi(err.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
failures.push({
|
||||
title: [...here, spec.title].join(" › "),
|
||||
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
for (const child of suite.suites ?? []) walk(child, here);
|
||||
};
|
||||
|
||||
for (const suite of report.suites ?? []) walk(suite, []);
|
||||
return failures;
|
||||
};
|
||||
|
||||
const formatFailure = (failure) => {
|
||||
const output =
|
||||
failure.errors.join("\n\n").trim() || "(no error output captured)";
|
||||
return [
|
||||
`<details><summary>❌ ${failure.title} <code>${failure.location}</code></summary>`,
|
||||
"",
|
||||
"```ts",
|
||||
output,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
export default async function postReportComment({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
|
||||
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
|
||||
let failures = [];
|
||||
|
||||
try {
|
||||
const report = JSON.parse(readFileSync(REPORT_PATH, "utf8"));
|
||||
stats = report.stats ?? stats;
|
||||
failures = collectFailures(report);
|
||||
} catch (err) {
|
||||
core.warning(`Could not parse Playwright JSON report: ${err.message}`);
|
||||
}
|
||||
|
||||
const summaryLine =
|
||||
`**${stats.unexpected} failed**, ${stats.expected} passed` +
|
||||
(stats.flaky ? `, ${stats.flaky} flaky` : "") +
|
||||
(stats.skipped ? `, ${stats.skipped} skipped` : "");
|
||||
|
||||
const details = failures.length
|
||||
? failures.map(formatFailure).join("\n")
|
||||
: "_No failing tests were captured in the report._";
|
||||
|
||||
let body = [
|
||||
"## Playwright E2E tests failed",
|
||||
"",
|
||||
summaryLine,
|
||||
"",
|
||||
details,
|
||||
"",
|
||||
"The combined HTML report is available as a workflow artifact.",
|
||||
"",
|
||||
`[View workflow run](${runUrl})`,
|
||||
].join("\n");
|
||||
|
||||
if (body.length > MAX_BODY) {
|
||||
body = `${body.slice(0, MAX_BODY)}\n\n_…report truncated, see the full HTML report artifact._`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getPassThroughSections,
|
||||
createPassThroughNode,
|
||||
computeBarycenter,
|
||||
dominantNeighborIndex,
|
||||
sortNodesInSections,
|
||||
} from "../../../../../src/resources/echarts/components/sankey/sankey-layout";
|
||||
|
||||
@@ -757,470 +756,5 @@ describe("Sankey Layout Functions", () => {
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("untangles a plateau-trapped subtree to remove an avoidable crossing (#52852)", () => {
|
||||
// Realistic consumption tree: home → floors → areas → devices, plus two
|
||||
// devices that attach higher up — one on a floor with no area
|
||||
// (dev_floor_outside) and one straight on home (dev_home) — which the
|
||||
// engine threads through with pass-throughs. The seed splits
|
||||
// floor_outside's subtree: its pass-through child sits *after*
|
||||
// floor_foundation's area. Pulling it back trades a crossing from the
|
||||
// (1,2) boundary to the (2,3) boundary — a net-zero "plateau" the old
|
||||
// strict gate refused, leaving the crossing. The plateau-escape must now
|
||||
// take that step and let the device section follow, reaching 0 crossings.
|
||||
const e = {
|
||||
homeFo: { source: "home", target: "floor_outside", value: 1 },
|
||||
homeFf: { source: "home", target: "floor_foundation", value: 1 },
|
||||
foHvac: { source: "floor_outside", target: "area_hvac", value: 1 },
|
||||
ffParking: {
|
||||
source: "floor_foundation",
|
||||
target: "area_parking",
|
||||
value: 1,
|
||||
},
|
||||
hvacDev: { source: "area_hvac", target: "dev_hvac", value: 1 },
|
||||
parkingDev: { source: "area_parking", target: "dev_parking", value: 1 },
|
||||
foDev: {
|
||||
source: "floor_outside",
|
||||
target: "dev_floor_outside",
|
||||
value: 1,
|
||||
},
|
||||
homeDev: { source: "home", target: "dev_home", value: 1 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 0,
|
||||
value: 4,
|
||||
inEdges: [],
|
||||
outEdges: [e.homeFo, e.homeFf, e.homeDev],
|
||||
},
|
||||
floor_outside: {
|
||||
id: "floor_outside",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.homeFo],
|
||||
outEdges: [e.foHvac, e.foDev],
|
||||
},
|
||||
floor_foundation: {
|
||||
id: "floor_foundation",
|
||||
depth: 1,
|
||||
value: 1,
|
||||
inEdges: [e.homeFf],
|
||||
outEdges: [e.ffParking],
|
||||
},
|
||||
area_hvac: {
|
||||
id: "area_hvac",
|
||||
depth: 2,
|
||||
value: 1,
|
||||
inEdges: [e.foHvac],
|
||||
outEdges: [e.hvacDev],
|
||||
},
|
||||
area_parking: {
|
||||
id: "area_parking",
|
||||
depth: 2,
|
||||
value: 1,
|
||||
inEdges: [e.ffParking],
|
||||
outEdges: [e.parkingDev],
|
||||
},
|
||||
dev_hvac: {
|
||||
id: "dev_hvac",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.hvacDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_parking: {
|
||||
id: "dev_parking",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.parkingDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_floor_outside: {
|
||||
id: "dev_floor_outside",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.foDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_home: {
|
||||
id: "dev_home",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.homeDev],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const ptHome1 = createPassThroughNode("home", "dev_home", 1, 1);
|
||||
const ptFo2 = createPassThroughNode(
|
||||
"floor_outside",
|
||||
"dev_floor_outside",
|
||||
2,
|
||||
1
|
||||
);
|
||||
const ptHome2 = createPassThroughNode("home", "dev_home", 2, 1);
|
||||
|
||||
// Seed order from ha-sankey-chart: pass-throughs appended after the real
|
||||
// children, so floor_outside's subtree is broken across the section.
|
||||
const input = {
|
||||
0: [graph.home],
|
||||
1: [graph.floor_outside, graph.floor_foundation, ptHome1],
|
||||
2: [graph.area_hvac, graph.area_parking, ptFo2, ptHome2],
|
||||
3: [
|
||||
graph.dev_hvac,
|
||||
graph.dev_parking,
|
||||
graph.dev_floor_outside,
|
||||
graph.dev_home,
|
||||
],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
|
||||
|
||||
// floor_outside's children (area_hvac and its pass-through) are now
|
||||
// contiguous, ahead of floor_foundation's; the layout is crossing-free.
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["home"],
|
||||
1: ["floor_outside", "floor_foundation", "home-dev_home-1"],
|
||||
2: [
|
||||
"area_hvac",
|
||||
"floor_outside-dev_floor_outside-2",
|
||||
"area_parking",
|
||||
"home-dev_home-2",
|
||||
],
|
||||
3: ["dev_hvac", "dev_floor_outside", "dev_parking", "dev_home"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
|
||||
// Re-running on the result must not drift: plateau churn is discarded and
|
||||
// the best snapshot is returned, so the order is stable (idempotent).
|
||||
const again = sortNodesInSections(result, [0, 1, 2, 3], edges);
|
||||
expect(sectionIds(again)).toEqual(sectionIds(result));
|
||||
});
|
||||
|
||||
it("groups single-parent siblings under their parent and keeps configured sibling order", () => {
|
||||
// Two floors, two areas each, fed to the engine interleaved (not grouped
|
||||
// by floor). The deterministic hierarchy pass must regroup areas under
|
||||
// their floor, and within a floor preserve the configured (seed) order:
|
||||
// a1 before a2, b1 before b2.
|
||||
const e = {
|
||||
hFa: { source: "home", target: "floor_a", value: 2 },
|
||||
hFb: { source: "home", target: "floor_b", value: 2 },
|
||||
faA1: { source: "floor_a", target: "a1", value: 1 },
|
||||
faA2: { source: "floor_a", target: "a2", value: 1 },
|
||||
fbB1: { source: "floor_b", target: "b1", value: 1 },
|
||||
fbB2: { source: "floor_b", target: "b2", value: 1 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 0,
|
||||
value: 4,
|
||||
inEdges: [],
|
||||
outEdges: [e.hFa, e.hFb],
|
||||
},
|
||||
floor_a: {
|
||||
id: "floor_a",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.hFa],
|
||||
outEdges: [e.faA1, e.faA2],
|
||||
},
|
||||
floor_b: {
|
||||
id: "floor_b",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.hFb],
|
||||
outEdges: [e.fbB1, e.fbB2],
|
||||
},
|
||||
a1: { id: "a1", depth: 2, value: 1, inEdges: [e.faA1], outEdges: [] },
|
||||
a2: { id: "a2", depth: 2, value: 1, inEdges: [e.faA2], outEdges: [] },
|
||||
b1: { id: "b1", depth: 2, value: 1, inEdges: [e.fbB1], outEdges: [] },
|
||||
b2: { id: "b2", depth: 2, value: 1, inEdges: [e.fbB2], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.home],
|
||||
1: [graph.floor_a, graph.floor_b],
|
||||
2: [graph.a1, graph.b1, graph.a2, graph.b2], // interleaved
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["home"],
|
||||
1: ["floor_a", "floor_b"],
|
||||
2: ["a1", "a2", "b1", "b2"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("orders a single-parent section by parent position, ignoring flow magnitude", () => {
|
||||
// childB carries a far larger flow than childA, but a single-parent
|
||||
// section is ordered by parent position (hierarchy), never by value.
|
||||
const edgeACa = { source: "A", target: "childA", value: 1 };
|
||||
const edgeBCb = { source: "B", target: "childB", value: 100 };
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
A: { id: "A", depth: 0, value: 1, inEdges: [], outEdges: [edgeACa] },
|
||||
B: { id: "B", depth: 0, value: 100, inEdges: [], outEdges: [edgeBCb] },
|
||||
childA: {
|
||||
id: "childA",
|
||||
depth: 1,
|
||||
value: 1,
|
||||
inEdges: [edgeACa],
|
||||
outEdges: [],
|
||||
},
|
||||
childB: {
|
||||
id: "childB",
|
||||
depth: 1,
|
||||
value: 100,
|
||||
inEdges: [edgeBCb],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = { 0: [graph.A, graph.B], 1: [graph.childB, graph.childA] };
|
||||
const result = sortNodesInSections(input, [0, 1], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["A", "B"],
|
||||
1: ["childA", "childB"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("keeps the single-parent tree hierarchical below a multi-parent source layer", () => {
|
||||
// grid + solar feed home (a genuine multi-parent section that stays under
|
||||
// the barycenter sweep); the floor/area tree below is single-parent and
|
||||
// must regroup by parent regardless of the multi-parent head.
|
||||
const e = {
|
||||
gH: { source: "grid", target: "home", value: 2 },
|
||||
sH: { source: "solar", target: "home", value: 2 },
|
||||
hFa: { source: "home", target: "floor_a", value: 2 },
|
||||
hFb: { source: "home", target: "floor_b", value: 2 },
|
||||
faA: { source: "floor_a", target: "area_a", value: 2 },
|
||||
fbB: { source: "floor_b", target: "area_b", value: 2 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
grid: { id: "grid", depth: 0, value: 2, inEdges: [], outEdges: [e.gH] },
|
||||
solar: {
|
||||
id: "solar",
|
||||
depth: 0,
|
||||
value: 2,
|
||||
inEdges: [],
|
||||
outEdges: [e.sH],
|
||||
},
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 1,
|
||||
value: 4,
|
||||
inEdges: [e.gH, e.sH],
|
||||
outEdges: [e.hFa, e.hFb],
|
||||
},
|
||||
floor_a: {
|
||||
id: "floor_a",
|
||||
depth: 2,
|
||||
value: 2,
|
||||
inEdges: [e.hFa],
|
||||
outEdges: [e.faA],
|
||||
},
|
||||
floor_b: {
|
||||
id: "floor_b",
|
||||
depth: 2,
|
||||
value: 2,
|
||||
inEdges: [e.hFb],
|
||||
outEdges: [e.fbB],
|
||||
},
|
||||
area_a: {
|
||||
id: "area_a",
|
||||
depth: 3,
|
||||
value: 2,
|
||||
inEdges: [e.faA],
|
||||
outEdges: [],
|
||||
},
|
||||
area_b: {
|
||||
id: "area_b",
|
||||
depth: 3,
|
||||
value: 2,
|
||||
inEdges: [e.fbB],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.grid, graph.solar],
|
||||
1: [graph.home],
|
||||
2: [graph.floor_a, graph.floor_b],
|
||||
3: [graph.area_b, graph.area_a], // reversed; must regroup under floors
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["grid", "solar"],
|
||||
1: ["home"],
|
||||
2: ["floor_a", "floor_b"],
|
||||
3: ["area_a", "area_b"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("regroups single-parent children after a multi-parent head is reordered (idempotent)", () => {
|
||||
// A multi-parent head section [H0,H1,H2] (only H1 draws from two sources)
|
||||
// gets reordered by the barycenter sweep. Its single-parent children must
|
||||
// then be regrouped under the *settled* head — H1's children before H2's
|
||||
// child — and the result must be idempotent. Before the head/tree passes
|
||||
// were ordered correctly the children stayed grouped against the head's
|
||||
// SEED order, which both mis-grouped them and broke f(f(x)) === f(x).
|
||||
const e = {
|
||||
s0h0: { source: "S0", target: "H0", value: 6 },
|
||||
s0h1: { source: "S0", target: "H1", value: 7 },
|
||||
s1h1: { source: "S1", target: "H1", value: 5 },
|
||||
s2h2: { source: "S2", target: "H2", value: 6 },
|
||||
h1d0: { source: "H1", target: "D0", value: 7 },
|
||||
h1d2: { source: "H1", target: "D2", value: 9 },
|
||||
h2d1: { source: "H2", target: "D1", value: 7 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
S0: {
|
||||
id: "S0",
|
||||
depth: 0,
|
||||
value: 13,
|
||||
inEdges: [],
|
||||
outEdges: [e.s0h0, e.s0h1],
|
||||
},
|
||||
S1: { id: "S1", depth: 0, value: 5, inEdges: [], outEdges: [e.s1h1] },
|
||||
S2: { id: "S2", depth: 0, value: 6, inEdges: [], outEdges: [e.s2h2] },
|
||||
H0: { id: "H0", depth: 1, value: 6, inEdges: [e.s0h0], outEdges: [] },
|
||||
H1: {
|
||||
id: "H1",
|
||||
depth: 1,
|
||||
value: 12,
|
||||
inEdges: [e.s1h1, e.s0h1],
|
||||
outEdges: [e.h1d0, e.h1d2],
|
||||
},
|
||||
H2: {
|
||||
id: "H2",
|
||||
depth: 1,
|
||||
value: 6,
|
||||
inEdges: [e.s2h2],
|
||||
outEdges: [e.h2d1],
|
||||
},
|
||||
D0: { id: "D0", depth: 2, value: 7, inEdges: [e.h1d0], outEdges: [] },
|
||||
D1: { id: "D1", depth: 2, value: 7, inEdges: [e.h2d1], outEdges: [] },
|
||||
D2: { id: "D2", depth: 2, value: 9, inEdges: [e.h1d2], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.S0, graph.S1, graph.S2],
|
||||
1: [graph.H2, graph.H1, graph.H0],
|
||||
2: [graph.D1, graph.D2, graph.D0],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
// Head settles to barycenter order [H0,H1,H2]; children regroup under it:
|
||||
// H1's children (D2,D0) precede H2's child (D1).
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["S0", "S1", "S2"],
|
||||
1: ["H0", "H1", "H2"],
|
||||
2: ["D2", "D0", "D1"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
|
||||
// Idempotent: re-feeding the output yields the identical order.
|
||||
const again = sortNodesInSections(result, [0, 1, 2], edges);
|
||||
expect(sectionIds(again)).toEqual(sectionIds(result));
|
||||
});
|
||||
|
||||
it("lets single-parent children follow their reordered multi-parent parent to reach 0 crossings", () => {
|
||||
// root [a,b,c,d]; section 1 [m1,m2] is multi-parent with a clean split
|
||||
// (c,d -> m1 ; a,b -> m2); section 2 [x<-m1, y<-m2] single-parent. The
|
||||
// optimum needs BOTH the parent order swapped (m2,m1) AND the children to
|
||||
// follow (y,x) — placing the tree after the head settles reaches it.
|
||||
const e = {
|
||||
am2: { source: "a", target: "m2", value: 1 },
|
||||
bm2: { source: "b", target: "m2", value: 1 },
|
||||
cm1: { source: "c", target: "m1", value: 1 },
|
||||
dm1: { source: "d", target: "m1", value: 1 },
|
||||
m1x: { source: "m1", target: "x", value: 2 },
|
||||
m2y: { source: "m2", target: "y", value: 2 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
a: { id: "a", depth: 0, value: 1, inEdges: [], outEdges: [e.am2] },
|
||||
b: { id: "b", depth: 0, value: 1, inEdges: [], outEdges: [e.bm2] },
|
||||
c: { id: "c", depth: 0, value: 1, inEdges: [], outEdges: [e.cm1] },
|
||||
d: { id: "d", depth: 0, value: 1, inEdges: [], outEdges: [e.dm1] },
|
||||
m1: {
|
||||
id: "m1",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.cm1, e.dm1],
|
||||
outEdges: [e.m1x],
|
||||
},
|
||||
m2: {
|
||||
id: "m2",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.am2, e.bm2],
|
||||
outEdges: [e.m2y],
|
||||
},
|
||||
x: { id: "x", depth: 2, value: 2, inEdges: [e.m1x], outEdges: [] },
|
||||
y: { id: "y", depth: 2, value: 2, inEdges: [e.m2y], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.a, graph.b, graph.c, graph.d],
|
||||
1: [graph.m1, graph.m2],
|
||||
2: [graph.x, graph.y],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["a", "b", "c", "d"],
|
||||
1: ["m2", "m1"],
|
||||
2: ["y", "x"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dominantNeighborIndex", () => {
|
||||
it("returns the index of the single heaviest neighbor", () => {
|
||||
const map = new Map([
|
||||
["light", 0],
|
||||
["heavy", 3],
|
||||
]);
|
||||
const result = dominantNeighborIndex(
|
||||
[
|
||||
{ id: "light", weight: 1 },
|
||||
{ id: "heavy", weight: 5 },
|
||||
],
|
||||
map,
|
||||
9
|
||||
);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("breaks weight ties by the earliest edge", () => {
|
||||
const map = new Map([
|
||||
["a", 2],
|
||||
["b", 4],
|
||||
]);
|
||||
const result = dominantNeighborIndex(
|
||||
[
|
||||
{ id: "a", weight: 1 },
|
||||
{ id: "b", weight: 1 },
|
||||
],
|
||||
map,
|
||||
9
|
||||
);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back when no neighbor is in the reference section", () => {
|
||||
const result = dominantNeighborIndex(
|
||||
[{ id: "missing", weight: 1 }],
|
||||
new Map(),
|
||||
7
|
||||
);
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4194,14 +4194,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "@playwright/test@npm:1.60.0"
|
||||
"@playwright/test@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "@playwright/test@npm:1.61.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.60.0"
|
||||
playwright: "npm:1.61.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/a13d369014e1934b0aa484c5d59537f5249af0fe006ac4ecbcbe14c673221412706193ea2d9cf3b2c0cc69e3ddbb4daddb006f0bedfdeb05f687776ed8c35f5f
|
||||
checksum: 10/359a9a4a59a87361d416818966bb810a38970d9f2b8364349d22099fb23eb043530714374a83010f9f3471c7e758df43c49bf32128745af13277adfb211aa752
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4856,7 +4856,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/dev-middleware@npm:^2.0.1":
|
||||
"@rspack/dev-middleware@npm:^2.0.3":
|
||||
version: 2.0.3
|
||||
resolution: "@rspack/dev-middleware@npm:2.0.3"
|
||||
peerDependencies:
|
||||
@@ -4868,18 +4868,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/dev-server@npm:2.0.3":
|
||||
version: 2.0.3
|
||||
resolution: "@rspack/dev-server@npm:2.0.3"
|
||||
"@rspack/dev-server@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "@rspack/dev-server@npm:2.1.0"
|
||||
dependencies:
|
||||
"@rspack/dev-middleware": "npm:^2.0.1"
|
||||
"@rspack/dev-middleware": "npm:^2.0.3"
|
||||
peerDependencies:
|
||||
"@rspack/core": ^2.0.0-0
|
||||
"@rspack/core": ^2.0.0
|
||||
selfsigned: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
selfsigned:
|
||||
optional: true
|
||||
checksum: 10/39ec36029e849cb5799c5cc8041b14df2732ec701215c25408b32b8e7fd3c7341f3f237edf7969cacaf4953684bd617b91a5730e144a2f21be899ab20f271fb7
|
||||
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9748,11 +9748,11 @@ __metadata:
|
||||
"@octokit/auth-oauth-device": "npm:8.0.3"
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.60.0"
|
||||
"@playwright/test": "npm:1.61.0"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.5.15"
|
||||
"@rspack/core": "npm:2.0.8"
|
||||
"@rspack/dev-server": "npm:2.0.3"
|
||||
"@rspack/dev-server": "npm:2.1.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
"@tsparticles/engine": "npm:4.2.1"
|
||||
@@ -12600,27 +12600,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "playwright-core@npm:1.60.0"
|
||||
"playwright-core@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "playwright-core@npm:1.61.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/66c0f83d627e673261c848dd6fe1f2856d5b5b6859268acb61a45c00f5bf926e596a351a9c481a3a4e82a45022c7c6b5d99ebc3906fc6876ac582ed6f7e16190
|
||||
checksum: 10/e7ac4b2ef1c5f1701ec8086e47bc341988a3f753009d450e2a62daab5ade1fa943f1ef7fb48c721618dd7bd42667a11ff73c2e5dbd0674c20a3397b3c5be2f61
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "playwright@npm:1.60.0"
|
||||
"playwright@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "playwright@npm:1.61.0"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.60.0"
|
||||
playwright-core: "npm:1.61.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/8569770637ee35d08cca3b53a5b56c21e9236bd1ac4718456d5988fb8acd51c5b3cc2cf90748363a36199529e870a9b6c68d6fc9e19571261cd867005d6331c1
|
||||
checksum: 10/b5faf97391315334a30e88e03216fd6090988b99896e71b341995a27c49d49dcebc825ec389dabc9b2d2cab6368c418cad9cbb925a88de35fb3b26a19bf05bea
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user