Compare commits

...

11 Commits

Author SHA1 Message Date
Aidan Timson
d1afcfcd32 Match original more closely 2025-11-07 13:43:20 +00:00
Aidan Timson
29e6ada90e Remove binary 2025-11-07 12:12:32 +00:00
Aidan Timson
ff1745fccc Working connection 2025-11-07 12:11:04 +00:00
Aidan Timson
59aaf86104 Update 2025-11-07 11:54:59 +00:00
Aidan Timson
52bc692c79 Add simple backend 2025-11-07 11:45:51 +00:00
Aidan Timson
29de405912 Setup CLI in container 2025-11-07 11:37:58 +00:00
Aidan Timson
a723171ef2 Clear mocks 2025-11-07 11:25:36 +00:00
Aidan Timson
cfe193cf60 Setup 2025-11-07 11:16:04 +00:00
Aidan Timson
a8e6557b09 Fix 2025-11-07 10:49:23 +00:00
Aidan Timson
a517aabdd8 Basic auto theme support 2025-11-07 10:44:30 +00:00
Aidan Timson
10c8828aa5 Setup 2025-11-07 10:33:51 +00:00
26 changed files with 1551 additions and 0 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
build/ build/
dist/ dist/
/hass_frontend/ /hass_frontend/
/logs/dist/
/translations/ /translations/
# yarn # yarn

View File

@@ -327,6 +327,20 @@ module.exports.config = {
}; };
}, },
logs({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "logs" + nameSuffix(latestBuild),
entry: {
entrypoint: path.resolve(paths.logs_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.logs_output_root, latestBuild),
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isStatsBuild,
};
},
landingPage({ isProdBuild, latestBuild }) { landingPage({ isProdBuild, latestBuild }) {
return { return {
name: "landing-page" + nameSuffix(latestBuild), name: "landing-page" + nameSuffix(latestBuild),

View File

@@ -39,6 +39,13 @@ gulp.task(
) )
); );
gulp.task(
"clean-logs",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.logs_output_root, paths.build_dir])
)
);
gulp.task( gulp.task(
"clean-landing-page", "clean-landing-page",
gulp.parallel("clean-translations", async () => gulp.parallel("clean-translations", async () =>

View File

@@ -245,6 +245,24 @@ gulp.task(
) )
); );
const LOGS_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
"gen-pages-logs-dev",
genPagesDevTask(LOGS_PAGE_ENTRIES, paths.logs_dir, paths.logs_output_root)
);
gulp.task(
"gen-pages-logs-prod",
genPagesProdTask(
LOGS_PAGE_ENTRIES,
paths.logs_dir,
paths.logs_output_root,
paths.logs_output_latest,
paths.logs_output_es5
)
);
const LANDING_PAGE_PAGE_ENTRIES = { "index.html": ["entrypoint"] }; const LANDING_PAGE_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task( gulp.task(

View File

@@ -202,6 +202,16 @@ gulp.task("copy-static-gallery", async () => {
copyMdiIcons(paths.gallery_output_static); copyMdiIcons(paths.gallery_output_static);
}); });
gulp.task("copy-static-logs", async () => {
// Copy app static files
fs.copySync(polyPath("public/static"), paths.logs_output_static);
copyFonts(paths.logs_output_static);
copyTranslations(paths.logs_output_static);
copyLocaleData(paths.logs_output_static);
copyMdiIcons(paths.logs_output_static);
});
gulp.task("copy-static-landing-page", async () => { gulp.task("copy-static-landing-page", async () => {
// Copy landing-page static files // Copy landing-page static files
fs.copySync( fs.copySync(

View File

@@ -7,6 +7,7 @@ import "./download-translations.js";
import "./entry-html.js"; import "./entry-html.js";
import "./fetch-nightly-translations.js"; import "./fetch-nightly-translations.js";
import "./gallery.js"; import "./gallery.js";
import "./logs.js";
import "./gather-static.js"; import "./gather-static.js";
import "./gen-icons-json.js"; import "./gen-icons-json.js";
import "./hassio.js"; import "./hassio.js";

View File

@@ -0,0 +1,39 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-logs",
gulp.parallel(
"gen-icons-json",
"gen-pages-logs-dev",
"build-translations",
"build-locale-data"
),
"copy-static-logs",
"rspack-dev-server-logs"
)
);
gulp.task(
"build-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-logs",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-logs",
"rspack-prod-logs",
"gen-pages-logs-prod"
)
);

View File

@@ -15,6 +15,7 @@ import {
createGalleryConfig, createGalleryConfig,
createHassioConfig, createHassioConfig,
createLandingPageConfig, createLandingPageConfig,
createLogsConfig,
} from "../rspack.cjs"; } from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [ const bothBuilds = (createConfigFunc, params) => [
@@ -204,6 +205,25 @@ gulp.task("rspack-prod-gallery", () =>
) )
); );
gulp.task("rspack-dev-server-logs", () =>
runDevServer({
compiler: rspack(
createLogsConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.logs_output_root,
port: 5647,
})
);
gulp.task("rspack-prod-logs", () =>
prodBuild(
bothBuilds(createLogsConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
gulp.task("rspack-watch-landing-page", () => { gulp.task("rspack-watch-landing-page", () => {
// This command will run forever because we don't close compiler // This command will run forever because we don't close compiler
rspack( rspack(

View File

@@ -59,5 +59,11 @@ module.exports = {
hassio_output_es5: path.resolve(__dirname, "../hassio/build/frontend_es5"), hassio_output_es5: path.resolve(__dirname, "../hassio/build/frontend_es5"),
hassio_publicPath: "/api/hassio/app", hassio_publicPath: "/api/hassio/app",
logs_dir: path.resolve(__dirname, "../logs"),
logs_output_root: path.resolve(__dirname, "../logs/dist"),
logs_output_static: path.resolve(__dirname, "../logs/dist/static"),
logs_output_latest: path.resolve(__dirname, "../logs/dist/frontend_latest"),
logs_output_es5: path.resolve(__dirname, "../logs/dist/frontend_es5"),
translations_src: path.resolve(__dirname, "../src/translations"), translations_src: path.resolve(__dirname, "../src/translations"),
}; };

View File

@@ -302,6 +302,11 @@ const createHassioConfig = ({
const createGalleryConfig = ({ isProdBuild, latestBuild }) => const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild })); createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createLogsConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.logs({ isProdBuild, latestBuild, isStatsBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) => const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild })); createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
@@ -311,6 +316,7 @@ module.exports = {
createCastConfig, createCastConfig,
createHassioConfig, createHassioConfig,
createGalleryConfig, createGalleryConfig,
createLogsConfig,
createRspackConfig, createRspackConfig,
createLandingPageConfig, createLandingPageConfig,
}; };

9
logs/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
dist/
src/
node_modules/
*.md
.git/
.gitignore
docker-compose.yaml
backend/ha-logs-proxy
backend/README.md

47
logs/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
ARG BUILD_FROM
FROM $BUILD_FROM AS base
# Install dependencies
RUN apk add --no-cache \
bash \
jq \
curl \
go
# Install Home Assistant CLI
ARG BUILD_ARCH
ARG CLI_VERSION
RUN curl -Lso /usr/bin/ha \
"https://github.com/home-assistant/cli/releases/download/${CLI_VERSION}/ha_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/ha
# Build Go backend
WORKDIR /app
COPY backend/go.mod backend/go.sum* ./
RUN go mod download || true
COPY backend/*.go ./
RUN CGO_ENABLED=0 go build -o ha-logs-proxy .
# Final stage
FROM $BUILD_FROM
# Install runtime dependencies
RUN apk add --no-cache \
bash \
jq \
curl
# Copy HA CLI from base
COPY --from=base /usr/bin/ha /usr/bin/ha
# Copy Go backend
COPY --from=base /app/ha-logs-proxy /usr/bin/ha-logs-proxy
WORKDIR /root
# Expose port for backend (5642 = LOGB)
EXPOSE 5642
# Default command
CMD ["/usr/bin/ha-logs-proxy"]

113
logs/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Home Assistant CLI Docker Container
A simple multi-architecture Docker container with the Home Assistant CLI installed.
## Development Usage
The CLI container is integrated into the `script/develop-logs` workflow. Both flags are required:
```bash
# Start dev server with CLI container (requires remote_api add-on)
script/develop-logs -c http://192.168.1.2 -t your_token_here
```
When started with credentials, the container runs a Go backend that proxies HA CLI logs commands. The backend API is available at `http://localhost:5642`.
**Frontend Features:**
- Dropdown menu to select log provider (core, supervisor, host, audio, dns, multicast)
- Follow mode with WebSocket streaming (`ha core logs --follow`)
- Manual refresh to fetch latest logs
- Download logs as text file
- Line wrapping toggle
- Auto-scroll to bottom when following
- Error display with retry
**API Endpoints:**
```bash
# List all endpoints
curl http://localhost:5642/api/logs
# Get static logs
curl http://localhost:5642/api/logs/core
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
# WebSocket streaming (requires websocat or browser)
websocat ws://localhost:5642/api/logs/core/follow
```
You can also execute HA CLI commands directly:
```bash
docker exec -it ha-cli-dev ha info
docker exec -it ha-cli-dev ha supervisor info
```
Stop everything with Ctrl+C (both the dev server and backend will stop automatically).
### Getting API Token
1. Install the [remote_api add-on](https://github.com/home-assistant/addons/tree/master/remote_api) in Home Assistant
2. Check the add-on logs for the generated token
3. Use the token with the `-t` flag
## Build
### Local Build (Single Architecture)
```bash
docker build \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg BUILD_ARCH=amd64 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:local \
.
```
### Multi-Architecture Build
The `build.yaml` configuration is designed for use with Home Assistant's build system. For local multi-arch builds, use Docker Buildx:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:latest \
.
```
## Usage
### Run CLI Commands
```bash
docker run --rm ha-cli:local ha help
docker run --rm ha-cli:local ha supervisor info
```
### Interactive Shell
```bash
docker run -it --rm ha-cli:local
# Then run: ha <command>
```
### With Docker Compose
```bash
docker compose run --rm ha-cli ha help
```
## Update CLI Version
Edit the `CLI_VERSION` in `build.yaml` or pass it as a build argument:
```bash
docker build --build-arg CLI_VERSION=4.43.0 ...
```
Check for latest versions at: https://github.com/home-assistant/cli/releases

1
logs/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
ha-logs-proxy

108
logs/backend/README.md Normal file
View File

@@ -0,0 +1,108 @@
# HA Logs Proxy Backend
A Go backend that proxies Home Assistant CLI logs commands through a secure HTTP API.
## Features
- Only allows `logs` commands (security-restricted)
- GET endpoints for static logs
- WebSocket endpoints for streaming logs (follow mode)
- CORS enabled for frontend integration
- Simple JSON API
## API Endpoints
### GET /api/logs
List all available endpoints.
### GET /api/logs/core
Get Home Assistant core logs.
### GET /api/logs/supervisor
Get Supervisor logs.
### GET /api/logs/host
Get host system logs.
### GET /api/logs/audio
Get audio logs.
### GET /api/logs/dns
Get DNS logs.
### GET /api/logs/multicast
Get multicast logs.
### GET /health
Health check endpoint.
**Response format (all log endpoints):**
```json
{
"output": "log content here...",
"error": "error message if any"
}
```
### WS /api/logs/*/follow
WebSocket endpoints for streaming logs in real-time.
Available endpoints:
- `WS /api/logs/core/follow` - Stream core logs
- `WS /api/logs/supervisor/follow` - Stream supervisor logs
- `WS /api/logs/host/follow` - Stream host logs
- `WS /api/logs/audio/follow` - Stream audio logs
- `WS /api/logs/dns/follow` - Stream DNS logs
- `WS /api/logs/multicast/follow` - Stream multicast logs
Each WebSocket message contains a single log line as plain text. The connection streams output from `ha {component} logs --follow` command.
## Running Locally
```bash
cd backend
go run main.go
```
The server starts on port 5642 (LOGB) by default. Override with `PORT` environment variable:
```bash
PORT=3000 go run main.go
```
## Building
```bash
go build -o ha-logs-proxy
./ha-logs-proxy
```
## Testing
```bash
# List endpoints
curl http://localhost:5642/api/logs
# Get core logs
curl http://localhost:5642/api/logs/core
# Get supervisor logs
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
```
## Docker Integration
The backend is designed to run in the same container as the HA CLI, sharing access to the `ha` command.

5
logs/backend/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/home-assistant/frontend/logs/backend
go 1.24
require github.com/gorilla/websocket v1.5.3

2
logs/backend/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

372
logs/backend/main.go Normal file
View File

@@ -0,0 +1,372 @@
package main
import (
"bufio"
"encoding/json"
"log"
"net/http"
"os"
"os/exec"
"github.com/gorilla/websocket"
)
type LogsResponse struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins (CORS)
},
}
func executeHACommand(args []string) (string, error) {
cmd := exec.Command("ha", args...)
output, err := cmd.CombinedOutput()
return string(output), err
}
func streamHACommandToWS(conn *websocket.Conn, args []string) error {
cmd := exec.Command("ha", args...)
// Get stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Start command
if err := cmd.Start(); err != nil {
return err
}
// Read and send output line by line
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
cmd.Process.Kill()
return err
}
}
// Wait for command to finish
if err := cmd.Wait(); err != nil {
return err
}
return scanner.Err()
}
func handleCoreLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Execute ha core logs command
output, err := executeHACommand([]string{"core", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
func handleSupervisorLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"supervisor", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleHostLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"host", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleAudioLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"audio", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleDNSLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"dns", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleMulticastLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"multicast", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleCoreLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to core logs follow")
if err := streamHACommandToWS(conn, []string{"core", "logs", "--follow"}); err != nil {
log.Printf("Error streaming core logs: %v", err)
}
log.Println("Client disconnected from core logs follow")
}
func handleSupervisorLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to supervisor logs follow")
if err := streamHACommandToWS(conn, []string{"supervisor", "logs", "--follow"}); err != nil {
log.Printf("Error streaming supervisor logs: %v", err)
}
log.Println("Client disconnected from supervisor logs follow")
}
func handleHostLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to host logs follow")
if err := streamHACommandToWS(conn, []string{"host", "logs", "--follow"}); err != nil {
log.Printf("Error streaming host logs: %v", err)
}
log.Println("Client disconnected from host logs follow")
}
func handleAudioLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to audio logs follow")
if err := streamHACommandToWS(conn, []string{"audio", "logs", "--follow"}); err != nil {
log.Printf("Error streaming audio logs: %v", err)
}
log.Println("Client disconnected from audio logs follow")
}
func handleDNSLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to dns logs follow")
if err := streamHACommandToWS(conn, []string{"dns", "logs", "--follow"}); err != nil {
log.Printf("Error streaming dns logs: %v", err)
}
log.Println("Client disconnected from dns logs follow")
}
func handleMulticastLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to multicast logs follow")
if err := streamHACommandToWS(conn, []string{"multicast", "logs", "--follow"}); err != nil {
log.Printf("Error streaming multicast logs: %v", err)
}
log.Println("Client disconnected from multicast logs follow")
}
func listEndpoints(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
endpoints := map[string]string{
"core": "/api/logs/core",
"supervisor": "/api/logs/supervisor",
"host": "/api/logs/host",
"audio": "/api/logs/audio",
"dns": "/api/logs/dns",
"multicast": "/api/logs/multicast",
"health": "/health",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(endpoints)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "5642"
}
// Register handlers
http.HandleFunc("/api/logs", listEndpoints)
http.HandleFunc("/api/logs/core", handleCoreLogs)
http.HandleFunc("/api/logs/supervisor", handleSupervisorLogs)
http.HandleFunc("/api/logs/host", handleHostLogs)
http.HandleFunc("/api/logs/audio", handleAudioLogs)
http.HandleFunc("/api/logs/dns", handleDNSLogs)
http.HandleFunc("/api/logs/multicast", handleMulticastLogs)
// WebSocket follow endpoints
http.HandleFunc("/api/logs/core/follow", handleCoreLogsFollow)
http.HandleFunc("/api/logs/supervisor/follow", handleSupervisorLogsFollow)
http.HandleFunc("/api/logs/host/follow", handleHostLogsFollow)
http.HandleFunc("/api/logs/audio/follow", handleAudioLogsFollow)
http.HandleFunc("/api/logs/dns/follow", handleDNSLogsFollow)
http.HandleFunc("/api/logs/multicast/follow", handleMulticastLogsFollow)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
log.Printf("Starting HA Logs Proxy on port %s", port)
log.Printf("Available endpoints:")
log.Printf(" GET /api/logs - List all endpoints")
log.Printf(" GET /api/logs/core - Core logs")
log.Printf(" GET /api/logs/supervisor - Supervisor logs")
log.Printf(" GET /api/logs/host - Host logs")
log.Printf(" GET /api/logs/audio - Audio logs")
log.Printf(" GET /api/logs/dns - DNS logs")
log.Printf(" GET /api/logs/multicast - Multicast logs")
log.Printf(" WS /api/logs/*/follow - Stream logs (WebSocket)")
log.Printf(" GET /health - Health check")
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

19
logs/build.yaml Normal file
View File

@@ -0,0 +1,19 @@
---
image: ghcr.io/home-assistant/{arch}-hassio-cli
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
armhf: ghcr.io/home-assistant/armhf-base:3.22
armv7: ghcr.io/home-assistant/armv7-base:3.22
amd64: ghcr.io/home-assistant/amd64-base:3.22
i386: ghcr.io/home-assistant/i386-base:3.22
args:
CLI_VERSION: 4.42.0
cosign:
enabled: true
repository_name: home-assistant/cli
repository_owner: home-assistant
labels:
org.opencontainers.image.title: Home Assistant CLI
org.opencontainers.image.description: Home Assistant CLI for container environments
org.opencontainers.image.source: https://github.com/home-assistant/cli
org.opencontainers.image.licenses: Apache-2.0

16
logs/docker-compose.yaml Normal file
View File

@@ -0,0 +1,16 @@
---
services:
ha-cli:
build:
context: .
args:
BUILD_FROM: alpine:3.22
BUILD_ARCH: amd64
CLI_VERSION: 4.42.0
image: ha-cli:local
ports:
- "5642:5642"
environment:
- PORT=5642
stdin_open: true
tty: true

34
logs/src/auto-theme.ts Normal file
View File

@@ -0,0 +1,34 @@
import { darkSemanticColorStyles } from "../../src/resources/theme/color/semantic.globals";
import { darkColorStyles } from "../../src/resources/theme/color/color.globals";
const mql = matchMedia("(prefers-color-scheme: dark)");
function applyTheme(dark: boolean) {
const el = document.documentElement;
if (dark) {
el.setAttribute("dark", "");
} else {
el.removeAttribute("dark");
}
}
// Add dark theme styles wrapped in media query
// This runs after append-ha-style has loaded the base theme
const styleElement = document.createElement("style");
styleElement.id = "auto-theme-dark";
styleElement.textContent = `
@media (prefers-color-scheme: dark) {
${darkSemanticColorStyles.cssText}
${darkColorStyles.cssText}
}
`;
// Append to head to ensure it comes after base styles
document.head.appendChild(styleElement);
// Apply theme on initial load
applyTheme(mql.matches);
// Listen for theme changes
mql.addEventListener("change", (e) => {
applyTheme(e.matches);
});

8
logs/src/entrypoint.ts Normal file
View File

@@ -0,0 +1,8 @@
import "./logs-app";
// Load base styles first, then apply theme
import("../../src/resources/append-ha-style").then(() => {
import("./auto-theme");
});
document.body.appendChild(document.createElement("logs-app"));

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<meta name="color-scheme" content="dark light" />
<title>Home Assistant Logs</title>
<% for (const entry of latestEntryJS) { %>
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
margin: 0;
padding: 0;
}
</style>
</head>
<body></body>
</html>

24
logs/src/logs-app.ts Normal file
View File

@@ -0,0 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
import "./logs-viewer";
@customElement("logs-app")
class LogsApp extends LitElement {
render() {
return html`<logs-viewer></logs-viewer>`;
}
static styles = css`
:host {
display: block;
min-height: 100vh;
background-color: var(--primary-background-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-app": LogsApp;
}
}

538
logs/src/logs-viewer.ts Normal file
View File

@@ -0,0 +1,538 @@
import {
mdiArrowCollapseDown,
mdiChevronDown,
mdiCircle,
mdiDownload,
mdiRefresh,
mdiWrap,
mdiWrapDisabled,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import "../../src/components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../src/components/ha-ansi-to-html";
import "../../src/components/ha-button";
import "../../src/components/ha-button-menu";
import "../../src/components/ha-card";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-list-item";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
// Data types
interface LogProvider {
key: string;
name: string;
}
@customElement("logs-viewer")
export class LogsViewer extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _selectedLogProvider?: string;
@state() private _logProviders: LogProvider[] = [];
@state() private _loading = false;
@state() private _wrapLines = true;
@state() private _error?: string;
@state() private _newLogsIndicator?: boolean;
@query(".error-log") private _logElement?: HTMLElement;
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@state() private _scrolledToBottomController =
new IntersectionController<boolean>(this, {
callback(this: IntersectionController<boolean>, entries) {
return entries[0].isIntersecting;
},
});
@state() private _scrolledToTopController =
new IntersectionController<boolean>(this, {});
private _ws: WebSocket | null = null;
private _apiUrl = `http://${window.location.hostname}:5642`;
private async _fetchLogs(): Promise<void> {
if (!this._selectedLogProvider) {
return;
}
this._loading = true;
this._error = undefined;
// Stop any existing websocket
this._stopFollowing();
// Clear existing logs
this._ansiToHtmlElement?.clear();
try {
// First, fetch the latest logs
const response = await fetch(
`${this._apiUrl}/api/logs/${this._selectedLogProvider}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const logText = data.output || "";
// Parse and display initial logs
if (logText.trim()) {
this._ansiToHtmlElement?.parseTextToColoredPre(logText);
// Add divider line
this._ansiToHtmlElement?.parseLineToColoredPre(
"--- Live logs start here ---"
);
}
this._loading = false;
// Scroll to bottom after loading
this._scrollToBottom();
// Start streaming
this._startFollowing();
} catch (err) {
this._error = `Error loading logs: ${err}`;
this._loading = false;
// eslint-disable-next-line
console.error("Error fetching logs:", err);
}
}
private async _fetchLogProviders(): Promise<void> {
try {
const response = await fetch(`${this._apiUrl}/api/logs`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const providers = await response.json();
// Define the order (matching backend registration order)
const order = ["core", "supervisor", "host", "audio", "dns", "multicast"];
// Convert object to array of providers, filter out health endpoint, and sort
this._logProviders = Object.entries(providers)
.filter(([key]) => key !== "health")
.map(([key]) => ({
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
}))
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
// Set default provider once loaded
if (this._logProviders.length > 0 && !this._selectedLogProvider) {
this._selectedLogProvider = this._logProviders[0].key;
await this._fetchLogs();
}
} catch (err) {
this._error = `Failed to load log providers: ${err}`;
// eslint-disable-next-line
console.error("Error fetching log providers:", err);
}
}
connectedCallback() {
super.connectedCallback();
this._fetchLogProviders();
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopFollowing();
}
protected firstUpdated() {
this._scrolledToBottomController.observe(this._scrollBottomMarkerElement!);
this._scrolledToTopController.observe(this._scrollTopMarkerElement!);
}
protected updated() {
if (this._newLogsIndicator && this._scrolledToBottomController.value) {
this._newLogsIndicator = false;
}
}
private _selectProvider(ev: Event) {
const target = ev.currentTarget as any;
this._selectedLogProvider = target.provider;
this._fetchLogs();
}
private _refresh() {
this._fetchLogs();
}
private _toggleLineWrap() {
this._wrapLines = !this._wrapLines;
}
private _scrollToBottom(): void {
if (this._logElement) {
this._newLogsIndicator = false;
this._logElement.scrollTo(0, this._logElement.scrollHeight);
}
}
private _startFollowing() {
if (!this._selectedLogProvider) {
return;
}
this._stopFollowing();
this._error = undefined;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.hostname}:5642/api/logs/${this._selectedLogProvider}/follow`;
try {
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
// eslint-disable-next-line
console.log("WebSocket connected");
};
this._ws.onmessage = (event) => {
const scrolledToBottom = this._scrolledToBottomController.value;
// Add the new line to the display
this._ansiToHtmlElement?.parseLineToColoredPre(event.data);
// Auto-scroll if user is at bottom
if (scrolledToBottom && this._logElement) {
this._scrollToBottom();
} else {
this._newLogsIndicator = true;
}
};
this._ws.onerror = (error) => {
// eslint-disable-next-line
console.error("WebSocket error:", error);
this._error = "WebSocket connection error";
};
this._ws.onclose = () => {
// eslint-disable-next-line
console.log("WebSocket disconnected");
};
} catch (err) {
this._error = `Failed to start following logs: ${err}`;
// eslint-disable-next-line
console.error("Error starting WebSocket:", err);
}
}
private _stopFollowing() {
if (this._ws) {
this._ws.close();
this._ws = null;
}
}
private _downloadLogs() {
if (!this._selectedLogProvider || !this._ansiToHtmlElement) {
return;
}
// Get the text content from the logs
const logText =
this._ansiToHtmlElement.shadowRoot?.querySelector("pre")?.textContent ||
"";
if (!logText.trim()) {
return;
}
// Create blob from log text
const blob = new Blob([logText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
// Create download link and trigger it
const a = document.createElement("a");
a.href = url;
a.download = `${this._selectedLogProvider}-logs-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
// Cleanup
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
render() {
const currentProvider = this._logProviders.find(
(p) => p.key === this._selectedLogProvider
);
return html`
<div class="container">
<div class="toolbar">
<ha-button-menu>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
${currentProvider?.name || "Select Provider"}
</ha-button>
${this._logProviders.map(
(provider) => html`
<ha-list-item
?selected=${provider.key === this._selectedLogProvider}
.provider=${provider.key}
@click=${this._selectProvider}
>
${provider.name}
</ha-list-item>
`
)}
</ha-button-menu>
</div>
<div class="content">
<div class="error-log-intro">
<ha-card outlined>
<div class="header">
<h1 class="card-header">${currentProvider?.name || "Logs"}</h1>
<div class="action-buttons">
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${"Download logs"}
.disabled=${!this._ansiToHtmlElement}
></ha-icon-button>
<ha-icon-button
.path=${this._wrapLines ? mdiWrapDisabled : mdiWrap}
@click=${this._toggleLineWrap}
.label=${this._wrapLines ? "Full width" : "Wrap lines"}
></ha-icon-button>
<ha-icon-button
.path=${mdiRefresh}
@click=${this._refresh}
.label=${"Refresh"}
.disabled=${!this._selectedLogProvider}
></ha-icon-button>
</div>
</div>
<div class="card-content error-log">
<div id="scroll-top-marker"></div>
${this._loading
? html`<div>Loading logs...</div>`
: this._error
? html`<div class="error">${this._error}</div>`
: nothing}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
></ha-ansi-to-html>
<div id="scroll-bottom-marker"></div>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible:
(this._newLogsIndicator &&
!this._scrolledToBottomController.value) ||
false,
})}"
size="small"
appearance="filled"
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="start"
></ha-svg-icon>
Scroll down
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="end"
></ha-svg-icon>
</ha-button>
${this._ws && !this._error
? html`<div class="live-indicator">
<ha-svg-icon .path=${mdiCircle}></ha-svg-icon>
Live
</div>`
: nothing}
</ha-card>
</div>
</div>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
direction: var(--direction);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.toolbar {
padding: var(--ha-space-2) var(--ha-space-4);
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.content {
direction: ltr;
}
.error-log-intro {
text-align: center;
margin: 0 var(--ha-space-4);
}
ha-card {
padding-top: var(--ha-space-2);
position: relative;
}
.header {
display: flex;
justify-content: space-between;
padding: 0 var(--ha-space-4);
}
.action-buttons {
display: flex;
align-items: center;
height: 100%;
}
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
display: block;
margin-block-start: 0px;
font-weight: var(--ha-font-weight-normal);
white-space: nowrap;
max-width: calc(100% - 150px);
overflow: hidden;
text-overflow: ellipsis;
}
.error-log {
position: relative;
font-family: var(--ha-font-family-code);
clear: both;
text-align: start;
padding-top: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 244px));
max-height: var(--error-log-card-height, calc(100vh - 244px));
border-top: 1px solid var(--divider-color);
direction: ltr;
}
.error-log > div {
padding: 0 var(--ha-space-4);
overflow: auto;
}
.error {
color: var(--error-color);
padding: var(--ha-space-4);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: var(--ha-space-1);
left: var(--ha-space-1);
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@keyframes breathe {
from {
opacity: 0.8;
}
to {
opacity: 0;
}
}
.live-indicator {
position: absolute;
bottom: 0;
inset-inline-end: var(--ha-space-4);
border-top-right-radius: var(--ha-space-2);
border-top-left-radius: var(--ha-space-2);
background-color: var(--primary-color);
color: var(--text-primary-color);
padding: var(--ha-space-1) var(--ha-space-2);
opacity: 0.8;
}
.live-indicator ha-svg-icon {
animation: breathe 1s cubic-bezier(0.5, 0, 1, 1) infinite alternate;
height: 14px;
width: 14px;
}
@media all and (max-width: 870px) {
.error-log {
min-height: var(--error-log-card-height, calc(100vh - 190px));
max-height: var(--error-log-card-height, calc(100vh - 190px));
}
ha-button-menu {
max-width: 50%;
}
ha-button {
max-width: 100%;
}
ha-button::part(label) {
overflow: hidden;
white-space: nowrap;
}
}
ha-list-item[selected] {
color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-viewer": LogsViewer;
}
}

96
script/develop-logs Executable file
View File

@@ -0,0 +1,96 @@
#!/bin/sh
# Run the logs frontend development server
# Stop on errors
set -e
cd "$(dirname "$0")/.."
# Parse command line arguments
SUPERVISOR_ENDPOINT=""
SUPERVISOR_API_TOKEN=""
while getopts "c:t:h" opt; do
case $opt in
c)
SUPERVISOR_ENDPOINT="$OPTARG"
;;
t)
SUPERVISOR_API_TOKEN="$OPTARG"
;;
h)
echo "Usage: $0 -c SUPERVISOR_ENDPOINT -t SUPERVISOR_API_TOKEN"
echo ""
echo "Options:"
echo " -c SUPERVISOR_ENDPOINT (e.g., http://192.168.1.2) [required]"
echo " -t SUPERVISOR_API_TOKEN (from remote_api add-on) [required]"
echo " -h Show this help message"
echo ""
echo "Example:"
echo " $0 -c http://192.168.1.2 -t your_token_here"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
# Validate that both -c and -t are provided
if [ -z "$SUPERVISOR_ENDPOINT" ] || [ -z "$SUPERVISOR_API_TOKEN" ]; then
echo "Error: Both -c and -t are required" >&2
echo "Use -h for help"
exit 1
fi
# Cleanup function
cleanup() {
echo ""
echo "Shutting down..."
echo "Stopping HA CLI container..."
docker stop ha-cli-dev 2>/dev/null || true
exit 0
}
# Set up trap to cleanup on exit
trap cleanup INT TERM EXIT
# Run HA CLI container
echo "Starting HA CLI container..."
# Build the container if needed
if ! docker images | grep -q "ha-cli:local"; then
echo "Building HA CLI container..."
(cd logs && docker compose build)
fi
# Clean up any existing container
docker stop ha-cli-dev 2>/dev/null || true
# Run the container in background (not detached, so it shares stdout)
docker run \
--name ha-cli-dev \
--rm \
-p 5642:5642 \
-e SUPERVISOR_ENDPOINT="$SUPERVISOR_ENDPOINT" \
-e SUPERVISOR_API_TOKEN="$SUPERVISOR_API_TOKEN" \
-e PORT=5642 \
ha-cli:local &
# Store the docker process ID
DOCKER_PID=$!
# Wait a moment for container to start
sleep 2
echo ""
echo "HA Logs Backend API: http://localhost:5642"
echo " GET /api/logs - List endpoints"
echo " GET /api/logs/core - Core logs"
echo " GET /api/logs/supervisor - Supervisor logs"
echo " GET /health - Health check"
echo ""
# Run gulp (this will block until Ctrl+C)
./node_modules/.bin/gulp develop-logs