mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-11 03:51:07 +00:00
Compare commits
11 Commits
copilot/fi
...
logs-front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1afcfcd32 | ||
|
|
29e6ada90e | ||
|
|
ff1745fccc | ||
|
|
59aaf86104 | ||
|
|
52bc692c79 | ||
|
|
29de405912 | ||
|
|
a723171ef2 | ||
|
|
cfe193cf60 | ||
|
|
a8e6557b09 | ||
|
|
a517aabdd8 | ||
|
|
10c8828aa5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/logs/dist/
|
||||
/translations/
|
||||
|
||||
# yarn
|
||||
|
||||
@@ -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 }) {
|
||||
return {
|
||||
name: "landing-page" + nameSuffix(latestBuild),
|
||||
|
||||
@@ -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(
|
||||
"clean-landing-page",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
|
||||
@@ -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"] };
|
||||
|
||||
gulp.task(
|
||||
|
||||
@@ -202,6 +202,16 @@ gulp.task("copy-static-gallery", async () => {
|
||||
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 () => {
|
||||
// Copy landing-page static files
|
||||
fs.copySync(
|
||||
|
||||
@@ -7,6 +7,7 @@ import "./download-translations.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./logs.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./hassio.js";
|
||||
|
||||
39
build-scripts/gulp/logs.js
Normal file
39
build-scripts/gulp/logs.js
Normal 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"
|
||||
)
|
||||
);
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createGalleryConfig,
|
||||
createHassioConfig,
|
||||
createLandingPageConfig,
|
||||
createLogsConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
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", () => {
|
||||
// This command will run forever because we don't close compiler
|
||||
rspack(
|
||||
|
||||
@@ -59,5 +59,11 @@ module.exports = {
|
||||
hassio_output_es5: path.resolve(__dirname, "../hassio/build/frontend_es5"),
|
||||
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"),
|
||||
};
|
||||
|
||||
@@ -302,6 +302,11 @@ const createHassioConfig = ({
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
|
||||
|
||||
const createLogsConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.logs({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
@@ -311,6 +316,7 @@ module.exports = {
|
||||
createCastConfig,
|
||||
createHassioConfig,
|
||||
createGalleryConfig,
|
||||
createLogsConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
};
|
||||
|
||||
9
logs/.dockerignore
Normal file
9
logs/.dockerignore
Normal 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
47
logs/Dockerfile
Normal 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
113
logs/README.md
Normal 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
1
logs/backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ha-logs-proxy
|
||||
108
logs/backend/README.md
Normal file
108
logs/backend/README.md
Normal 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
5
logs/backend/go.mod
Normal 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
2
logs/backend/go.sum
Normal 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
372
logs/backend/main.go
Normal 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
19
logs/build.yaml
Normal 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
16
logs/docker-compose.yaml
Normal 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
34
logs/src/auto-theme.ts
Normal 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
8
logs/src/entrypoint.ts
Normal 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"));
|
||||
37
logs/src/html/index.html.template
Normal file
37
logs/src/html/index.html.template
Normal 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
24
logs/src/logs-app.ts
Normal 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
538
logs/src/logs-viewer.ts
Normal 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
96
script/develop-logs
Executable 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
|
||||
Reference in New Issue
Block a user