Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
01dd53241a
|
|||
|
81f8c71b6f
|
|||
|
7109b6fa1c
|
|||
|
e5ac4a1fec
|
|||
|
065d5d74bb
|
|||
|
08f14f3af8
|
|||
|
5720fabdbc
|
|||
|
54299583d6
|
|||
|
c9bd9a5a2e
|
|||
|
66b0681a76
|
|||
|
060cd24de2
|
|||
|
5c40629987
|
|||
|
e12f42fe16
|
|||
|
8323d27e7d
|
|||
|
047bb237e0
|
|||
|
5398cccd44
|
|||
|
4bf64c3841
|
|||
|
d760420f7c
|
|||
|
0e5f2dded5
|
|||
|
07f3cc8e09
|
|||
|
ef8e5c0b93
|
|||
|
94b27488bd
|
|||
|
7d0b4dab8b
|
|||
|
d0d716b07a
|
|||
|
9889ba4417
|
|||
|
c71d5b17f0
|
|||
|
5ca534b0b1
|
|||
|
70c12ca24b
|
|||
|
dbd519c121
|
|||
|
b32145fb58
|
|||
|
47d3b0e68f
|
|||
|
8fd13fefbf
|
|||
|
974c4b61ea
|
|||
|
d50a5159d4
|
@@ -1,5 +1,5 @@
|
|||||||
# ─── uptop configuration ───────────────────────────────────
|
# ─── uptop configuration ───────────────────────────────────
|
||||||
# Copy to .env and edit. Only uncomment what you need.
|
# Export in your environment or pass via docker run --env-file.\n# Only uncomment what you need.
|
||||||
|
|
||||||
# ─── Core ──────────────────────────────────────────────────
|
# ─── Core ──────────────────────────────────────────────────
|
||||||
UPTOP_PORT=23234 # SSH server port
|
UPTOP_PORT=23234 # SSH server port
|
||||||
@@ -40,3 +40,5 @@ UPTOP_DB_DSN=/data/uptop.db # File path (SQLite) or connection string (Postgre
|
|||||||
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
|
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
|
||||||
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
|
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
|
||||||
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
|
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
|
||||||
|
# UPTOP_TRUSTED_PROXIES= # Comma-separated CIDRs/IPs for X-Forwarded-For trust
|
||||||
|
# UPTOP_MAINT_RETENTION=168h # How long ended maintenance windows are kept
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:
|
|||||||
label: What happened?
|
label: What happened?
|
||||||
description: Include what you expected to happen instead.
|
description: Include what you expected to happen instead.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
When I run `uptop serve`, the TUI crashes after 10 seconds.
|
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||||
I expected it to keep running and display monitor status.
|
I expected it to keep running and display monitor status.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -25,7 +25,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Run `uptop serve`
|
1. Run `uptop`
|
||||||
2. Wait ~10 seconds
|
2. Wait ~10 seconds
|
||||||
3. TUI crashes with panic
|
3. TUI crashes with panic
|
||||||
validations:
|
validations:
|
||||||
@@ -37,7 +37,7 @@ body:
|
|||||||
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||||
render: shell
|
render: shell
|
||||||
placeholder: |
|
placeholder: |
|
||||||
uptop version 2026.06.1
|
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||||
OS: Debian 13
|
OS: Debian 13
|
||||||
Terminal: Ghostty
|
Terminal: Ghostty
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ jobs:
|
|||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean --release-notes=/tmp/release-notes.md
|
args: release --clean --release-notes=/tmp/release-notes.md
|
||||||
env:
|
env:
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
||||||
GORELEASER_FORCE_TOKEN: gitea
|
GORELEASER_FORCE_TOKEN: gitea
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
GITEA_API_URL: http://gitea:3000/api/v1
|
GITEA_API_URL: http://gitea:3000/api/v1
|
||||||
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
|
|
||||||
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
|
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
|
||||||
# which runs on GitHub Actions when the push mirror delivers the tag and
|
# which runs on GitHub Actions when the push mirror delivers the tag and
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ jobs:
|
|||||||
|
|
||||||
TAGS="lerkolabs/uptop:${TAG}"
|
TAGS="lerkolabs/uptop:${TAG}"
|
||||||
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
|
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
|
||||||
|
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:${TAG}"
|
||||||
|
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:sha-${SHORT_SHA}"
|
||||||
# :latest only for real releases — rc rehearsal tags must not move it
|
# :latest only for real releases — rc rehearsal tags must not move it
|
||||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
case "$TAG" in
|
case "$TAG" in
|
||||||
*-*) ;;
|
*-*) ;;
|
||||||
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;;
|
*)
|
||||||
|
TAGS="${TAGS},lerkolabs/uptop:latest"
|
||||||
|
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:latest"
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
@@ -56,6 +61,13 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ secrets.GHCR_USERNAME }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
# Scan must gate the push: build amd64 locally, scan it, and only then run
|
# Scan must gate the push: build amd64 locally, scan it, and only then run
|
||||||
# the multi-arch push (amd64 layers come from the builder cache, so the
|
# the multi-arch push (amd64 layers come from the builder cache, so the
|
||||||
# second build only adds the arm64 work).
|
# second build only adds the arm64 work).
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: search
|
||||||
|
attributes:
|
||||||
|
label: Before filing
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and didn't find a match
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: Include what you expected to happen instead.
|
||||||
|
placeholder: |
|
||||||
|
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||||
|
I expected it to keep running and display monitor status.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. Run `uptop`
|
||||||
|
2. Wait ~10 seconds
|
||||||
|
3. TUI crashes with panic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment & logs
|
||||||
|
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||||
|
render: shell
|
||||||
|
placeholder: |
|
||||||
|
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||||
|
OS: Debian 13
|
||||||
|
Terminal: Ghostty
|
||||||
|
|
||||||
|
[paste any error output here]
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,8 +1 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
|
||||||
- name: Report a Bug
|
|
||||||
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=bug_report.yaml
|
|
||||||
about: Report bugs on our Gitea instance
|
|
||||||
- name: Request a Feature
|
|
||||||
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=feature_request.yaml
|
|
||||||
about: Suggest features on our Gitea instance
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or enhancement
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What's frustrating or missing?
|
||||||
|
placeholder: I find myself always needing to ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: How would you like this to work?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: Forward Issues to Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
forward:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Build issue body
|
||||||
|
env:
|
||||||
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
|
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||||
|
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||||
|
run: |
|
||||||
|
jq -n \
|
||||||
|
--arg url "$ISSUE_URL" \
|
||||||
|
--arg author "$ISSUE_AUTHOR" \
|
||||||
|
--arg body "$ISSUE_BODY" \
|
||||||
|
'">" + " Forwarded from GitHub: " + $url + "\n> Reported by: [@" + $author + "](https://github.com/" + $author + ")\n\n---\n\n" + $body' \
|
||||||
|
-r > /tmp/gitea-issue-body.md || exit 1
|
||||||
|
|
||||||
|
- name: Resolve label IDs
|
||||||
|
id: labels
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GH_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
|
||||||
|
run: |
|
||||||
|
GITEA_LABELS=$(curl -f \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/labels?limit=50") || exit 1
|
||||||
|
|
||||||
|
GH_LABELS_COMPACT=$(echo "$GH_LABELS" | jq -c '.')
|
||||||
|
LABEL_IDS=$(echo "$GITEA_LABELS" | jq -c --argjson gh "$GH_LABELS_COMPACT" '
|
||||||
|
[.[] | select(
|
||||||
|
.name == "github" or
|
||||||
|
(.name as $n | $gh | index($n) != null)
|
||||||
|
) | .id]
|
||||||
|
')
|
||||||
|
|
||||||
|
echo "ids=${LABEL_IDS}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create Gitea issue
|
||||||
|
id: create
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
|
LABEL_IDS: ${{ steps.labels.outputs.ids }}
|
||||||
|
run: |
|
||||||
|
RESPONSE=$(curl -f -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/issues" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg title "$ISSUE_TITLE" \
|
||||||
|
--rawfile body /tmp/gitea-issue-body.md \
|
||||||
|
--argjson labels "$LABEL_IDS" \
|
||||||
|
'{title: $title, body: $body, labels: $labels}'
|
||||||
|
)") || exit 1
|
||||||
|
|
||||||
|
GITEA_URL=$(echo "$RESPONSE" | jq -re '.html_url') || exit 1
|
||||||
|
GITEA_NUM=$(echo "$RESPONSE" | jq -re '.number') || exit 1
|
||||||
|
|
||||||
|
echo "url=${GITEA_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "number=${GITEA_NUM}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Comment on GitHub issue
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_URL: ${{ steps.create.outputs.url }}
|
||||||
|
run: |
|
||||||
|
gh issue comment "${{ github.event.issue.number }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--body "Thanks for reporting! This issue has been forwarded to our [primary tracker](${GITEA_URL}). Discussion and updates will happen there."
|
||||||
@@ -25,4 +25,6 @@ authorized_keys
|
|||||||
tmp
|
tmp
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
|
data/
|
||||||
.env
|
.env
|
||||||
|
vhs
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ nfpms:
|
|||||||
dst: /usr/share/doc/uptop/LICENSE
|
dst: /usr/share/doc/uptop/LICENSE
|
||||||
type: doc
|
type: doc
|
||||||
|
|
||||||
|
brews:
|
||||||
|
- name: uptop
|
||||||
|
repository:
|
||||||
|
owner: lerkolabs
|
||||||
|
name: homebrew-tap
|
||||||
|
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||||
|
url_template: "https://github.com/lerkolabs/uptop/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
|
||||||
|
commit_author:
|
||||||
|
name: lerkolabs-bot
|
||||||
|
email: bot@lerkolabs.com
|
||||||
|
homepage: https://github.com/lerkolabs/uptop
|
||||||
|
description: Self-hosted uptime monitoring with a TUI over SSH
|
||||||
|
license: MIT
|
||||||
|
install: |
|
||||||
|
bin.install "uptop"
|
||||||
|
test: |
|
||||||
|
system bin/"uptop", "version"
|
||||||
|
|
||||||
# Changelog generation must stay enabled: the --release-notes flag is consumed
|
# Changelog generation must stay enabled: the --release-notes flag is consumed
|
||||||
# by the changelog pipe, so disabling it silently drops the git-cliff notes
|
# by the changelog pipe, so disabling it silently drops the git-cliff notes
|
||||||
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
|
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
|
||||||
|
|||||||
@@ -23,14 +23,13 @@ RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
|
|||||||
COPY --from=builder /app/uptop .
|
COPY --from=builder /app/uptop .
|
||||||
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|
||||||
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
|
||||||
ENV UPTOP_DB_TYPE=sqlite
|
ENV UPTOP_DB_TYPE=sqlite
|
||||||
ENV UPTOP_DB_DSN=/data/uptop.db
|
ENV UPTOP_DB_DSN=/data/uptop.db
|
||||||
ENV UPTOP_KEYS=/data/authorized_keys
|
ENV UPTOP_KEYS=/data/authorized_keys
|
||||||
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
||||||
ENV UPTOP_PORT=23234
|
ENV UPTOP_PORT=23234
|
||||||
|
|
||||||
EXPOSE 23234
|
EXPOSE 8080 23234
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/api/health || exit 1
|
CMD wget -qO- http://localhost:8080/api/health || exit 1
|
||||||
USER uptop
|
USER uptop
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<h1>uptop</h1>
|
<img src="assets/logo.svg" alt="uptop" width="320">
|
||||||
<p>Self-hosted uptime monitoring with a TUI over SSH.</p>
|
<p>Self-hosted uptime monitoring with a TUI over SSH.</p>
|
||||||
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
|
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||||
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
<a href="https://github.com/lerkolabs/uptop/releases/latest"><img src="https://img.shields.io/github/v/release/lerkolabs/uptop" alt="Latest Release"></a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/lerkolabs/uptop"><img src="https://goreportcard.com/badge/github.com/lerkolabs/uptop" alt="Go Report Card"></a>
|
||||||
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
|
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
|
||||||
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
|
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img src="assets/monitors.png" alt="uptop monitors view" width="800">
|
<img src="assets/demo.gif" alt="uptop demo" width="800">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
@@ -27,7 +29,7 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
|||||||
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
|
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
|
||||||
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
|
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
|
||||||
- **HA clustering** — leader/follower with automatic failover
|
- **HA clustering** — leader/follower with automatic failover
|
||||||
- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana
|
- **Prometheus metrics** — `/metrics` endpoint (`UPTOP_METRICS_PUBLIC=true` to expose without auth)
|
||||||
- **Public status page** — HTML + JSON, toggle with an env var
|
- **Public status page** — HTML + JSON, toggle with an env var
|
||||||
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
||||||
- **Uptime Kuma import** — migrate from Kuma with one command
|
- **Uptime Kuma import** — migrate from Kuma with one command
|
||||||
@@ -38,18 +40,27 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><img src="assets/monitors.png" alt="monitors dashboard" width="400"></td>
|
||||||
<td><img src="assets/detail.png" alt="detail panel" width="400"></td>
|
<td><img src="assets/detail.png" alt="detail panel" width="400"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td><img src="assets/alerts.png" alt="alerts view" width="400"></td>
|
<td><img src="assets/alerts.png" alt="alerts view" width="400"></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="assets/logs.png" alt="logs view" width="400"></td>
|
<td><img src="assets/logs.png" alt="logs view" width="400"></td>
|
||||||
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" align="center"><img src="assets/theme.png" alt="theme selection" width="600"></td>
|
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
|
||||||
|
<td><img src="assets/theme.png" alt="theme selection" width="400"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
|
||||||
|
Five built-in themes: Flexoki Dark, Tokyo Night, Catppuccin Mocha, Nord, Gruvbox. Press `T` to cycle.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/themes.png" alt="all five themes" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -146,6 +157,8 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
|||||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
||||||
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
|
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
|
||||||
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
||||||
|
| `UPTOP_METRICS_PUBLIC` | `false` | Expose `/metrics` without auth |
|
||||||
|
| `UPTOP_MAINT_RETENTION` | `168h` | How long ended maintenance windows are kept |
|
||||||
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
|
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
|
||||||
|
|
||||||
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
|
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
|
||||||
@@ -179,7 +192,7 @@ uptop prunes its own history in the background — no external cleanup jobs need
|
|||||||
| Check history | newest 1,000 checks per monitor |
|
| Check history | newest 1,000 checks per monitor |
|
||||||
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
|
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
|
||||||
| Logs | newest 200 entries |
|
| Logs | newest 200 entries |
|
||||||
| Maintenance windows | 7 days after they end (configurable) |
|
| Maintenance windows | 7 days after they end (`UPTOP_MAINT_RETENTION`) |
|
||||||
|
|
||||||
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
|
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 976 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" fill="none">
|
||||||
|
<!-- Terminal-inspired text mark for uptop -->
|
||||||
|
<rect width="320" height="80" rx="8" fill="#1C1B1A"/>
|
||||||
|
<!-- Prompt caret -->
|
||||||
|
<text x="16" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#3AA99F">▲</text>
|
||||||
|
<!-- "uptop" in monospace -->
|
||||||
|
<text x="52" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#CECDC3">uptop</text>
|
||||||
|
<!-- Status dot -->
|
||||||
|
<circle cx="296" cy="40" r="6" fill="#879A39"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 275 KiB |
@@ -52,7 +52,14 @@ func init() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mv := info.Main.Version; mv != "" && mv != "(devel)" {
|
if mv := info.Main.Version; mv != "" && mv != "(devel)" {
|
||||||
version = strings.TrimPrefix(mv, "v")
|
mv = strings.TrimPrefix(mv, "v")
|
||||||
|
// Pseudo-versions (e.g. "0.1.1-0.20260620165311-5ca534b0b100+dirty")
|
||||||
|
// are noisy in the TUI footer. Extract just the base semver.
|
||||||
|
if i := strings.Index(mv, "-0."); i > 0 {
|
||||||
|
mv = mv[:i]
|
||||||
|
}
|
||||||
|
mv = strings.TrimSuffix(mv, "+dirty")
|
||||||
|
version = mv
|
||||||
}
|
}
|
||||||
for _, s := range info.Settings {
|
for _, s := range info.Settings {
|
||||||
switch s.Key {
|
switch s.Key {
|
||||||
@@ -77,17 +84,37 @@ func main() {
|
|||||||
case "export":
|
case "export":
|
||||||
runExport(os.Args[2:])
|
runExport(os.Args[2:])
|
||||||
return
|
return
|
||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v", "-version":
|
||||||
printVersion()
|
printVersion()
|
||||||
return
|
return
|
||||||
case "migrate-secrets":
|
case "migrate-secrets":
|
||||||
runMigrateSecrets(os.Args[2:])
|
runMigrateSecrets(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
printUsage()
|
||||||
|
return
|
||||||
|
case "serve":
|
||||||
|
runServe(os.Args[2:])
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runServe(os.Args[1:])
|
runServe(os.Args[1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: uptop <command> [flags]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
serve Start the server (default if no command given)
|
||||||
|
apply Apply monitors from a YAML file
|
||||||
|
export Export monitors to YAML
|
||||||
|
migrate-secrets Re-encrypt alert credentials with current key
|
||||||
|
version Print version and exit
|
||||||
|
|
||||||
|
Run 'uptop serve --help' for server flags.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
out := "uptop " + version
|
out := "uptop " + version
|
||||||
var meta []string
|
var meta []string
|
||||||
@@ -186,7 +213,7 @@ func runApply(args []string) {
|
|||||||
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
||||||
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
||||||
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
|
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||||
|
|
||||||
@@ -219,7 +246,7 @@ func runApply(args []string) {
|
|||||||
func runExport(args []string) {
|
func runExport(args []string) {
|
||||||
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
||||||
outPath := fs.String("o", "-", "Output file path (- for stdout)")
|
outPath := fs.String("o", "-", "Output file path (- for stdout)")
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||||
|
|
||||||
@@ -239,7 +266,7 @@ func runExport(args []string) {
|
|||||||
|
|
||||||
func runMigrateSecrets(args []string) {
|
func runMigrateSecrets(args []string) {
|
||||||
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
@@ -331,7 +358,7 @@ func runServe(args []string) {
|
|||||||
|
|
||||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
port := fs.Int("port", cfg.Port, "SSH Port")
|
port := fs.Int("port", cfg.Port, "SSH Port")
|
||||||
flagDBType := fs.String("db-type", cfg.DBType, "Database type")
|
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)")
|
||||||
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
||||||
demo := fs.Bool("demo", false, "Seed demo data")
|
demo := fs.Bool("demo", false, "Seed demo data")
|
||||||
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
leader:
|
leader:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-leader
|
container_name: uptop-leader
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
ports:
|
ports:
|
||||||
- "23234:23234" # SSH
|
- "23234:23234" # SSH
|
||||||
- "8080:8080" # HTTP
|
- "8080:8080" # HTTP
|
||||||
@@ -40,6 +42,8 @@ services:
|
|||||||
follower:
|
follower:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-follower
|
container_name: uptop-follower
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
ports:
|
ports:
|
||||||
- "23233:23234" # SSH (Mapped to different host port)
|
- "23233:23234" # SSH (Mapped to different host port)
|
||||||
- "8081:8080" # HTTP (Mapped to different host port)
|
- "8081:8080" # HTTP (Mapped to different host port)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- UPTOP_DB_TYPE=postgres
|
- UPTOP_DB_TYPE=postgres
|
||||||
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
||||||
|
|
||||||
# --- Web Server Configuration (Phase 4) ---
|
# --- Web Server Configuration ---
|
||||||
- UPTOP_HTTP_PORT=8080
|
- UPTOP_HTTP_PORT=8080
|
||||||
- UPTOP_STATUS_ENABLED=true
|
- UPTOP_STATUS_ENABLED=true
|
||||||
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
leader:
|
leader:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=leader
|
- UPTOP_CLUSTER_MODE=leader
|
||||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||||
@@ -12,6 +14,8 @@ services:
|
|||||||
|
|
||||||
probe-us-east:
|
probe-us-east:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=us-east-1
|
- UPTOP_NODE_ID=us-east-1
|
||||||
@@ -24,6 +28,8 @@ services:
|
|||||||
|
|
||||||
probe-eu-west:
|
probe-eu-west:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=eu-west-1
|
- UPTOP_NODE_ID=eu-west-1
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- ALL
|
- ALL
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -47,13 +47,11 @@ Probes are lightweight, stateless nodes that run checks from different locations
|
|||||||
| Node | Variable | Value |
|
| Node | Variable | Value |
|
||||||
|------|----------|-------|
|
|------|----------|-------|
|
||||||
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
|
||||||
| Leader | `UPTOP_AGG_STRATEGY` | `any-down`, `majority-down`, or `all-down` |
|
|
||||||
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
|
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
|
||||||
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
|
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
|
||||||
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
|
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
|
||||||
| Probe | `UPTOP_NODE_REGION` | Region tag matching monitor assignments |
|
|
||||||
|
|
||||||
Optional: `UPTOP_NODE_NAME` for a human-readable label in the TUI.
|
Optional: `UPTOP_AGG_STRATEGY` (default `any-down`), `UPTOP_NODE_REGION` (omit to match all monitors), `UPTOP_NODE_NAME` (human-readable label in the TUI).
|
||||||
|
|
||||||
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
|
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
|
||||||
|
|
||||||
@@ -80,6 +78,6 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated.
|
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints reject all requests (fail closed); only `/api/health` stays open.
|
||||||
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
|
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
|
||||||
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ type ProbeNode struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
|
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
|
||||||
// "last sent" / health indicators survive restarts instead of resetting to "never".
|
// "last sent" / health indicators survive restarts instead of resetting to "never".
|
||||||
type AlertHealthRecord struct {
|
type AlertHealthRecord struct {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type Engine struct {
|
|||||||
liveState map[int]models.Site
|
liveState map[int]models.Site
|
||||||
|
|
||||||
logMu sync.RWMutex
|
logMu sync.RWMutex
|
||||||
logStore []string
|
logStore []models.LogEntry
|
||||||
|
|
||||||
activeMu sync.RWMutex
|
activeMu sync.RWMutex
|
||||||
isActive bool
|
isActive bool
|
||||||
@@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string {
|
|||||||
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
|
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
|
||||||
// it. It never touches the database, so it is safe to call from the db-write
|
// it. It never touches the database, so it is safe to call from the db-write
|
||||||
// drop/error path without recursing back through the write queue.
|
// drop/error path without recursing back through the write queue.
|
||||||
func (e *Engine) appendLog(msg string) string {
|
func (e *Engine) appendLog(msg string) models.LogEntry {
|
||||||
ts := time.Now().Format("15:04:05")
|
entry := models.LogEntry{
|
||||||
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
|
Message: sanitizeLog(msg),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
e.logMu.Lock()
|
e.logMu.Lock()
|
||||||
e.logStore = append([]string{entry}, e.logStore...)
|
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
|
||||||
if len(e.logStore) > maxLogEntries {
|
if len(e.logStore) > maxLogEntries {
|
||||||
e.logStore = e.logStore[:maxLogEntries]
|
e.logStore = e.logStore[:maxLogEntries]
|
||||||
}
|
}
|
||||||
@@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string {
|
|||||||
|
|
||||||
func (e *Engine) AddLog(msg string) {
|
func (e *Engine) AddLog(msg string) {
|
||||||
entry := e.appendLog(msg)
|
entry := e.appendLog(msg)
|
||||||
e.enqueueWrite(writeLog{message: entry})
|
e.enqueueWrite(writeLog{message: entry.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueWrite hands a persistence task to the writer goroutine without
|
// enqueueWrite hands a persistence task to the writer goroutine without
|
||||||
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) InitLogs() {
|
func (e *Engine) InitLogs() {
|
||||||
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(logs) == 0 {
|
if len(entries) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.logMu.Lock()
|
e.logMu.Lock()
|
||||||
defer e.logMu.Unlock()
|
defer e.logMu.Unlock()
|
||||||
e.logStore = logs
|
e.logStore = entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
||||||
@@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetLogs() []string {
|
func (e *Engine) GetLogs() []models.LogEntry {
|
||||||
e.logMu.RLock()
|
e.logMu.RLock()
|
||||||
defer e.logMu.RUnlock()
|
defer e.logMu.RUnlock()
|
||||||
logs := make([]string, len(e.logStore))
|
logs := make([]models.LogEntry, len(e.logStore))
|
||||||
copy(logs, e.logStore)
|
copy(logs, e.logStore)
|
||||||
return logs
|
return logs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type mockStore struct {
|
|||||||
sites []models.SiteConfig
|
sites []models.SiteConfig
|
||||||
alerts map[int]models.AlertConfig
|
alerts map[int]models.AlertConfig
|
||||||
maintenance map[int]bool
|
maintenance map[int]bool
|
||||||
logs []string
|
logs []models.LogEntry
|
||||||
history map[int][]models.CheckRecord
|
history map[int][]models.CheckRecord
|
||||||
savedChecks []savedCheck
|
savedChecks []savedCheck
|
||||||
savedLogs []string
|
savedLogs []string
|
||||||
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) {
|
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) {
|
||||||
return m.logs, nil
|
return m.logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
|
|||||||
logs := e.GetLogs()
|
logs := e.GetLogs()
|
||||||
found := false
|
found := false
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
if containsStr(l, "suppressed") {
|
if containsStr(l.Message, "suppressed") {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
|
|||||||
if len(logs) != 100 {
|
if len(logs) != 100 {
|
||||||
t.Errorf("expected 100 logs, got %d", len(logs))
|
t.Errorf("expected 100 logs, got %d", len(logs))
|
||||||
}
|
}
|
||||||
if !containsStr(logs[0], "log-104") {
|
if !containsStr(logs[0].Message, "log-104") {
|
||||||
t.Errorf("expected newest log first, got %s", logs[0])
|
t.Errorf("expected newest log first, got %s", logs[0].Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitLogs_LoadsFromDB(t *testing.T) {
|
func TestInitLogs_LoadsFromDB(t *testing.T) {
|
||||||
ms := newMockStore()
|
ms := newMockStore()
|
||||||
ms.logs = []string{"old-log-1", "old-log-2"}
|
ms.logs = []models.LogEntry{
|
||||||
|
{Message: "old-log-1"},
|
||||||
|
{Message: "old-log-2"},
|
||||||
|
}
|
||||||
e := newTestEngine(ms)
|
e := newTestEngine(ms)
|
||||||
e.InitLogs()
|
e.InitLogs()
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("OK"))
|
_, _ = w.Write([]byte("OK"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ func TestHealth_WrongSecret(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 401 {
|
if resp.StatusCode != 200 {
|
||||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
|
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var logs []string
|
var entries []models.LogEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var msg string
|
var e models.LogEntry
|
||||||
if err := rows.Scan(&msg); err != nil {
|
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
|
||||||
return logs, err
|
return entries, err
|
||||||
}
|
}
|
||||||
logs = append(logs, msg)
|
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
|
||||||
|
if len(e.Message) > 3 && e.Message[0] == '[' {
|
||||||
|
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
|
||||||
|
e.Message = strings.TrimSpace(e.Message[idx+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return logs, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
|
|||||||
// LoadLogs ordering ties when rows share a created_at second).
|
// LoadLogs ordering ties when rows share a created_at second).
|
||||||
present := make(map[string]bool, len(logs))
|
present := make(map[string]bool, len(logs))
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
present[l] = true
|
present[l.Message] = true
|
||||||
}
|
}
|
||||||
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
||||||
t.Error("newest log was pruned")
|
t.Error("newest log was pruned")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type Store interface {
|
|||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
SaveLog(ctx context.Context, message string) error
|
SaveLog(ctx context.Context, message string) error
|
||||||
LoadLogs(ctx context.Context, limit int) ([]string, error)
|
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
|
||||||
PruneLogs(ctx context.Context) error
|
PruneLogs(ctx context.Context) error
|
||||||
|
|
||||||
// Maintenance Windows
|
// Maintenance Windows
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil }
|
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
|
||||||
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
||||||
|
|
||||||
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||||
if m.GetActiveMaintenanceWindowsFunc != nil {
|
if m.GetActiveMaintenanceWindowsFunc != nil {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func (m *Model) refreshLive() {
|
|||||||
m.sites = ordered
|
m.sites = ordered
|
||||||
m.refreshLogContent()
|
m.refreshLogContent()
|
||||||
|
|
||||||
if m.currentTab == 0 && m.selectedID != 0 {
|
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
||||||
for i, s := range m.sites {
|
for i, s := range m.sites {
|
||||||
if s.ID == m.selectedID {
|
if s.ID == m.selectedID {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
@@ -118,7 +118,7 @@ func (m *Model) refreshLive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncSelectedID() {
|
func (m *Model) syncSelectedID() {
|
||||||
if m.currentTab == 0 && m.cursor < len(m.sites) {
|
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
m.selectedID = m.sites[m.cursor].ID
|
m.selectedID = m.sites[m.cursor].ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||||
|
borderColor := m.theme.Border
|
||||||
|
titleColor := m.theme.Muted
|
||||||
|
if focused {
|
||||||
|
borderColor = m.theme.Accent
|
||||||
|
titleColor = m.theme.Accent
|
||||||
|
}
|
||||||
|
|
||||||
|
bc := lipgloss.NewStyle().Foreground(borderColor)
|
||||||
|
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
|
||||||
|
|
||||||
|
innerW := width - 2
|
||||||
|
if innerW < 10 {
|
||||||
|
innerW = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
titleRendered := tc.Render(" " + title + " ")
|
||||||
|
titleLen := len([]rune(title)) + 2
|
||||||
|
fillLen := innerW - titleLen - 1
|
||||||
|
if fillLen < 0 {
|
||||||
|
fillLen = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
|
||||||
|
|
||||||
|
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
|
||||||
|
inner := contentStyle.Render(content)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, top)
|
||||||
|
for _, line := range strings.Split(inner, "\n") {
|
||||||
|
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
|
||||||
|
}
|
||||||
|
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -17,6 +17,17 @@ func parseHex(hex string) (r, g, b uint8) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trueColorHex(c lipgloss.TerminalColor) string {
|
||||||
|
switch v := c.(type) {
|
||||||
|
case lipgloss.CompleteColor:
|
||||||
|
return v.TrueColor
|
||||||
|
case lipgloss.Color:
|
||||||
|
return string(v)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func dimColor(hex string, brightness float64) lipgloss.Color {
|
func dimColor(hex string, brightness float64) lipgloss.Color {
|
||||||
r, g, b := parseHex(hex)
|
r, g, b := parseHex(hex)
|
||||||
f := 0.3 + brightness*0.7
|
f := 0.3 + brightness*0.7
|
||||||
@@ -27,35 +38,36 @@ func dimColor(hex string, brightness float64) lipgloss.Color {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
|
func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||||
if bg != "" {
|
if bg != nil {
|
||||||
return s.Background(bg)
|
return s.Background(bg)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
|
func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||||
var hex string
|
var base lipgloss.TerminalColor
|
||||||
var t float64
|
var t float64
|
||||||
switch {
|
switch {
|
||||||
case ms < 200:
|
case ms < 200:
|
||||||
hex = m.st.sparkSuccess
|
base = m.st.sparkSuccess
|
||||||
t = float64(ms) / 200
|
t = float64(ms) / 200
|
||||||
case ms < 500:
|
case ms < 500:
|
||||||
hex = m.st.sparkWarning
|
base = m.st.sparkWarning
|
||||||
t = float64(ms-200) / 300
|
t = float64(ms-200) / 300
|
||||||
default:
|
default:
|
||||||
hex = m.st.sparkDanger
|
base = m.st.sparkDanger
|
||||||
t = float64(ms-500) / 1500
|
t = float64(ms-500) / 1500
|
||||||
if t > 1 {
|
if t > 1 {
|
||||||
t = 1
|
t = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hex := trueColorHex(base)
|
||||||
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
||||||
return withBg(s, bg)
|
return withBg(s, bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
|
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||||
if len(latencies) == 0 {
|
if len(latencies) == 0 {
|
||||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
@@ -103,7 +115,7 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
|
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
@@ -143,7 +155,7 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
|
|||||||
return offset + (x - padding)
|
return offset + (x - padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
|
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
||||||
allSites := m.engine.GetAllSites()
|
allSites := m.engine.GetAllSites()
|
||||||
var childStatuses [][]bool
|
var childStatuses [][]bool
|
||||||
for _, s := range allSites {
|
for _, s := range allSites {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLatencySparkline_Empty(t *testing.T) {
|
func TestLatencySparkline_Empty(t *testing.T) {
|
||||||
got := styledModel.latencySparkline(nil, nil, 10, "")
|
got := styledModel.latencySparkline(nil, nil, 10, nil)
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty sparkline should be dots, got %q", got)
|
t.Errorf("empty sparkline should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -17,7 +19,7 @@ func TestLatencySparkline_Empty(t *testing.T) {
|
|||||||
func TestLatencySparkline_SingleValue(t *testing.T) {
|
func TestLatencySparkline_SingleValue(t *testing.T) {
|
||||||
latencies := []time.Duration{100 * time.Millisecond}
|
latencies := []time.Duration{100 * time.Millisecond}
|
||||||
statuses := []bool{true}
|
statuses := []bool{true}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|||||||
latencies[i] = time.Duration(i*50) * time.Millisecond
|
latencies[i] = time.Duration(i*50) * time.Millisecond
|
||||||
statuses[i] = true
|
statuses[i] = true
|
||||||
}
|
}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|||||||
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
||||||
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
||||||
statuses := []bool{true, true, true}
|
statuses := []bool{true, true, true}
|
||||||
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
|
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil))
|
||||||
runes := []rune(out)
|
runes := []rune(out)
|
||||||
if len(runes) < 3 {
|
if len(runes) < 3 {
|
||||||
t.Fatalf("expected 3 runes, got %d", len(runes))
|
t.Fatalf("expected 3 runes, got %d", len(runes))
|
||||||
@@ -57,14 +59,14 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
|||||||
|
|
||||||
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
||||||
st := newStyles(themeFlexokiDark)
|
st := newStyles(themeFlexokiDark)
|
||||||
st.sparkSuccess = "#00ff00"
|
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||||
st.sparkWarning = "#ffff00"
|
st.sparkWarning = lipgloss.Color("#ffff00")
|
||||||
st.sparkDanger = "#ff0000"
|
st.sparkDanger = lipgloss.Color("#ff0000")
|
||||||
m := Model{st: st}
|
m := Model{st: st}
|
||||||
|
|
||||||
green := m.latencyStyle(50, "")
|
green := m.latencyStyle(50, nil)
|
||||||
yellow := m.latencyStyle(300, "")
|
yellow := m.latencyStyle(300, nil)
|
||||||
red := m.latencyStyle(800, "")
|
red := m.latencyStyle(800, nil)
|
||||||
|
|
||||||
gfg := green.GetForeground()
|
gfg := green.GetForeground()
|
||||||
yfg := yellow.GetForeground()
|
yfg := yellow.GetForeground()
|
||||||
@@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
|||||||
|
|
||||||
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
||||||
st := newStyles(themeFlexokiDark)
|
st := newStyles(themeFlexokiDark)
|
||||||
st.sparkSuccess = "#00ff00"
|
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||||
m := Model{st: st}
|
m := Model{st: st}
|
||||||
|
|
||||||
dim := m.latencyStyle(10, "")
|
dim := m.latencyStyle(10, nil)
|
||||||
bright := m.latencyStyle(190, "")
|
bright := m.latencyStyle(190, nil)
|
||||||
|
|
||||||
if dim.GetForeground() == bright.GetForeground() {
|
if dim.GetForeground() == bright.GetForeground() {
|
||||||
t.Error("10ms and 190ms should have different brightness within green band")
|
t.Error("10ms and 190ms should have different brightness within green band")
|
||||||
@@ -91,7 +93,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
|||||||
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
||||||
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
||||||
statuses := []bool{true, true, true}
|
statuses := []bool{true, true, true}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
count := utf8.RuneCountInString(stripANSI(got))
|
count := utf8.RuneCountInString(stripANSI(got))
|
||||||
if count != 5 {
|
if count != 5 {
|
||||||
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
||||||
@@ -116,7 +118,7 @@ func stripANSI(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||||
got := styledModel.heartbeatSparkline(nil, 10, "")
|
got := styledModel.heartbeatSparkline(nil, 10, nil)
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty heartbeat should be dots, got %q", got)
|
t.Errorf("empty heartbeat should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||||
statuses := []bool{true, false, true, true, false}
|
statuses := []bool{true, false, true, true, false}
|
||||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("heartbeat sparkline should not be empty")
|
t.Error("heartbeat sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
||||||
statuses := []bool{true, true}
|
statuses := []bool{true, true}
|
||||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||||
if !strings.Contains(got, "···") {
|
if !strings.Contains(got, "···") {
|
||||||
t.Errorf("should have dot padding for width > data, got %q", got)
|
t.Errorf("should have dot padding for width > data, got %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (m Model) viewAlertsTab() string {
|
|||||||
nameW := widths[2]
|
nameW := widths[2]
|
||||||
cfgW := widths[4]
|
cfgW := widths[4]
|
||||||
|
|
||||||
return m.renderTable(
|
tbl := m.renderTable(
|
||||||
headers,
|
headers,
|
||||||
len(m.alerts),
|
len(m.alerts),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
@@ -206,6 +206,25 @@ func (m Model) viewAlertsTab() string {
|
|||||||
},
|
},
|
||||||
widths, nil,
|
widths, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var totalSent, totalFail int
|
||||||
|
for _, a := range m.alerts {
|
||||||
|
h := m.engine.GetAlertHealth(a.ID)
|
||||||
|
totalSent += h.SendCount
|
||||||
|
totalFail += h.FailCount
|
||||||
|
}
|
||||||
|
types := make(map[string]bool)
|
||||||
|
for _, a := range m.alerts {
|
||||||
|
types[a.Type] = true
|
||||||
|
}
|
||||||
|
failLabel := "failures"
|
||||||
|
if totalFail == 1 {
|
||||||
|
failLabel = "failure"
|
||||||
|
}
|
||||||
|
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
|
||||||
|
len(m.alerts), len(types), totalSent, totalFail, failLabel)
|
||||||
|
|
||||||
|
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewAlertDetailPanel() string {
|
func (m Model) viewAlertDetailPanel() string {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logSeverity int
|
type logSeverity int
|
||||||
@@ -15,8 +17,8 @@ const (
|
|||||||
severitySystem
|
severitySystem
|
||||||
)
|
)
|
||||||
|
|
||||||
func classifyLog(line string) logSeverity {
|
func classifyLog(msg string) logSeverity {
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(msg)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, "confirmed down"),
|
case strings.Contains(lower, "confirmed down"),
|
||||||
strings.Contains(lower, "is down"),
|
strings.Contains(lower, "is down"),
|
||||||
@@ -63,67 +65,32 @@ func (m Model) renderLogTag(sev logSeverity) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderLogLine(line string) string {
|
func (m Model) renderLogLine(entry models.LogEntry) string {
|
||||||
sev := classifyLog(line)
|
sev := classifyLog(entry.Message)
|
||||||
tag := m.renderLogTag(sev)
|
tag := m.renderLogTag(sev)
|
||||||
|
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
|
||||||
ts := ""
|
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
||||||
msg := line
|
|
||||||
if len(line) > 10 && line[0] == '[' {
|
|
||||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
|
||||||
ts = m.st.subtleStyle.Render(line[1:idx])
|
|
||||||
msg = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts != "" {
|
|
||||||
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(" %s %s", tag, msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshLogContent rebuilds the log viewport from the full engine log list,
|
|
||||||
// filtering before windowing so the entry count and "(n hidden)" reflect all
|
|
||||||
// logs, not just the visible viewport slice.
|
|
||||||
func (m *Model) refreshLogContent() {
|
func (m *Model) refreshLogContent() {
|
||||||
var rendered []string
|
var rendered []string
|
||||||
total := 0
|
total := 0
|
||||||
shown := 0
|
shown := 0
|
||||||
|
|
||||||
for _, line := range m.engine.GetLogs() {
|
for _, entry := range m.engine.GetLogs() {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total++
|
total++
|
||||||
sev := classifyLog(line)
|
sev := classifyLog(entry.Message)
|
||||||
if m.logFilterImportant && !isImportantLog(sev) {
|
if m.logFilterImportant && !isImportantLog(sev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
shown++
|
shown++
|
||||||
rendered = append(rendered, m.renderLogLine(line))
|
rendered = append(rendered, m.renderLogLine(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logTotal = total
|
m.logTotal = total
|
||||||
m.logShown = shown
|
m.logShown = shown
|
||||||
m.logViewport.SetContent(strings.Join(rendered, "\n"))
|
m.logViewport.SetContent(strings.Join(rendered, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewLogsTab() string {
|
|
||||||
if m.logTotal == 0 {
|
|
||||||
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
|
|
||||||
}
|
|
||||||
|
|
||||||
filterLabel := "All"
|
|
||||||
if m.logFilterImportant {
|
|
||||||
filterLabel = "Important"
|
|
||||||
}
|
|
||||||
|
|
||||||
header := m.st.subtleStyle.Render(fmt.Sprintf(
|
|
||||||
" %d entries Filter: %s", m.logShown, filterLabel))
|
|
||||||
|
|
||||||
if m.logFilterImportant && m.logShown < m.logTotal {
|
|
||||||
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "\n" + header + "\n\n" + m.logViewport.View()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
||||||
|
sev := classifyLog(entry.Message)
|
||||||
|
|
||||||
|
var tag string
|
||||||
|
switch sev {
|
||||||
|
case severityDown:
|
||||||
|
tag = m.st.dangerStyle.Render("▼")
|
||||||
|
case severityUp:
|
||||||
|
tag = m.st.specialStyle.Render("▲")
|
||||||
|
case severityWarn:
|
||||||
|
tag = m.st.warnStyle.Render("◆")
|
||||||
|
case severitySystem:
|
||||||
|
tag = m.st.titleStyle.Render("●")
|
||||||
|
default:
|
||||||
|
tag = m.st.subtleStyle.Render("·")
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := entry.CreatedAt.Local().Format("01/02 15:04")
|
||||||
|
|
||||||
|
msg := entry.Message
|
||||||
|
msg = strings.TrimPrefix(msg, "Monitor ")
|
||||||
|
msg = strings.TrimPrefix(msg, "Push ")
|
||||||
|
|
||||||
|
prefixW := len(ts) + 4
|
||||||
|
msgW := maxW - prefixW
|
||||||
|
if msgW < 5 {
|
||||||
|
msgW = 5
|
||||||
|
}
|
||||||
|
msg = limitStr(msg, msgW)
|
||||||
|
|
||||||
|
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||||
|
logs := m.engine.GetLogs()
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return m.st.subtleStyle.Render(" No logs yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||||
|
|
||||||
|
var all []string
|
||||||
|
for _, entry := range logs {
|
||||||
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
all = append(all, m.renderCompactLogLine(entry, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
start := m.logScrollOffset
|
||||||
|
if start > len(all) {
|
||||||
|
start = len(all)
|
||||||
|
}
|
||||||
|
end := start + maxLines
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
visible := all[start:end]
|
||||||
|
|
||||||
|
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollLogs(delta int) {
|
||||||
|
logs := m.engine.GetLogs()
|
||||||
|
total := 0
|
||||||
|
for _, entry := range logs {
|
||||||
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logScrollOffset += delta
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
|
if m.logScrollOffset > total-1 {
|
||||||
|
m.logScrollOffset = total - 1
|
||||||
|
}
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func (m Model) viewMaintTab() string {
|
|||||||
monW := widths[3]
|
monW := widths[3]
|
||||||
timeW := widths[5]
|
timeW := widths[5]
|
||||||
|
|
||||||
return m.renderTable(
|
tbl := m.renderTable(
|
||||||
headers,
|
headers,
|
||||||
len(m.maintenanceWindows),
|
len(m.maintenanceWindows),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
@@ -130,6 +130,21 @@ func (m Model) viewMaintTab() string {
|
|||||||
widths,
|
widths,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var active, scheduled, ended int
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.StartTime.After(now) {
|
||||||
|
scheduled++
|
||||||
|
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||||
|
ended++
|
||||||
|
} else {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
|
||||||
|
|
||||||
|
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) initMaintHuhForm() tea.Cmd {
|
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ func (m Model) viewNodesTab() string {
|
|||||||
}
|
}
|
||||||
nameW := widths[0]
|
nameW := widths[0]
|
||||||
|
|
||||||
return m.renderTable(
|
tbl := m.renderTable(
|
||||||
headers,
|
headers,
|
||||||
len(m.nodes),
|
len(m.nodes),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
@@ -48,6 +50,30 @@ func (m Model) viewNodesTab() string {
|
|||||||
widths,
|
widths,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var online int
|
||||||
|
regions := make(map[string]bool)
|
||||||
|
var leader string
|
||||||
|
for _, n := range m.nodes {
|
||||||
|
if time.Since(n.LastSeen) < 60*time.Second {
|
||||||
|
online++
|
||||||
|
}
|
||||||
|
if n.Region != "" {
|
||||||
|
regions[n.Region] = true
|
||||||
|
}
|
||||||
|
if n.Name == "leader" {
|
||||||
|
leader = n.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := []string{fmt.Sprintf("%d/%d online", online, len(m.nodes))}
|
||||||
|
if leader != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("leader: %s", leader))
|
||||||
|
}
|
||||||
|
if len(regions) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d regions", len(regions)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tbl + "\n " + m.st.subtleStyle.Render(strings.Join(parts, " · "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) fmtNodeStatus(lastSeen time.Time) string {
|
func (m Model) fmtNodeStatus(lastSeen time.Time) string {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) viewSettingsTab() string {
|
||||||
|
maxSections := 2
|
||||||
|
if m.isAdmin {
|
||||||
|
maxSections = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
sections := []string{"Alerts", "Nodes"}
|
||||||
|
if m.isAdmin {
|
||||||
|
sections = append(sections, "Users")
|
||||||
|
}
|
||||||
|
_ = maxSections
|
||||||
|
|
||||||
|
var tabs []string
|
||||||
|
for i, name := range sections {
|
||||||
|
if i == m.settingsSection {
|
||||||
|
tabs = append(tabs, m.st.activeTab.Render(name))
|
||||||
|
} else {
|
||||||
|
tabs = append(tabs, m.st.inactiveTab.Render(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
|
||||||
|
var content string
|
||||||
|
switch m.settingsSection {
|
||||||
|
case sectionAlerts:
|
||||||
|
content = m.viewAlertsTab()
|
||||||
|
case sectionNodes:
|
||||||
|
content = m.viewNodesTab()
|
||||||
|
case sectionUsers:
|
||||||
|
if m.isAdmin {
|
||||||
|
content = m.viewUsersTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return header + "\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) switchSettingsSection(idx int) {
|
||||||
|
max := 1
|
||||||
|
if m.isAdmin {
|
||||||
|
max = 2
|
||||||
|
}
|
||||||
|
if idx > max {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
idx = max
|
||||||
|
}
|
||||||
|
m.settingsSection = idx
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
}
|
||||||
@@ -82,8 +82,12 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
var widths []int
|
var widths []int
|
||||||
var fixed int
|
var fixed int
|
||||||
|
|
||||||
|
cw := m.contentWidth
|
||||||
|
if cw == 0 {
|
||||||
|
cw = m.termWidth
|
||||||
|
}
|
||||||
for _, c := range siteColumns {
|
for _, c := range siteColumns {
|
||||||
if c.minTerm > 0 && m.termWidth < c.minTerm {
|
if c.minTerm > 0 && cw < c.minTerm {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
active = append(active, c.key)
|
active = append(active, c.key)
|
||||||
@@ -104,7 +108,7 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
|
|
||||||
numCols := len(headers)
|
numCols := len(headers)
|
||||||
borderOverhead := 2 + (numCols - 1)
|
borderOverhead := 2 + (numCols - 1)
|
||||||
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed
|
avail := cw - chromePadH - 2 - borderOverhead - fixed
|
||||||
if avail < 20 {
|
if avail < 20 {
|
||||||
avail = 20
|
avail = 20
|
||||||
}
|
}
|
||||||
@@ -204,7 +208,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
site := m.sites[i]
|
site := m.sites[i]
|
||||||
rowIdx := i - start
|
rowIdx := i - start
|
||||||
var rowBg lipgloss.Color
|
var rowBg lipgloss.TerminalColor
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
rowBg = m.theme.SelectedBg
|
rowBg = m.theme.SelectedBg
|
||||||
} else if rowIdx%2 == 1 {
|
} else if rowIdx%2 == 1 {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd {
|
|||||||
st := m.store
|
st := m.store
|
||||||
id := m.editID
|
id := m.editID
|
||||||
username, key, role := d.Username, d.PublicKey, d.Role
|
username, key, role := d.Username, d.PublicKey, d.Role
|
||||||
m.state = stateUsers
|
m.state = stateDashboard
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
return writeCmd("Update user", func() error {
|
return writeCmd("Update user", func() error {
|
||||||
return st.UpdateUser(context.Background(), id, username, key, role)
|
return st.UpdateUser(context.Background(), id, username, key, role)
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) isWide() bool {
|
func (m Model) isWide() bool {
|
||||||
return m.termWidth >= wideBreakpoint
|
w := m.contentWidth
|
||||||
|
if w == 0 {
|
||||||
|
w = m.termWidth
|
||||||
|
}
|
||||||
|
return w >= wideBreakpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
|
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
|
||||||
@@ -35,7 +39,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
}
|
}
|
||||||
borderOverhead := 2 + len(colWidths) - 1
|
borderOverhead := 2 + len(colWidths) - 1
|
||||||
tableWidth := colTotal + borderOverhead
|
tableWidth := colTotal + borderOverhead
|
||||||
maxWidth := m.termWidth - chromePadH - 2
|
cw := m.contentWidth
|
||||||
|
if cw == 0 {
|
||||||
|
cw = m.termWidth
|
||||||
|
}
|
||||||
|
maxWidth := cw - chromePadH - 2
|
||||||
if tableWidth > maxWidth {
|
if tableWidth > maxWidth {
|
||||||
tableWidth = maxWidth
|
tableWidth = maxWidth
|
||||||
}
|
}
|
||||||
@@ -44,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
}
|
}
|
||||||
|
|
||||||
t := table.New().
|
t := table.New().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.HiddenBorder()).
|
||||||
BorderStyle(m.st.tableBorderStyle).
|
|
||||||
Width(tableWidth).
|
Width(tableWidth).
|
||||||
Headers(headers...).
|
Headers(headers...).
|
||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
@@ -86,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
return "\n" + t.Render()
|
return t.Render()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,43 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func cc(hex, ansi string) lipgloss.CompleteColor {
|
||||||
|
return lipgloss.CompleteColor{
|
||||||
|
TrueColor: hex,
|
||||||
|
ANSI256: hex,
|
||||||
|
ANSI: ansi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
// Base layers
|
// Base layers
|
||||||
Bg lipgloss.Color
|
Bg lipgloss.TerminalColor
|
||||||
Surface lipgloss.Color
|
Surface lipgloss.TerminalColor
|
||||||
Panel lipgloss.Color
|
Panel lipgloss.TerminalColor
|
||||||
Border lipgloss.Color
|
Border lipgloss.TerminalColor
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
Fg lipgloss.Color
|
Fg lipgloss.TerminalColor
|
||||||
Muted lipgloss.Color
|
Muted lipgloss.TerminalColor
|
||||||
Subtle lipgloss.Color
|
Subtle lipgloss.TerminalColor
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
Success lipgloss.Color
|
Success lipgloss.TerminalColor
|
||||||
Warning lipgloss.Color
|
Warning lipgloss.TerminalColor
|
||||||
Stale lipgloss.Color
|
Stale lipgloss.TerminalColor
|
||||||
Danger lipgloss.Color
|
Danger lipgloss.TerminalColor
|
||||||
Info lipgloss.Color
|
Info lipgloss.TerminalColor
|
||||||
Accent lipgloss.Color
|
Accent lipgloss.TerminalColor
|
||||||
Purple lipgloss.Color
|
Purple lipgloss.TerminalColor
|
||||||
|
|
||||||
// Table
|
// Table
|
||||||
ZebraBg lipgloss.Color
|
ZebraBg lipgloss.TerminalColor
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
SelectedFg lipgloss.Color
|
SelectedFg lipgloss.TerminalColor
|
||||||
SelectedBg lipgloss.Color
|
SelectedBg lipgloss.TerminalColor
|
||||||
}
|
}
|
||||||
|
|
||||||
var themes = []Theme{
|
var themes = []Theme{
|
||||||
@@ -46,107 +54,107 @@ var themes = []Theme{
|
|||||||
|
|
||||||
var themeFlexokiDark = Theme{
|
var themeFlexokiDark = Theme{
|
||||||
Name: "Flexoki Dark",
|
Name: "Flexoki Dark",
|
||||||
Bg: "#1C1B1A",
|
Bg: cc("#1C1B1A", ""),
|
||||||
Surface: "#282726",
|
Surface: cc("#282726", ""),
|
||||||
Panel: "#343331",
|
Panel: cc("#343331", ""),
|
||||||
Border: "#575653",
|
Border: cc("#575653", "8"),
|
||||||
Fg: "#CECDC3",
|
Fg: cc("#CECDC3", "15"),
|
||||||
Muted: "#878580",
|
Muted: cc("#878580", "7"),
|
||||||
Subtle: "#6F6E69",
|
Subtle: cc("#6F6E69", "7"),
|
||||||
Success: "#879A39",
|
Success: cc("#879A39", "10"),
|
||||||
Warning: "#D0A215",
|
Warning: cc("#D0A215", "11"),
|
||||||
Stale: "#DA702C",
|
Stale: cc("#DA702C", "3"),
|
||||||
Danger: "#D14D41",
|
Danger: cc("#D14D41", "9"),
|
||||||
Info: "#4385BE",
|
Info: cc("#4385BE", "12"),
|
||||||
Accent: "#3AA99F",
|
Accent: cc("#3AA99F", "14"),
|
||||||
Purple: "#8B7EC8",
|
Purple: cc("#8B7EC8", "13"),
|
||||||
ZebraBg: "#222120",
|
ZebraBg: cc("#222120", ""),
|
||||||
SelectedFg: "#FFFCF0",
|
SelectedFg: cc("#FFFCF0", "15"),
|
||||||
SelectedBg: "#403E3C",
|
SelectedBg: cc("#403E3C", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeTokyoNight = Theme{
|
var themeTokyoNight = Theme{
|
||||||
Name: "Tokyo Night",
|
Name: "Tokyo Night",
|
||||||
Bg: "#1a1b26",
|
Bg: cc("#1a1b26", ""),
|
||||||
Surface: "#24283b",
|
Surface: cc("#24283b", ""),
|
||||||
Panel: "#292e42",
|
Panel: cc("#292e42", ""),
|
||||||
Border: "#3b4261",
|
Border: cc("#3b4261", "8"),
|
||||||
Fg: "#c0caf5",
|
Fg: cc("#c0caf5", "15"),
|
||||||
Muted: "#a9b1d6",
|
Muted: cc("#a9b1d6", "7"),
|
||||||
Subtle: "#565f89",
|
Subtle: cc("#565f89", "7"),
|
||||||
Success: "#9ece6a",
|
Success: cc("#9ece6a", "10"),
|
||||||
Warning: "#e0af68",
|
Warning: cc("#e0af68", "11"),
|
||||||
Stale: "#ff9e64",
|
Stale: cc("#ff9e64", "3"),
|
||||||
Danger: "#f7768e",
|
Danger: cc("#f7768e", "9"),
|
||||||
Info: "#7aa2f7",
|
Info: cc("#7aa2f7", "12"),
|
||||||
Accent: "#7dcfff",
|
Accent: cc("#7dcfff", "14"),
|
||||||
Purple: "#bb9af7",
|
Purple: cc("#bb9af7", "13"),
|
||||||
ZebraBg: "#1c1d28",
|
ZebraBg: cc("#1c1d28", ""),
|
||||||
SelectedFg: "#c0caf5",
|
SelectedFg: cc("#c0caf5", "15"),
|
||||||
SelectedBg: "#292e42",
|
SelectedBg: cc("#292e42", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeGruvbox = Theme{
|
var themeGruvbox = Theme{
|
||||||
Name: "Gruvbox",
|
Name: "Gruvbox",
|
||||||
Bg: "#282828",
|
Bg: cc("#282828", ""),
|
||||||
Surface: "#3c3836",
|
Surface: cc("#3c3836", ""),
|
||||||
Panel: "#504945",
|
Panel: cc("#504945", ""),
|
||||||
Border: "#665c54",
|
Border: cc("#665c54", "8"),
|
||||||
Fg: "#ebdbb2",
|
Fg: cc("#ebdbb2", "15"),
|
||||||
Muted: "#bdae93",
|
Muted: cc("#bdae93", "7"),
|
||||||
Subtle: "#7c6f64",
|
Subtle: cc("#7c6f64", "7"),
|
||||||
Success: "#b8bb26",
|
Success: cc("#b8bb26", "10"),
|
||||||
Warning: "#fabd2f",
|
Warning: cc("#fabd2f", "11"),
|
||||||
Stale: "#fe8019",
|
Stale: cc("#fe8019", "3"),
|
||||||
Danger: "#fb4934",
|
Danger: cc("#fb4934", "9"),
|
||||||
Info: "#83a598",
|
Info: cc("#83a598", "12"),
|
||||||
Accent: "#8ec07c",
|
Accent: cc("#8ec07c", "14"),
|
||||||
Purple: "#d3869b",
|
Purple: cc("#d3869b", "13"),
|
||||||
ZebraBg: "#2a2a2a",
|
ZebraBg: cc("#2a2a2a", ""),
|
||||||
SelectedFg: "#fbf1c7",
|
SelectedFg: cc("#fbf1c7", "15"),
|
||||||
SelectedBg: "#504945",
|
SelectedBg: cc("#504945", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeCatppuccinMocha = Theme{
|
var themeCatppuccinMocha = Theme{
|
||||||
Name: "Catppuccin Mocha",
|
Name: "Catppuccin Mocha",
|
||||||
Bg: "#1e1e2e",
|
Bg: cc("#1e1e2e", ""),
|
||||||
Surface: "#313244",
|
Surface: cc("#313244", ""),
|
||||||
Panel: "#45475a",
|
Panel: cc("#45475a", ""),
|
||||||
Border: "#585b70",
|
Border: cc("#585b70", "8"),
|
||||||
Fg: "#cdd6f4",
|
Fg: cc("#cdd6f4", "15"),
|
||||||
Muted: "#a6adc8",
|
Muted: cc("#a6adc8", "7"),
|
||||||
Subtle: "#6c7086",
|
Subtle: cc("#6c7086", "7"),
|
||||||
Success: "#a6e3a1",
|
Success: cc("#a6e3a1", "10"),
|
||||||
Warning: "#f9e2af",
|
Warning: cc("#f9e2af", "11"),
|
||||||
Stale: "#fab387",
|
Stale: cc("#fab387", "3"),
|
||||||
Danger: "#f38ba8",
|
Danger: cc("#f38ba8", "9"),
|
||||||
Info: "#89b4fa",
|
Info: cc("#89b4fa", "12"),
|
||||||
Accent: "#94e2d5",
|
Accent: cc("#94e2d5", "14"),
|
||||||
Purple: "#cba6f7",
|
Purple: cc("#cba6f7", "13"),
|
||||||
ZebraBg: "#232334",
|
ZebraBg: cc("#232334", ""),
|
||||||
SelectedFg: "#cdd6f4",
|
SelectedFg: cc("#cdd6f4", "15"),
|
||||||
SelectedBg: "#45475a",
|
SelectedBg: cc("#45475a", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeNord = Theme{
|
var themeNord = Theme{
|
||||||
Name: "Nord",
|
Name: "Nord",
|
||||||
Bg: "#2e3440",
|
Bg: cc("#2e3440", ""),
|
||||||
Surface: "#3b4252",
|
Surface: cc("#3b4252", ""),
|
||||||
Panel: "#434c5e",
|
Panel: cc("#434c5e", ""),
|
||||||
Border: "#4c566a",
|
Border: cc("#4c566a", "8"),
|
||||||
Fg: "#d8dee9",
|
Fg: cc("#d8dee9", "15"),
|
||||||
Muted: "#d8dee9",
|
Muted: cc("#d8dee9", "7"),
|
||||||
Subtle: "#4c566a",
|
Subtle: cc("#4c566a", "7"),
|
||||||
Success: "#a3be8c",
|
Success: cc("#a3be8c", "10"),
|
||||||
Warning: "#ebcb8b",
|
Warning: cc("#ebcb8b", "11"),
|
||||||
Stale: "#d08770",
|
Stale: cc("#d08770", "3"),
|
||||||
Danger: "#bf616a",
|
Danger: cc("#bf616a", "9"),
|
||||||
Info: "#81a1c1",
|
Info: cc("#81a1c1", "12"),
|
||||||
Accent: "#88c0d0",
|
Accent: cc("#88c0d0", "14"),
|
||||||
Purple: "#b48ead",
|
Purple: cc("#b48ead", "13"),
|
||||||
ZebraBg: "#323845",
|
ZebraBg: cc("#323845", ""),
|
||||||
SelectedFg: "#eceff4",
|
SelectedFg: cc("#eceff4", "15"),
|
||||||
SelectedBg: "#434c5e",
|
SelectedBg: cc("#434c5e", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Theme) HuhTheme() *huh.Theme {
|
func (t Theme) HuhTheme() *huh.Theme {
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ type styles struct {
|
|||||||
activeTab lipgloss.Style
|
activeTab lipgloss.Style
|
||||||
inactiveTab lipgloss.Style
|
inactiveTab lipgloss.Style
|
||||||
|
|
||||||
sparkSuccess string
|
sparkSuccess lipgloss.TerminalColor
|
||||||
sparkWarning string
|
sparkWarning lipgloss.TerminalColor
|
||||||
sparkDanger string
|
sparkDanger lipgloss.TerminalColor
|
||||||
|
|
||||||
tableHeaderStyle lipgloss.Style
|
tableHeaderStyle lipgloss.Style
|
||||||
tableCellStyle lipgloss.Style
|
tableCellStyle lipgloss.Style
|
||||||
@@ -46,23 +46,23 @@ type styles struct {
|
|||||||
|
|
||||||
func newStyles(t Theme) *styles {
|
func newStyles(t Theme) *styles {
|
||||||
return &styles{
|
return &styles{
|
||||||
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle),
|
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
|
||||||
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
||||||
warnStyle: lipgloss.NewStyle().Foreground(t.Warning),
|
warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
|
||||||
staleStyle: lipgloss.NewStyle().Foreground(t.Stale),
|
staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
|
||||||
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger),
|
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
|
||||||
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
||||||
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
||||||
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted),
|
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted).Faint(true),
|
||||||
|
|
||||||
sparkSuccess: string(t.Success),
|
sparkSuccess: t.Success,
|
||||||
sparkWarning: string(t.Warning),
|
sparkWarning: t.Warning,
|
||||||
sparkDanger: string(t.Danger),
|
sparkDanger: t.Danger,
|
||||||
|
|
||||||
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
|
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
|
||||||
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
|
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
|
||||||
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
|
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
|
||||||
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border),
|
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border).Faint(true),
|
||||||
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
||||||
|
|
||||||
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
||||||
@@ -84,6 +84,24 @@ const (
|
|||||||
detailSparkWidth = 40
|
detailSparkWidth = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tabMonitors = 0
|
||||||
|
tabMaint = 1
|
||||||
|
tabSettings = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sectionAlerts = 0
|
||||||
|
sectionNodes = 1
|
||||||
|
sectionUsers = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
panelMonitors = 0
|
||||||
|
panelLogs = 1
|
||||||
|
panelDetail = 2
|
||||||
|
)
|
||||||
|
|
||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -102,16 +120,20 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
state sessionState
|
state sessionState
|
||||||
currentTab int
|
currentTab int
|
||||||
cursor int
|
settingsSection int
|
||||||
selectedID int
|
cursor int
|
||||||
tableOffset int
|
selectedID int
|
||||||
maxTableRows int
|
tableOffset int
|
||||||
termWidth int
|
maxTableRows int
|
||||||
termHeight int
|
termWidth int
|
||||||
editID int
|
termHeight int
|
||||||
editToken string
|
contentWidth int
|
||||||
|
focusedPanel int
|
||||||
|
logScrollOffset int
|
||||||
|
editID int
|
||||||
|
editToken string
|
||||||
|
|
||||||
huhForm *huh.Form
|
huhForm *huh.Form
|
||||||
siteFormData *siteFormData
|
siteFormData *siteFormData
|
||||||
@@ -165,7 +187,7 @@ type Model struct {
|
|||||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||||
tabSeq int // seq of the newest issued tab-data load
|
tabSeq int // seq of the newest issued tab-data load
|
||||||
|
|
||||||
// detail-panel state-change history, loaded on enter so View does no DB IO
|
detailOpen bool
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
|
|
||||||
@@ -197,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
logViewport: vpLogs,
|
logViewport: vpLogs,
|
||||||
@@ -210,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
sparkTooltipIdx: -1,
|
sparkTooltipIdx: -1,
|
||||||
|
|||||||
@@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
id := m.deleteID
|
id := m.deleteID
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
switch m.deleteTab {
|
switch m.deleteTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
|
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
|
||||||
m.engine.RemoveSite(id)
|
m.engine.RemoveSite(id)
|
||||||
m.adjustCursor(len(m.sites) - 1)
|
m.adjustCursor(len(m.sites) - 1)
|
||||||
case 1:
|
case tabMaint:
|
||||||
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
|
|
||||||
m.adjustCursor(len(m.alerts) - 1)
|
|
||||||
case 4:
|
|
||||||
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
|
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
|
||||||
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||||
case 5:
|
case tabSettings:
|
||||||
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
|
switch m.settingsSection {
|
||||||
m.adjustCursor(len(m.users) - 1)
|
case sectionAlerts:
|
||||||
|
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
|
||||||
|
m.adjustCursor(len(m.alerts) - 1)
|
||||||
|
case sectionUsers:
|
||||||
|
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
|
||||||
|
m.adjustCursor(len(m.users) - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.refreshLive()
|
m.refreshLive()
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case "n", "N", "esc":
|
case "n", "N", "esc":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if keyMsg.String() == "esc" {
|
if keyMsg.String() == "esc" {
|
||||||
m.huhForm = nil
|
m.huhForm = nil
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.currentTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailInlineHeight = 8
|
||||||
|
|
||||||
func (m *Model) recalcLayout() {
|
func (m *Model) recalcLayout() {
|
||||||
chrome := chromeBase
|
chrome := chromeBase
|
||||||
if m.filterMode || m.filterText != "" {
|
if m.filterMode || m.filterText != "" {
|
||||||
chrome++
|
chrome++
|
||||||
}
|
}
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors {
|
||||||
|
chrome += detailInlineHeight
|
||||||
|
}
|
||||||
m.maxTableRows = m.termHeight - chrome
|
m.maxTableRows = m.termHeight - chrome
|
||||||
if m.maxTableRows < 1 {
|
if m.maxTableRows < 1 {
|
||||||
m.maxTableRows = 1
|
m.maxTableRows = 1
|
||||||
@@ -265,7 +264,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
|
if m.state != stateDashboard {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||||
@@ -275,11 +274,11 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.state == stateLogs {
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
m.logViewport.ScrollUp(3)
|
m.scrollLogs(-3)
|
||||||
} else {
|
} else {
|
||||||
m.logViewport.ScrollDown(3)
|
m.scrollLogs(3)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -301,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +327,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleSLAKey(msg)
|
return m.handleSLAKey(msg)
|
||||||
case stateAlertDetail:
|
case stateAlertDetail:
|
||||||
return m.handleAlertDetailKey(msg)
|
return m.handleAlertDetailKey(msg)
|
||||||
case stateDashboard, stateLogs, stateUsers:
|
case stateDashboard:
|
||||||
return m.handleDashboardKey(msg)
|
return m.handleDashboardKey(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -521,63 +523,84 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
var cmd tea.Cmd
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "/":
|
case "/":
|
||||||
if m.currentTab == 0 {
|
if m.currentTab == tabMonitors {
|
||||||
m.filterMode = true
|
m.filterMode = true
|
||||||
m.recalcLayout()
|
m.recalcLayout()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "f":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logFilterImportant = !m.logFilterImportant
|
|
||||||
m.refreshLogContent()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "tab":
|
case "tab":
|
||||||
m.switchTab(m.currentTab + 1)
|
m.switchTab(m.currentTab + 1)
|
||||||
case "pgup", "pgdown":
|
case "left":
|
||||||
if m.state == stateLogs {
|
if m.currentTab == tabSettings {
|
||||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
m.switchSettingsSection(m.settingsSection - 1)
|
||||||
return m, cmd
|
}
|
||||||
|
case "right":
|
||||||
|
if m.currentTab == tabSettings {
|
||||||
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
|
}
|
||||||
|
case "l":
|
||||||
|
switch m.currentTab {
|
||||||
|
case tabSettings:
|
||||||
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
|
case tabMonitors:
|
||||||
|
if m.focusedPanel == panelLogs {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.state == stateLogs {
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
m.logViewport.ScrollUp(1)
|
m.scrollLogs(-1)
|
||||||
} else if m.cursor > 0 {
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
if m.cursor < m.tableOffset {
|
if m.cursor < m.tableOffset {
|
||||||
m.tableOffset = m.cursor
|
m.tableOffset = m.cursor
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.state == stateLogs {
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
m.logViewport.ScrollDown(1)
|
m.scrollLogs(1)
|
||||||
} else {
|
return m, nil
|
||||||
max := m.currentListLen() - 1
|
}
|
||||||
if m.cursor < max {
|
max := m.currentListLen() - 1
|
||||||
m.cursor++
|
if m.cursor < max {
|
||||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
m.cursor++
|
||||||
m.tableOffset++
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||||
}
|
m.tableOffset++
|
||||||
m.syncSelectedID()
|
}
|
||||||
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "n":
|
case "n":
|
||||||
return m.handleNewItem()
|
return m.handleNewItem()
|
||||||
case "e", "enter":
|
case "enter":
|
||||||
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
|
m.state = stateDetail
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
return m.handleEditItem()
|
||||||
|
case "e":
|
||||||
return m.handleEditItem()
|
return m.handleEditItem()
|
||||||
case "t":
|
case "t":
|
||||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||||
a := m.alerts[m.cursor]
|
a := m.alerts[m.cursor]
|
||||||
return m, m.testAlertCmd(a.ID, a.Name)
|
return m, m.testAlertCmd(a.ID, a.Name)
|
||||||
}
|
}
|
||||||
case " ":
|
case " ":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
if m.currentTab == tabMonitors && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||||
gid := m.sites[m.cursor].ID
|
gid := m.sites[m.cursor].ID
|
||||||
m.collapsed[gid] = !m.collapsed[gid]
|
m.collapsed[gid] = !m.collapsed[gid]
|
||||||
payload := collapsedJSON(m.collapsed)
|
payload := collapsedJSON(m.collapsed)
|
||||||
@@ -588,7 +611,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
id := m.sites[m.cursor].ID
|
id := m.sites[m.cursor].ID
|
||||||
paused := m.engine.ToggleSitePause(id)
|
paused := m.engine.ToggleSitePause(id)
|
||||||
st := m.store
|
st := m.store
|
||||||
@@ -598,14 +621,62 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "i":
|
case "i":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.detailOpen = !m.detailOpen
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
m.recalcLayout()
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
st := m.store
|
||||||
|
open := m.detailOpen
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.detailOpen {
|
||||||
|
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
saveCmd := writeCmd("Save detail preference", func() error {
|
||||||
|
v := "false"
|
||||||
|
if open {
|
||||||
|
v = "true"
|
||||||
|
}
|
||||||
|
return st.SetPreference(context.Background(), "detail_open", v)
|
||||||
|
})
|
||||||
|
if cmd != nil {
|
||||||
|
return m, tea.Batch(cmd, saveCmd)
|
||||||
|
}
|
||||||
|
return m, saveCmd
|
||||||
|
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||||
m.state = stateAlertDetail
|
m.state = stateAlertDetail
|
||||||
}
|
}
|
||||||
|
case "esc":
|
||||||
|
if m.currentTab == tabMonitors {
|
||||||
|
if m.focusedPanel != panelMonitors {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.detailOpen {
|
||||||
|
m.detailOpen = false
|
||||||
|
m.recalcLayout()
|
||||||
|
st := m.store
|
||||||
|
return m, writeCmd("Save detail preference", func() error {
|
||||||
|
return st.SetPreference(context.Background(), "detail_open", "false")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h":
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.historySiteName = site.Name
|
||||||
|
m.historySiteID = site.ID
|
||||||
|
m.historyChanges = nil
|
||||||
|
m.historyViewport = viewport.New(
|
||||||
|
m.termWidth-chromePadH,
|
||||||
|
m.termHeight-10,
|
||||||
|
)
|
||||||
|
m.historyViewport.SetContent("\n Loading state history...")
|
||||||
|
m.state = stateHistory
|
||||||
|
return m, m.loadHistoryCmd(site.ID)
|
||||||
|
}
|
||||||
|
case "s":
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.openSLAView(m.sites[m.cursor])
|
||||||
|
}
|
||||||
case "x":
|
case "x":
|
||||||
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
|
||||||
mw := m.maintenanceWindows[m.cursor]
|
mw := m.maintenanceWindows[m.cursor]
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||||
@@ -637,19 +708,22 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
|
|||||||
m.editID = 0
|
m.editID = 0
|
||||||
m.editToken = ""
|
m.editToken = ""
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
m.state = stateFormSite
|
m.state = stateFormSite
|
||||||
return m, m.initSiteHuhForm()
|
return m, m.initSiteHuhForm()
|
||||||
case 1:
|
case tabMaint:
|
||||||
m.state = stateFormAlert
|
|
||||||
return m, m.initAlertHuhForm()
|
|
||||||
case 4:
|
|
||||||
m.state = stateFormMaint
|
m.state = stateFormMaint
|
||||||
return m, m.initMaintHuhForm()
|
return m, m.initMaintHuhForm()
|
||||||
case 5:
|
case tabSettings:
|
||||||
if m.isAdmin {
|
switch m.settingsSection {
|
||||||
m.state = stateFormUser
|
case sectionAlerts:
|
||||||
return m, m.initUserHuhForm()
|
m.state = stateFormAlert
|
||||||
|
return m, m.initAlertHuhForm()
|
||||||
|
case sectionUsers:
|
||||||
|
if m.isAdmin {
|
||||||
|
m.state = stateFormUser
|
||||||
|
return m, m.initUserHuhForm()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -657,24 +731,27 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
if len(m.sites) > 0 {
|
if len(m.sites) > 0 {
|
||||||
m.editID = m.sites[m.cursor].ID
|
m.editID = m.sites[m.cursor].ID
|
||||||
m.editToken = m.sites[m.cursor].Token
|
m.editToken = m.sites[m.cursor].Token
|
||||||
m.state = stateFormSite
|
m.state = stateFormSite
|
||||||
return m, m.initSiteHuhForm()
|
return m, m.initSiteHuhForm()
|
||||||
}
|
}
|
||||||
case 1:
|
case tabSettings:
|
||||||
if len(m.alerts) > 0 {
|
switch m.settingsSection {
|
||||||
m.editID = m.alerts[m.cursor].ID
|
case sectionAlerts:
|
||||||
m.state = stateFormAlert
|
if len(m.alerts) > 0 {
|
||||||
return m, m.initAlertHuhForm()
|
m.editID = m.alerts[m.cursor].ID
|
||||||
}
|
m.state = stateFormAlert
|
||||||
case 5:
|
return m, m.initAlertHuhForm()
|
||||||
if m.isAdmin && len(m.users) > 0 {
|
}
|
||||||
m.editID = m.users[m.cursor].ID
|
case sectionUsers:
|
||||||
m.state = stateFormUser
|
if m.isAdmin && len(m.users) > 0 {
|
||||||
return m, m.initUserHuhForm()
|
m.editID = m.users[m.cursor].ID
|
||||||
|
m.state = stateFormUser
|
||||||
|
return m, m.initUserHuhForm()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -682,43 +759,43 @@ func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
|
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
if len(m.sites) > 0 {
|
if len(m.sites) > 0 {
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
m.deleteName = m.sites[m.cursor].Name
|
m.deleteName = m.sites[m.cursor].Name
|
||||||
m.deleteTab = 0
|
m.deleteTab = tabMonitors
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
}
|
}
|
||||||
case 1:
|
case tabMaint:
|
||||||
if len(m.alerts) > 0 {
|
|
||||||
m.deleteID = m.alerts[m.cursor].ID
|
|
||||||
m.deleteName = m.alerts[m.cursor].Name
|
|
||||||
m.deleteTab = 1
|
|
||||||
m.state = stateConfirmDelete
|
|
||||||
}
|
|
||||||
case 4:
|
|
||||||
if len(m.maintenanceWindows) > 0 {
|
if len(m.maintenanceWindows) > 0 {
|
||||||
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||||
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||||
m.deleteTab = 4
|
m.deleteTab = tabMaint
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
}
|
}
|
||||||
case 5:
|
case tabSettings:
|
||||||
if m.isAdmin && len(m.users) > 0 {
|
switch m.settingsSection {
|
||||||
m.deleteID = m.users[m.cursor].ID
|
case sectionAlerts:
|
||||||
m.deleteName = m.users[m.cursor].Username
|
if len(m.alerts) > 0 {
|
||||||
m.deleteTab = 5
|
m.deleteID = m.alerts[m.cursor].ID
|
||||||
m.state = stateConfirmDelete
|
m.deleteName = m.alerts[m.cursor].Name
|
||||||
|
m.deleteTab = tabSettings
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
|
case sectionUsers:
|
||||||
|
if m.isAdmin && len(m.users) > 0 {
|
||||||
|
m.deleteID = m.users[m.cursor].ID
|
||||||
|
m.deleteName = m.users[m.cursor].Username
|
||||||
|
m.deleteTab = tabSettings
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
tabCount := 5
|
tabCount := tabSettings + 1
|
||||||
if m.isAdmin {
|
|
||||||
tabCount = 6
|
|
||||||
}
|
|
||||||
for i := 0; i < tabCount; i++ {
|
for i := 0; i < tabCount; i++ {
|
||||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||||
m.switchTab(i)
|
m.switchTab(i)
|
||||||
@@ -726,6 +803,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabMonitors {
|
||||||
|
if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
return m, nil
|
||||||
|
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelDetail
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefix, listLen := m.currentZonePrefix()
|
prefix, listLen := m.currentZonePrefix()
|
||||||
end := m.tableOffset + m.maxTableRows
|
end := m.tableOffset + m.maxTableRows
|
||||||
if end > listLen {
|
if end > listLen {
|
||||||
@@ -735,6 +824,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,24 +835,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) switchTab(idx int) {
|
func (m *Model) switchTab(idx int) {
|
||||||
maxTabs := 4
|
maxTabs := tabSettings
|
||||||
if m.isAdmin {
|
|
||||||
maxTabs = 5
|
|
||||||
}
|
|
||||||
if idx > maxTabs {
|
if idx > maxTabs {
|
||||||
idx = 0
|
idx = 0
|
||||||
}
|
}
|
||||||
m.currentTab = idx
|
m.currentTab = idx
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.tableOffset = 0
|
m.tableOffset = 0
|
||||||
switch idx {
|
m.state = stateDashboard
|
||||||
case 2:
|
|
||||||
m.state = stateLogs
|
|
||||||
case 5:
|
|
||||||
m.state = stateUsers
|
|
||||||
default:
|
|
||||||
m.state = stateDashboard
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) adjustCursor(_ int) {
|
func (m *Model) adjustCursor(_ int) {
|
||||||
@@ -791,30 +873,36 @@ func (m *Model) submitForm() tea.Cmd {
|
|||||||
|
|
||||||
func (m Model) currentListLen() int {
|
func (m Model) currentListLen() int {
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 1:
|
case tabMonitors:
|
||||||
return len(m.alerts)
|
|
||||||
case 3:
|
|
||||||
return len(m.nodes)
|
|
||||||
case 4:
|
|
||||||
return len(m.maintenanceWindows)
|
|
||||||
case 5:
|
|
||||||
return len(m.users)
|
|
||||||
default:
|
|
||||||
return len(m.sites)
|
return len(m.sites)
|
||||||
|
case tabMaint:
|
||||||
|
return len(m.maintenanceWindows)
|
||||||
|
case tabSettings:
|
||||||
|
switch m.settingsSection {
|
||||||
|
case sectionAlerts:
|
||||||
|
return len(m.alerts)
|
||||||
|
case sectionNodes:
|
||||||
|
return len(m.nodes)
|
||||||
|
case sectionUsers:
|
||||||
|
return len(m.users)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) currentZonePrefix() (string, int) {
|
func (m Model) currentZonePrefix() (string, int) {
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
return "site", len(m.sites)
|
return "site", len(m.sites)
|
||||||
case 1:
|
case tabMaint:
|
||||||
return "alert", len(m.alerts)
|
|
||||||
case 4:
|
|
||||||
return "maint", len(m.maintenanceWindows)
|
return "maint", len(m.maintenanceWindows)
|
||||||
case 5:
|
case tabSettings:
|
||||||
return "user", len(m.users)
|
switch m.settingsSection {
|
||||||
default:
|
case sectionAlerts:
|
||||||
return "site", 0
|
return "alert", len(m.alerts)
|
||||||
|
case sectionUsers:
|
||||||
|
return "user", len(m.users)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return "site", 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
|
|||||||
mm := updated.(Model)
|
mm := updated.(Model)
|
||||||
found := false
|
found := false
|
||||||
for _, line := range mm.engine.GetLogs() {
|
for _, line := range mm.engine.GetLogs() {
|
||||||
if strings.Contains(line, "Delete site failed: boom") {
|
if strings.Contains(line.Message, "Delete site failed: boom") {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,20 +151,52 @@ func (m Model) viewDashboard() string {
|
|||||||
|
|
||||||
var content string
|
var content string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
content = m.viewSitesTab()
|
showSidebar := m.termWidth >= wideBreakpoint
|
||||||
case 1:
|
if showSidebar {
|
||||||
content = m.viewAlertsTab()
|
availW := m.termWidth - chromePadH
|
||||||
case 2:
|
leftW := availW * 70 / 100
|
||||||
content = m.viewLogsTab()
|
rightW := availW - leftW
|
||||||
case 3:
|
m.contentWidth = leftW - 2
|
||||||
content = m.viewNodesTab()
|
monitors := m.viewSitesTab()
|
||||||
case 4:
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
||||||
content = m.viewMaintTab()
|
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
||||||
case 5:
|
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
||||||
if m.isAdmin {
|
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
||||||
content = m.viewUsersTab()
|
if m.detailOpen {
|
||||||
|
site := ""
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site = m.sites[m.cursor].Name
|
||||||
|
}
|
||||||
|
detail := m.viewDetailInline(availW - 2)
|
||||||
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
|
content = top + "\n" + detailPanel
|
||||||
|
} else {
|
||||||
|
content = top
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.contentWidth = m.termWidth - 2
|
||||||
|
monitors := m.viewSitesTab()
|
||||||
|
availW := m.termWidth - chromePadH
|
||||||
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
||||||
|
if m.detailOpen {
|
||||||
|
site := ""
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site = m.sites[m.cursor].Name
|
||||||
|
}
|
||||||
|
detail := m.viewDetailInline(availW - 2)
|
||||||
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
|
content = monPanel + "\n" + detailPanel
|
||||||
|
} else {
|
||||||
|
content = monPanel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
case tabMaint:
|
||||||
|
m.contentWidth = m.termWidth
|
||||||
|
content = m.viewMaintTab()
|
||||||
|
case tabSettings:
|
||||||
|
m.contentWidth = m.termWidth
|
||||||
|
content = m.viewSettingsTab()
|
||||||
}
|
}
|
||||||
|
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
@@ -177,13 +209,19 @@ func (m Model) viewDashboard() string {
|
|||||||
availHeight = 5
|
availHeight = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
|
divW := m.termWidth - chromePadH
|
||||||
|
if divW < 40 {
|
||||||
|
divW = 40
|
||||||
|
}
|
||||||
|
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
||||||
|
|
||||||
|
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
|
||||||
if contentHeight < 1 {
|
if contentHeight < 1 {
|
||||||
contentHeight = 1
|
contentHeight = 1
|
||||||
}
|
}
|
||||||
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
|
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
|
||||||
|
|
||||||
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
|
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
|
||||||
}
|
}
|
||||||
|
|
||||||
type tabEntry struct {
|
type tabEntry struct {
|
||||||
@@ -193,15 +231,15 @@ type tabEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderTabBar(stats dashboardStats) string {
|
func (m Model) renderTabBar(stats dashboardStats) string {
|
||||||
tabs := []tabEntry{
|
settingsCount := len(m.alerts) + len(m.nodes)
|
||||||
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount},
|
settingsWarn := stats.offlineNodes
|
||||||
{"Alerts", len(m.alerts), 0},
|
|
||||||
{"Logs", 0, 0},
|
|
||||||
{"Nodes", len(m.nodes), stats.offlineNodes},
|
|
||||||
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
|
||||||
}
|
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
tabs = append(tabs, tabEntry{"Users", len(m.users), 0})
|
settingsCount += len(m.users)
|
||||||
|
}
|
||||||
|
tabs := []tabEntry{
|
||||||
|
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
|
||||||
|
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
||||||
|
{"Settings", settingsCount, settingsWarn},
|
||||||
}
|
}
|
||||||
|
|
||||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
|
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
|
||||||
@@ -264,24 +302,38 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
|||||||
|
|
||||||
var keys string
|
var keys string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case tabMonitors:
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit"
|
if m.focusedPanel == panelLogs {
|
||||||
case 1:
|
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
|
||||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
} else if m.detailOpen {
|
||||||
case 2:
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
||||||
keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit"
|
} else {
|
||||||
case 4:
|
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
}
|
||||||
|
case tabMaint:
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||||
case 5:
|
case tabSettings:
|
||||||
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
switch m.settingsSection {
|
||||||
|
case sectionAlerts:
|
||||||
|
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
case sectionUsers:
|
||||||
|
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
default:
|
||||||
|
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
keys = "[T]Theme [Tab]Switch [q]Quit"
|
keys = "[T]Theme [Tab]Switch [q]Quit"
|
||||||
}
|
}
|
||||||
|
|
||||||
ver := m.st.subtleStyle.Render("v" + m.version)
|
ver := m.st.subtleStyle.Render("v" + m.version)
|
||||||
footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||||
if m.filterText != "" && m.currentTab == 0 {
|
if m.filterText != "" && m.currentTab == tabMonitors {
|
||||||
footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||||
}
|
}
|
||||||
return footer
|
|
||||||
|
divW := m.termWidth - chromePadH
|
||||||
|
if divW < 40 {
|
||||||
|
divW = 40
|
||||||
|
}
|
||||||
|
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ func (m Model) viewDetailPanel() string {
|
|||||||
if site.ParentID > 0 {
|
if site.ParentID > 0 {
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if s.ID == site.ParentID {
|
if s.ID == site.ParentID {
|
||||||
breadcrumb = m.st.subtleStyle.Render(" Sites > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
|
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if breadcrumb == "" {
|
if breadcrumb == "" {
|
||||||
breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name)
|
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
|
||||||
}
|
}
|
||||||
b.WriteString(breadcrumb + "\n")
|
b.WriteString(breadcrumb + "\n")
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
@@ -215,7 +215,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, "")))
|
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil)))
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
up := 0
|
up := 0
|
||||||
for _, s := range hist.Statuses {
|
for _, s := range hist.Statuses {
|
||||||
@@ -228,7 +228,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up, len(hist.Statuses))
|
up, len(hist.Statuses))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, "")))
|
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, nil)))
|
||||||
var minL, maxL, total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
count := 0
|
count := 0
|
||||||
for i, l := range hist.Latencies {
|
for i, l := range hist.Latencies {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) viewDetailInline(width int) string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
|
latency := m.fmtLatency(site.Latency)
|
||||||
|
uptime := m.fmtUptime(hist.Statuses)
|
||||||
|
|
||||||
|
line1Parts := []string{status}
|
||||||
|
if site.Latency > 0 {
|
||||||
|
line1Parts = append(line1Parts, latency)
|
||||||
|
}
|
||||||
|
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
|
||||||
|
if !site.LastCheck.IsZero() {
|
||||||
|
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||||
|
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
|
errW := width - 12
|
||||||
|
if errW < 20 {
|
||||||
|
errW = 20
|
||||||
|
}
|
||||||
|
errMsg := limitStr(site.LastError, errW)
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateChanges []models.StateChange
|
||||||
|
if m.detailChangesSiteID == site.ID {
|
||||||
|
stateChanges = m.detailChanges
|
||||||
|
}
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
var parts []string
|
||||||
|
limit := 3
|
||||||
|
if len(stateChanges) < limit {
|
||||||
|
limit = len(stateChanges)
|
||||||
|
}
|
||||||
|
for _, sc := range stateChanges[:limit] {
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
arrow := m.st.subtleStyle.Render("→")
|
||||||
|
from := m.fmtStatusWord(sc.FromStatus)
|
||||||
|
to := m.fmtStatusWord(sc.ToStatus)
|
||||||
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||||
|
if sc.ErrorReason != "" {
|
||||||
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||||
|
}
|
||||||
|
parts = append(parts, entry)
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hist.Latencies) > 0 {
|
||||||
|
sparkW := width - 30
|
||||||
|
if sparkW < 10 {
|
||||||
|
sparkW = 10
|
||||||
|
}
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
|
||||||
|
minMs := hist.Latencies[0].Milliseconds()
|
||||||
|
maxMs := hist.Latencies[0].Milliseconds()
|
||||||
|
var sumMs int64
|
||||||
|
for _, l := range hist.Latencies {
|
||||||
|
ms := l.Milliseconds()
|
||||||
|
if ms < minMs {
|
||||||
|
minMs = ms
|
||||||
|
}
|
||||||
|
if ms > maxMs {
|
||||||
|
maxMs = ms
|
||||||
|
}
|
||||||
|
sumMs += ms
|
||||||
|
}
|
||||||
|
avgMs := sumMs / int64(len(hist.Latencies))
|
||||||
|
stats := fmt.Sprintf("Min %s Avg %s Max %s",
|
||||||
|
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
|
||||||
|
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
|
||||||
|
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
|
||||||
|
b.WriteString(" " + spark + " " + stats + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||||
|
b.WriteString(" " + keys + "\n")
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "DOWN":
|
||||||
|
return m.st.dangerStyle.Render("DOWN")
|
||||||
|
case "UP":
|
||||||
|
return m.st.specialStyle.Render("UP")
|
||||||
|
default:
|
||||||
|
return m.st.subtleStyle.Render(status)
|
||||||
|
}
|
||||||
|
}
|
||||||